96 Commits

Author SHA1 Message Date
melod1n 389d3f9e52 Style: Update message bubble colors
Updates the container colors for incoming and outgoing message bubbles to align with Material 3 design tokens.

- The outgoing message bubble container color is changed from `surfaceColorAtElevation(2.dp)` to `surfaceContainer`.
- The reply container color within an outgoing message is changed from `primaryContainer` to `surfaceContainerHighest`.

Additionally, the `@Preview` for `MessageBubble` is updated to display both an incoming and an outgoing message for better design validation.
2025-12-15 23:08:42 +03:00
melod1n 69a50f8fcd Refactor: Encapsulate MessageBubble colors
This commit refactors the `MessageBubble` composable by extracting the color logic into a private `messageBubbleColors` function. This function returns an immutable `MessageBubbleColors` data class, which holds the container, content, and reply container colors.

This change cleans up the main composable, improves readability, and centralizes color definitions for both incoming and outgoing messages. Additionally, the background color logic for attachments has been simplified to make it transparent for media types like stickers and videos.
2025-12-15 23:01:38 +03:00
melod1n 8839015249 Fix: Ensure sender's name truncates correctly in incoming messages
This commit resolves an issue where the sender's name in incoming message bubbles would not truncate properly, potentially breaking the layout. The fix ensures the name text correctly adapts to the width of the message bubble.

Additionally, this change introduces `ImmutableList` for message attachments to improve performance and refactors where the conversion to `ImmutableList` happens, moving it into the `MessageMapper`.

Key changes:
- The `MessageBubble` now reports its width, allowing the sender's name `Text` to be constrained correctly.
- Sender's name now uses `labelMedium` typography.
- Enabled showing the sender's name by default in `MessagesHistoryViewModelImpl`.
- Changed `UiItem.Message.attachments` from `List` to `ImmutableList` for better Compose performance.
- Moved the `toImmutableList()` conversion for attachments into the `MessageMapper`.
2025-12-15 22:58:50 +03:00
melod1n 478639e427 Refactor: Extract RootScreen from MainActivity and fix reply message user
This commit refactors the UI composition logic by extracting it from `MainActivity` into a new, dedicated `RootScreen` composable. This improves the separation of concerns and simplifies `MainActivity`.

Additionally, a bug has been fixed where a replied-to message would incorrectly display the author of the parent message instead of its own author.

Key changes:
- Moved theme setup, permission handling, Long-Poll/Online service management, and navigation graph hosting into the new `RootScreen.kt`.
- `MainActivity` is now significantly simplified, delegating its UI composition to `RootScreen`.
- Corrected the user and group assignment for `replyMessage` in `MessagesRepositoryImpl` to ensure the correct author is displayed.
- Introduced `OnlineFriendsViewModel` to the `FriendsRoute` to separate the logic for online friends.
- Replaced `List` with a custom `ImmutableList` for `photoViewerInfo` state to improve Compose stability.
2025-12-15 22:24:17 +03:00
dependabot[bot] dcbfd43896 Chore(deps): Bump actions/upload-artifact from 5 to 6 (#249)
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 5 to 6.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-15 21:39:44 +03:00
melod1n 7b2c102470 feat(messages): Implement "message read by" counter
This commit introduces the ability to see how many people have read an outgoing message in a group chat. A "views" count is now displayed in the message options dialog for relevant messages.

- **API & Data Layer:**
  - Added `getMessageReadPeers` to `MessagesService` and `MessagesRepository` to fetch users who have read a specific message.
  - Introduced `MessagesGetReadPeersResponse` to handle the API response.
  - A new URL constant `GET_MESSAGE_READ_PEERS` was added.

- **Domain Layer:**
  - A new `GetMessageReadPeersUseCase` is created to provide the view count to the ViewModel.
  - The use case is registered in the `DomainModule`.

- **ViewModel:**
  - `MessagesHistoryViewModel` now includes `loadMessageReadPeers` to asynchronously fetch and return the view count for a message.

- **UI (Compose):**
  - The `MessageOptionsDialog` now displays a "views" count for outgoing chat messages.
  - It uses a `LaunchedEffect` to call `loadMessageReadPeers` when the dialog is shown.
  - The `visibility` icon has been updated and its XML file renamed to `round_visibility_24px.xml` to follow a consistent naming convention.

- **Refactoring & Minor Fixes:**
  - Simplified several `derivedStateOf` usages to direct calculations or property delegates in `MessageBubble.kt` and `MessagesList.kt` for minor performance improvements.
  - Renamed `IncomingMessageBubble.kt` and `OutgoingMessageBubble.kt` to `MessageBubbleIncoming.kt` and `MessageBubbleOutgoing.kt` respectively for consistency.
  - Removed an unnecessary log statement in `MessagesList.kt`.
2025-12-06 14:37:08 +03:00
melod1n 5310596cf6 Refactor: Improve swipe-to-dismiss and image sharing
This commit refactors the swipe-to-dismiss gesture and the image sharing logic in the photo viewer.

The swipe-to-dismiss animation is now smoother and more reliable, using `Animatable` instead of `animateFloatAsState`. The background dimming effect has also been improved to be more responsive to the drag gesture.

Additionally, the responsibility for creating the share `Intent` has been moved from the composable screen into the `PhotoViewViewModel`, improving the separation of concerns.

Key changes:
- Replaced `animateFloatAsState` with `Animatable` for smoother swipe-to-dismiss animations.
- Improved the alpha calculation for the background during the drag gesture.
- Moved the creation of the share `Intent` into the `PhotoViewViewModel`.
- Simplified the drag-handling logic by removing local state management from the composable.
2025-12-06 09:00:01 +03:00
melod1n 65ff74622a Update README.md 2025-12-06 03:39:25 +03:00
melod1n f48878f003 Refactor: Implement swipe-to-reply and redesign input bar
This commit introduces the ability to reply to a message by swiping it to the right. The message input bar and related components have been redesigned and refactored for a cleaner look and better user experience.

Key changes:
- Added a swipe-to-reply gesture on message bubbles.
- Redesigned the message `InputBar` with updated styling, animations, and rounded corners that adapt to the reply state.
- Renamed `MessagesHistoryInputBar` to a more generic `InputBar`.
- Introduced `FastTextField`, a customized `BasicTextField`, for better performance and control.
- Replaced `IconButton` with `FastIconButton` and `RippledClickContainer` in several places for consistent click handling.
- Refactored `PinnedMessageContainer` and `ReplyContainer` with improved UI.
- Updated the Compose BOM to `2025.12.00`.
2025-12-06 03:35:14 +03:00
melod1n c666bd46f3 Potential fix for code scanning alert no. 1: Workflow does not contain permissions
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2025-12-03 06:25:22 +03:00
melod1n 421ca27758 Potential fix for code scanning alert no. 2: Workflow does not contain permissions
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2025-12-03 06:24:52 +03:00
melod1n 8231062ca5 Refactor: Move RippledClickContainer to core UI module
Moves the `RippledClickContainer` composable from the `messageshistory` feature module to the `core/ui` module to allow for reuse across different features.

Additionally, this change introduces text truncation with an ellipsis for the title and text within the `ReplyContainer` to prevent long content from breaking the layout.
2025-12-03 06:22:53 +03:00
melod1n 723555f634 feat(messages): Implement reply functionality
This commit introduces the ability to reply to messages.

- **API & Data Layer:**
  - Replaced `replyTo` parameter with `forward` in `sendMessage` calls across the data, domain, and repository layers to support the new reply mechanism.

- **ViewModel:**
  - Added logic to handle the reply state, including storing the replied message's ID (`replyToCmId`).
  - When a message is sent, it now correctly constructs a `forward` JSON object if it is a reply.
  - The UI state (`MessagesHistoryScreenState`) is updated to show and hide the reply preview.
  - Added a `onReplyCloseClicked` handler to cancel a reply.
  - The ViewModel interface was removed, and the implementation class `MessagesHistoryViewModelImpl` is used directly.

- **UI (Compose):**
  - A new `ReplyContainer` is displayed above the message input bar when a reply is active.
  - The input bar's corner radius animates to integrate with the reply container.
  - Added a `FocusRequester` to automatically focus the input field when the reply action is selected.
  - Added spacing in the message list to prevent the reply preview from overlapping messages.
  - The message options dialog now passes the `messageId` and `cmId` when an option is picked.
2025-12-03 06:12:44 +03:00
melod1n 3e05744a18 Update README.md 2025-12-03 06:07:45 +03:00
melod1n 821ee46cef feat(messages): Implement reply functionality
This commit introduces the ability to reply to messages.

- **API & Data Layer:**
  - Replaced `replyTo` parameter with `forward` in `sendMessage` calls across the data, domain, and repository layers to support the new reply mechanism.

- **ViewModel:**
  - Added logic to handle the reply state, including storing the replied message's ID (`replyToCmId`).
  - When a message is sent, it now correctly constructs a `forward` JSON object if it is a reply.
  - The UI state (`MessagesHistoryScreenState`) is updated to show and hide the reply preview.
  - Added a `onReplyCloseClicked` handler to cancel a reply.
  - The ViewModel interface was removed, and the implementation class `MessagesHistoryViewModelImpl` is used directly.

- **UI (Compose):**
  - A new `ReplyContainer` is displayed above the message input bar when a reply is active.
  - The input bar's corner radius animates to integrate with the reply container.
  - Added a `FocusRequester` to automatically focus the input field when the reply action is selected.
  - Added spacing in the message list to prevent the reply preview from overlapping messages.
  - The message options dialog now passes the `messageId` and `cmId` when an option is picked.
2025-12-03 06:07:03 +03:00
melod1n dcddddea9b Refactor: Exclude outgoing messages from being marked as read
The "Mark as read" option will no longer be shown for outgoing messages in the message options dialog, as they are implicitly read.
2025-12-03 06:06:37 +03:00
melod1n 018151ad18 add confirmation dialog to chat creation
This commit introduces a confirmation dialog before creating a new chat. The dialog displays the final chat title, which is now dynamically generated based on the user's input or the names of the selected participants.

Key changes:
- Added a confirmation dialog that appears when the user clicks the "create chat" button.
- Implemented logic to generate a provisional chat title from participants' names if no title is explicitly set.
- Refactored `CreateChatViewModel` by removing the interface and simplifying the implementation.
- Added new string resources for the confirmation dialog.
2025-12-02 05:31:09 +03:00
melod1n 6f55251fb7 new file: app/keystore/keystore.jks 2025-12-02 04:10:35 +03:00
melod1n 83772fb3ec remove keystore 2025-12-02 04:10:00 +03:00
melod1n d5ee50a979 update keystore.jks 2025-12-02 04:01:05 +03:00
melod1n 079c4178ec Update keystore.jks 2025-12-02 03:48:37 +03:00
melod1n 21bcacded8 disable cleartext traffic 2025-12-02 03:30:24 +03:00
melod1n 3a272376c1 Refactor: replace material icons with local drawables and bump libs 2025-12-02 02:21:45 +03:00
dependabot[bot] ea6c094b4d Bump actions/upload-artifact from 4 to 5 (#247)
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4 to 5.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-02 00:02:14 +03:00
melod1n 91b87f6fa5 fix deprecations 2025-10-09 06:46:25 +03:00
melod1n a5952098a2 new attachment: gift 2025-10-09 06:32:57 +03:00
melod1n 4f1149c8d8 bump libs 2025-10-09 06:20:28 +03:00
melod1n 56bf6ac063 bump libs 2025-10-07 20:12:51 +03:00
melod1n 1b9401673d Update README.md 2025-09-10 10:25:08 +03:00
melod1n db92b261c0 vk.com -> vk.ru 2025-09-10 10:24:53 +03:00
dependabot[bot] 42ad04093d Bump agp from 8.12.2 to 8.13.0 (#231) 2025-09-08 20:31:10 +00:00
dependabot[bot] 9535847bf3 Bump koin from 4.1.0 to 4.1.1 (#232) 2025-09-08 20:31:07 +00:00
dependabot[bot] b3c35ec6f5 Bump com.google.android.material:material from 1.12.0 to 1.13.0 (#233) 2025-09-08 20:31:02 +00:00
dependabot[bot] f5853f61c3 Bump lifecycle from 2.9.2 to 2.9.3 (#228) 2025-08-31 10:23:56 +00:00
dependabot[bot] 620cf53c65 Bump androidx.compose:compose-bom-alpha from 2025.08.00 to 2025.08.01 (#229) 2025-08-31 10:23:51 +00:00
dependabot[bot] 411776b767 Bump agp from 8.12.1 to 8.12.2 (#230) 2025-08-31 10:23:46 +00:00
melod1n 8cb3ed8784 some improvements, new feature 2025-08-27 05:17:40 +03:00
melod1n 4677e484d9 move strings in core/ui module
disable generating android resources everywhere except core/ui module
replace UiR with R
2025-08-27 04:53:46 +03:00
melod1n 799ed820e3 feat: audio message preview 2025-08-26 09:36:55 +03:00
dependabot[bot] 3fd679e65d Bump actions/setup-java from 4 to 5 (#227) 2025-08-23 01:38:06 +00:00
dependabot[bot] 6b95deb7bf Bump agp from 8.12.0 to 8.12.1 (#226) 2025-08-19 21:36:23 +00:00
melod1n 47c1f623f0 Refactor: Introduce video message record mode
This commit refactors the `ActionMode` sealed class into an enum and adds a new `RECORD_VIDEO` state. This allows for distinct actions for recording audio and video messages.

Specifically, the following changes were made:
- Converted `ActionMode` from a sealed class to an enum.
- Added `RECORD_VIDEO` to `ActionMode`.
- Updated `MessagesHistoryInputBar` to:
    - Animate the action button icon change between record modes.
    - Remove the shake animation from emoji, attachment, and mic buttons.
- Updated `MessagesHistoryViewModel` to toggle between `RECORD_AUDIO` and `RECORD_VIDEO` when the action button is clicked in a record mode.
- Added support for displaying `VIDEO_MESSAGE` attachments in `Attachments.kt`, including an animated circular preview.
- Updated `MessageBubble` to render video messages without a background, similar to stickers.
- Added `image` property to `VkVideoMessageDomain` to hold the URL for the video message preview.
- Added a new drawable `rounded_photo_camera_24` for the video record button.
- Updated `VkVideoMessageData` to parse and provide the square preview image URL to the domain model.
2025-08-20 00:31:33 +03:00
melod1n 600aed40e7 Refactor: Make FullScreenDialog dismissible
This commit introduces a dismiss functionality to the `FullScreenDialog`.

The `FullScreenDialog` now accepts an `onDismiss` lambda parameter.
This `onDismiss` lambda is invoked when a dismiss request is made for the dialog.

In `PhotoViewScreen`, the `onDismiss` lambda passed to `FullScreenDialog` is now the `onDismiss` lambda received by the `PhotoViewScreen` itself.
2025-08-19 23:15:37 +03:00
melod1n 22e8a5c09e Prevent PhotoViewer share/copy while loading
This commit prevents the share and copy actions in the PhotoViewer
from being triggered if the content is still loading.
2025-08-19 22:58:13 +03:00
melod1n 252f6ec21e Refactor: Use Dialog for PhotoViewScreen
This commit refactors the PhotoViewScreen to be displayed as a Dialog instead of a separate navigation destination.

Key changes:
- Introduced `PhotoViewDialog` composable that wraps `PhotoViewRoute` in a `FullScreenDialog`.
- Modified `RootScreen` to use `PhotoViewDialog` for displaying images.
- Updated `PhotoViewViewModelImpl` to handle loading state and display a loader while downloading images.
- Made `Loader` and `ContainedLoader` colors configurable.
- Adjusted `PhotoViewScreen` UI:
    - Set background to translucent black.
    - Updated TopAppBar background color and icon tints.
    - Improved vertical drag gesture for dismissing the viewer.
- Made `VkUserData.LastSeen.platform` nullable.
- Removed unused navigation functions related to the old PhotoViewScreen.
2025-08-19 22:54:38 +03:00
melod1n 7e25bc3a8d Update target and compile SDK to 36
Bumps various dependencies:
- Haze from 1.6.9 to 1.6.10
- Kotlin from 2.2.0 to 2.2.10
- KSP from 2.2.0-2.0.2 to 2.2.10-2.0.2
- Compose BOM from 2025.07.01 to 2025.08.00
- Core KTX from 1.16.0 to 1.17.0
- Nanokt from 1.2.0 to 1.3.0
2025-08-17 07:19:39 +03:00
dependabot[bot] 7b0f6fe2a6 Bump actions/checkout from 4 to 5 (#220) 2025-08-17 04:07:36 +00:00
dependabot[bot] 6d5b09ef81 Bump haze from 1.6.8 to 1.6.9 (#218) 2025-08-08 22:14:19 +00:00
dependabot[bot] 268e0a8beb Bump agp from 8.11.1 to 8.12.0 (#219) 2025-08-08 22:14:15 +00:00
dependabot[bot] 9e67ad0834 Bump lifecycle from 2.9.1 to 2.9.2 (#213) 2025-07-31 19:49:49 +00:00
dependabot[bot] 34ea8ef944 Bump chucker from 4.1.0 to 4.2.0 (#211) 2025-07-31 19:49:34 +00:00
dependabot[bot] 3d0c310575 Bump haze from 1.6.7 to 1.6.8 (#212) 2025-07-31 19:49:28 +00:00
dependabot[bot] 602cf8f18b Bump androidx.compose:compose-bom-alpha from 2025.06.02 to 2025.07.01 (#217) 2025-07-31 19:49:24 +00:00
dependabot[bot] 1b3581fcfd Bump androidx.navigation:navigation-compose from 2.9.1 to 2.9.3 (#216) 2025-07-31 19:49:19 +00:00
dependabot[bot] 4b254d7d41 Bump agp from 8.11.0 to 8.11.1 (#210) 2025-07-14 09:27:25 +00:00
dependabot[bot] d98dca83f1 Bump haze from 1.6.6 to 1.6.7 (#209) 2025-07-09 17:54:25 +00:00
melod1n 9e6b079bf6 ability to import/export auth data and some refactoring 2025-07-09 17:29:51 +03:00
dependabot[bot] d2aaac68e2 Bump org.jetbrains.kotlinx:kotlinx-serialization-json (#202)
Bumps [org.jetbrains.kotlinx:kotlinx-serialization-json](https://github.com/Kotlin/kotlinx.serialization) from 1.8.1 to 1.9.0.
- [Release notes](https://github.com/Kotlin/kotlinx.serialization/releases)
- [Changelog](https://github.com/Kotlin/kotlinx.serialization/blob/master/CHANGELOG.md)
- [Commits](https://github.com/Kotlin/kotlinx.serialization/compare/v1.8.1...v1.9.0)

---
updated-dependencies:
- dependency-name: org.jetbrains.kotlinx:kotlinx-serialization-json
  dependency-version: 1.9.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-09 13:49:55 +03:00
dependabot[bot] ada9c13947 Bump androidx.navigation:navigation-compose from 2.9.0 to 2.9.1 (#206) 2025-07-09 10:48:11 +00:00
dependabot[bot] cd4c1d6d76 Bump haze from 1.6.4 to 1.6.6 (#203) 2025-07-09 08:43:00 +00:00
dependabot[bot] fab92d50d6 Bump androidx.compose:compose-bom-alpha from 2025.06.01 to 2025.06.02 (#205) 2025-07-09 08:42:52 +00:00
dependabot[bot] 9e4521c3a0 Bump com.squareup.okhttp3:logging-interceptor (#208) 2025-07-09 08:42:41 +00:00
melod1n 70b552412c Enhance PhotoViewer with share and open-in actions, improve reply UI
This commit introduces "Share" and "Open in..." actions to the `PhotoViewScreen`, allowing users to share images via other apps or open them in external viewers.

**Key changes:**

- **PhotoViewer:**
    - Added "Share" and "Open in..." options to the `PhotoViewScreen` dropdown menu.
    - `PhotoViewViewModel`:
        - Implemented `onShareClicked()` and `onOpenInClicked()` to handle these new actions.
        - Added `shareRequest` StateFlow to manage image sharing intents.
        - Introduced `downloadAndStoreImageToCache()` to download and cache images for sharing.
        - `onImageShared()` resets `shareRequest` after sharing.
    - Updated `TopBar` to include the new menu items.
    - Added string resources for "Open in…" and "Share".
- **Reply UI:**
    - `Reply.kt`: Title and summary text now use `TextOverflow.Ellipsis` to prevent long text from breaking the layout.
- **API Model:**
    - `MessagesResponse.kt`: Added `MessagesMarkAsImportantResponse` data class to handle the response for marking messages as important.
- **Data Layer:**
    - `MessagesRepositoryImpl`: Updated `markAsImportant` to correctly map the API response using `MessagesMarkAsImportantResponse`.
- **Minor:**
    - `README.md`: Updated feature checklist for external viewer.
    - `ApplicationModule.kt`: Added experimental Coil API opt-in.
2025-06-26 20:46:53 +03:00
dependabot[bot] a7307e7862 Bump agp from 8.10.1 to 8.11.0 (#197) 2025-06-26 16:54:50 +00:00
melod1n 60a30b9422 feat: Display stickers in messages (#200)
This commit introduces the ability to display stickers within message bubbles.

Key changes:
- `Attachments.kt`: Added handling for `AttachmentType.STICKER`. If an attachment type is unsupported, a placeholder text is now displayed.
- `Sticker.kt`: New composable created to render `VkStickerDomain` using `AsyncImage`.
- `MessageBubble.kt`:
    - Adjusted background alpha for sticker messages to make the bubble transparent.
    - Minor refactoring of `minDateContainerWidth` and `dateContainerWidth` initialization.
- `VkStickerDomain.kt`: Added `getUrl()` function to construct sticker image URLs, with options for specifying width and background.
2025-06-26 19:54:33 +03:00
melod1n 93d81f1e9e Refactor: Rename addTextContextMenuComponents to appendTextContextMenuComponents
This commit also updates the Compose BOM version to `2025.06.01`.
2025-06-25 09:36:00 +03:00
melod1n 5be101deec Refactor: Move versioning to build files, update dependencies
This commit moves the `minSdk`, `targetSdk`, `compileSdk`, `versionCode`, and `versionName` definitions from `gradle/libs.versions.toml` directly into the relevant build files (`app/build.gradle.kts` and convention plugins).

Additionally, the following dependencies were updated:
- Gradle wrapper from 8.12 to 8.14.2
- KSP from 2.1.21-2.0.2 to 2.2.0-2.0.2
- ModuleGraph from 2.8.0 to 2.9.0

The `fast-android-test` and `fast-jvm-library` plugin aliases were also commented out in `gradle/libs.versions.toml`.
2025-06-25 09:33:26 +03:00
melod1n 76dd1e2ce7 Reply attachment (#195) 2025-06-25 09:04:50 +03:00
dependabot[bot] 56683bea96 Bump kotlin from 2.1.21 to 2.2.0 (#194) 2025-06-24 11:46:37 +00:00
melod1n 3dae1fe101 Refactor: Introduce FullScreenContainedLoader and use rememberUpdatedState
This commit introduces `FullScreenContainedLoader` and replaces usages of `FullScreenLoader` where appropriate.

It also updates several composables to use `rememberUpdatedState` for lambda parameters to ensure the latest versions are used.

Additionally, the following changes are included:
- Add a setting to show/hide the attachment button in the chat input bar.
- Implement navigation to `PhotoViewScreen` when a photo attachment is clicked in a message.
- Add "Copy link" and "Copy image" actions to `PhotoViewScreen`.
- Remove unused settings and their corresponding logic from `SettingsViewModel` and `UserSettings`.
2025-06-24 14:44:51 +03:00
melod1n c1e76e1c60 improvements 2025-06-23 20:49:14 +03:00
melod1n c14ee45d53 improvements 2025-06-23 19:50:28 +03:00
melod1n 9d4e3f50ea split MessagesHistoryScreen.kt in separated composables.
Added "regular" to text field in messages history screen - for clearing formatting
2025-06-23 17:36:02 +03:00
melod1n 17b5c944ac m3 expressive theme and full screen loader
revert pre-loading non-main screens
2025-06-20 21:55:17 +03:00
melod1n 5aa1f21183 switch to compose-bom-alpha 2025-06-20 21:25:17 +03:00
dependabot[bot] a916dc649c Bump room from 2.7.1 to 2.7.2 (#191) 2025-06-20 16:09:23 +00:00
dependabot[bot] 7e0b9d49ba Bump androidx.compose:compose-bom from 2025.06.00 to 2025.06.01 (#192) 2025-06-20 16:09:18 +00:00
dependabot[bot] d525a1573c Bump koin from 4.0.4 to 4.1.0 (#189) 2025-06-16 22:36:35 +00:00
dependabot[bot] 091a88dd45 Bump ksp from 2.1.21-2.0.1 to 2.1.21-2.0.2 (#190) 2025-06-16 22:36:31 +00:00
melod1n a3a970115a Bump deps 2025-06-09 13:29:36 +03:00
dependabot[bot] e36c1ea0ca Bump haze from 1.6.2 to 1.6.3 (#185) 2025-06-03 00:10:25 +00:00
dependabot[bot] f2437f67cb Bump com.squareup.okhttp3:logging-interceptor (#184) 2025-05-30 08:10:27 +00:00
dependabot[bot] 049bcfd2da Bump agp from 8.10.0 to 8.10.1 (#183) 2025-05-30 08:10:21 +00:00
dependabot[bot] d5e24214ce Bump haze from 1.6.1 to 1.6.2 (#182) 2025-05-21 18:48:06 +00:00
dependabot[bot] c94e128e64 Bump androidx.compose:compose-bom from 2025.05.00 to 2025.05.01 (#181) 2025-05-20 21:24:10 +00:00
dependabot[bot] 0836816391 Bump haze from 1.6.0 to 1.6.1 (#180) 2025-05-19 18:27:08 +00:00
melod1n be58949372 Update build.yml 2025-05-16 05:09:53 +03:00
melod1n 618388a719 update workflow to upload apks with build time in name 2025-05-16 05:09:04 +03:00
melod1n 7369fd5c70 Bump retrofit and converter-moshi from 2.11.0 to 3.0.0 2025-05-16 01:21:07 +03:00
dependabot[bot] b668e86622 Bump ksp from 2.1.20-2.0.1 to 2.1.21-2.0.1 (#177) 2025-05-15 22:08:57 +00:00
melod1n aa4326f09e chore: update readme 2025-05-13 23:30:04 +03:00
dependabot[bot] 0a8d26a8f0 Bump kotlin from 2.1.20 to 2.1.21 (#175) 2025-05-13 20:10:14 +00:00
dependabot[bot] 08d20f7a0f Bump com.google.accompanist:accompanist-permissions (#174) 2025-05-13 19:53:51 +00:00
melod1n 325211ad5f refactor: improve auth screen and bump haze version
* Bump haze version to 1.6.0.
* Blur now works on android 11 and older
* Add "Sign up" and "Forgot password?" links to the auth screen.
* Add logic to toggle dynamic colors on logo click in the auth screen (Android 12+).
2025-05-13 22:52:53 +03:00
melod1n b63cc86e48 pre-warm up the main screens when the application is launched 2025-05-11 22:36:41 +03:00
melod1n 628b93e4ab Bump project version (#173) 2025-05-11 21:52:37 +03:00
166 changed files with 6802 additions and 4407 deletions
+28 -14
View File
@@ -1,10 +1,10 @@
name: Android CI Build name: Android CI Build
on: on:
push: workflow_dispatch:
branches: [ "master", "hotfix/*", "feature/*" ]
pull_request: permissions:
branches: [ "master", "hotfix/*", "feature/*" ] contents: read
env: env:
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }} KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
@@ -17,10 +17,10 @@ jobs:
name: Build artifacts name: Build artifacts
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v5
- name: set up JDK 21 - name: set up JDK 21
uses: actions/setup-java@v4 uses: actions/setup-java@v5
with: with:
java-version: '21' java-version: '21'
distribution: 'temurin' distribution: 'temurin'
@@ -32,17 +32,31 @@ jobs:
- name: Build and sign release APK - name: Build and sign release APK
run: ./gradlew assembleRelease run: ./gradlew assembleRelease
- name: Upload release APK - name: Find generated release APK name
uses: actions/upload-artifact@v4 id: find_apk_release
run: |
APK_PATH=$(find app/build/outputs/apk/release -name "*.apk" | head -n 1)
echo "APK_PATH=$APK_PATH" >> $GITHUB_ENV
echo "APK_NAME=$(basename $APK_PATH)" >> $GITHUB_ENV
- name: Upload APK with original name
uses: actions/upload-artifact@v6
with: with:
name: app-release.apk name: ${{ env.APK_NAME }}
path: app/build/outputs/apk/release/app-release.apk path: ${{ env.APK_PATH }}
- name: Build and sign debug APK - name: Build and sign debug APK
run: ./gradlew assembleDebug run: ./gradlew assembleDebug
- name: Upload debug APK - name: Find generated debug APK name
uses: actions/upload-artifact@v4 id: find_apk_debug
run: |
APK_PATH=$(find app/build/outputs/apk/debug -name "*.apk" | head -n 1)
echo "APK_PATH=$APK_PATH" >> $GITHUB_ENV
echo "APK_NAME=$(basename $APK_PATH)" >> $GITHUB_ENV
- name: Upload APK with original name
uses: actions/upload-artifact@v6
with: with:
name: app-debug.apk name: ${{ env.APK_NAME }}
path: app/build/outputs/apk/debug/app-debug.apk path: ${{ env.APK_PATH }}
+7 -4
View File
@@ -1,6 +1,9 @@
name: Android CI Release name: Android CI Release
permissions:
contents: read
on: on:
workflow_dispatch:
push: push:
branches: [ "release/*"] branches: [ "release/*"]
@@ -15,10 +18,10 @@ jobs:
name: Build artifacts name: Build artifacts
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v5
- name: set up JDK 21 - name: set up JDK 21
uses: actions/setup-java@v4 uses: actions/setup-java@v5
with: with:
java-version: '21' java-version: '21'
distribution: 'temurin' distribution: 'temurin'
@@ -31,7 +34,7 @@ jobs:
run: ./gradlew assembleRelease run: ./gradlew assembleRelease
- name: Upload release APK - name: Upload release APK
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v6
with: with:
name: app-release.apk name: app-release.apk
path: app/build/outputs/apk/release/app-release.apk path: app/build/outputs/apk/release/app-release.apk
@@ -40,7 +43,7 @@ jobs:
run: ./gradlew bundleRelease run: ./gradlew bundleRelease
- name: Upload release Bundle - name: Upload release Bundle
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v6
with: with:
name: app-release.aab name: app-release.aab
path: app/build/outputs/bundle/release/app-release.aab path: app/build/outputs/bundle/release/app-release.aab
+21 -12
View File
@@ -7,15 +7,17 @@ Unofficial messenger for russian social network VKontakte
- [x] 2FA support - [x] 2FA support
- [x] Resend otp - [x] Resend otp
- [x] Captcha support - [x] Captcha support
- [ ] Support for new authorization with service and refresh tokens - [x] Support for new authorization with service and refresh tokens
- [ ] Handle token expiration
- [x] Ability to export/import tokens
- [x] Conversations list - [x] Conversations list
- [x] Pagination - [x] Pagination
- [x] Manual refresh - [x] Manual refresh
- [x] Pin & unpin conversations - [x] Pin & unpin conversations
- [x] Delete conversations - [x] Delete conversations
- [ ] Archive - [x] Archive
- [ ] View archived conversations - [x] View archived conversations
- [ ] Archive & unarchive conversations - [x] Archive & unarchive conversations
- [x] Friends list - [x] Friends list
- [x] Sort alphabetically, by priority or random - [x] Sort alphabetically, by priority or random
- [x] Separate tab with only friends who are online - [x] Separate tab with only friends who are online
@@ -30,17 +32,24 @@ Unofficial messenger for russian social network VKontakte
- [x] Read status - [x] Read status
- [x] Edit status - [x] Edit status
- [x] Sending status - [x] Sending status
- [ ] Message's attachments - [x] Message's attachments
- [ ] Photo - [x] Photo
- [ ] Video - [x] Video
- [ ] Audio - [x] Audio
- [ ] File - [x] File
- [ ] Link - [x] Link
- [x] Sticker
- [x] Reply
- [ ] Forwarded messages
- [ ] Wall post
- [ ] Comment in wall post
- [ ] Poll
- [ ] TODO - [ ] TODO
- [x] Send messages - [x] Send messages
- [x] Pinned message - [x] Pinned message
- [x] Pin & unpin messages - [x] Pin & unpin messages
- [ ] Reply to message - [x] Reply to message
- [x] Swipe to reply to message
- [x] Delete message - [x] Delete message
- [x] Select multiple messages - [x] Select multiple messages
- [x] Delete - [x] Delete
@@ -55,7 +64,7 @@ Unofficial messenger for russian social network VKontakte
- [x] View attachments - [x] View attachments
- [x] Open photo - [x] Open photo
- [x] Internal viewer - [x] Internal viewer
- [ ] External viewer - [x] External viewer
- [ ] Open video in external player - [ ] Open video in external player
- [ ] TODO - [ ] TODO
- [ ] Caching - [ ] Caching
+15 -2
View File
@@ -1,3 +1,4 @@
import com.android.build.gradle.internal.api.BaseVariantOutputImpl
import java.util.Properties import java.util.Properties
plugins { plugins {
@@ -12,8 +13,8 @@ android {
defaultConfig { defaultConfig {
applicationId = "dev.meloda.fastvk" applicationId = "dev.meloda.fastvk"
versionCode = libs.versions.versionCode.get().toInt() versionCode = 10
versionName = libs.versions.versionName.get() versionName = "0.2.2"
} }
signingConfigs { signingConfigs {
@@ -58,6 +59,18 @@ android {
} }
} }
applicationVariants.all {
outputs.all {
val date = System.currentTimeMillis() / 1000
val buildType = buildType.name
val appVersion = versionName
val appVersionCode = versionCode
val newApkName = "app-$buildType-v$appVersion($appVersionCode)-$date.apk"
(this as? BaseVariantOutputImpl)?.outputFileName = newApkName
}
}
packaging { packaging {
resources { resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}" excludes += "/META-INF/{AL2.0,LGPL2.1}"
Binary file not shown.
@@ -5,6 +5,7 @@ import android.content.res.Resources
import android.os.PowerManager import android.os.PowerManager
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import coil.ImageLoader import coil.ImageLoader
import coil.annotation.ExperimentalCoilApi
import dev.meloda.fast.MainViewModelImpl import dev.meloda.fast.MainViewModelImpl
import dev.meloda.fast.auth.captcha.di.captchaModule import dev.meloda.fast.auth.captcha.di.captchaModule
import dev.meloda.fast.auth.login.di.loginModule import dev.meloda.fast.auth.login.di.loginModule
@@ -33,6 +34,7 @@ import org.koin.core.qualifier.qualifier
import org.koin.dsl.bind import org.koin.dsl.bind
import org.koin.dsl.module import org.koin.dsl.module
@OptIn(ExperimentalCoilApi::class)
val applicationModule = module { val applicationModule = module {
includes(domainModule) includes(domainModule)
includes( includes(
@@ -66,6 +68,7 @@ val applicationModule = module {
ImageLoader.Builder(get()) ImageLoader.Builder(get())
.crossfade(true) .crossfade(true)
.build() .build()
.also { it.diskCache?.directory?.toFile()?.listFiles() }
} }
singleOf(::LongPollControllerImpl) bind LongPollController::class singleOf(::LongPollControllerImpl) bind LongPollController::class
@@ -10,7 +10,7 @@ import dev.meloda.fast.presentation.MainScreen
import dev.meloda.fast.profile.navigation.Profile import dev.meloda.fast.profile.navigation.Profile
import dev.meloda.fast.ui.util.ImmutableList import dev.meloda.fast.ui.util.ImmutableList
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import dev.meloda.fast.ui.R as UiR import dev.meloda.fast.ui.R
@Serializable @Serializable
object MainGraph object MainGraph
@@ -28,21 +28,21 @@ fun NavGraphBuilder.mainScreen(
) { ) {
val navigationItems = ImmutableList.of( val navigationItems = ImmutableList.of(
BottomNavigationItem( BottomNavigationItem(
titleResId = UiR.string.title_friends, titleResId = R.string.title_friends,
selectedIconResId = UiR.drawable.baseline_people_alt_24, selectedIconResId = R.drawable.baseline_people_alt_24,
unselectedIconResId = UiR.drawable.outline_people_alt_24, unselectedIconResId = R.drawable.outline_people_alt_24,
route = Friends, route = Friends,
), ),
BottomNavigationItem( BottomNavigationItem(
titleResId = UiR.string.title_conversations, titleResId = R.string.title_conversations,
selectedIconResId = UiR.drawable.baseline_chat_24, selectedIconResId = R.drawable.baseline_chat_24,
unselectedIconResId = UiR.drawable.outline_chat_24, unselectedIconResId = R.drawable.outline_chat_24,
route = ConversationsGraph route = ConversationsGraph
), ),
BottomNavigationItem( BottomNavigationItem(
titleResId = UiR.string.title_profile, titleResId = R.string.title_profile,
selectedIconResId = UiR.drawable.baseline_account_circle_24, selectedIconResId = R.drawable.baseline_account_circle_24,
unselectedIconResId = UiR.drawable.outline_account_circle_24, unselectedIconResId = R.drawable.outline_account_circle_24,
route = Profile route = Profile
) )
) )
@@ -4,7 +4,6 @@ import android.Manifest
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.app.NotificationChannel import android.app.NotificationChannel
import android.app.NotificationManager import android.app.NotificationManager
import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.res.Configuration import android.content.res.Configuration
import android.os.Build import android.os.Build
@@ -15,44 +14,20 @@ import androidx.activity.SystemBarStyle
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.lifecycle.compose.LifecycleResumeEffect import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.conena.nanokt.android.content.pxToDp
import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import dev.meloda.fast.MainViewModel import dev.meloda.fast.MainViewModel
import dev.meloda.fast.MainViewModelImpl import dev.meloda.fast.MainViewModelImpl
import dev.meloda.fast.common.AppConstants import dev.meloda.fast.common.AppConstants
import dev.meloda.fast.common.LongPollController
import dev.meloda.fast.common.model.LongPollState
import dev.meloda.fast.datastore.AppSettings import dev.meloda.fast.datastore.AppSettings
import dev.meloda.fast.datastore.UserSettings
import dev.meloda.fast.model.api.domain.VkUser
import dev.meloda.fast.service.OnlineService import dev.meloda.fast.service.OnlineService
import dev.meloda.fast.service.longpolling.LongPollingService import dev.meloda.fast.service.longpolling.LongPollingService
import dev.meloda.fast.ui.model.DeviceSize import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.model.SizeConfig
import dev.meloda.fast.ui.model.ThemeConfig
import dev.meloda.fast.ui.theme.AppTheme
import dev.meloda.fast.ui.theme.LocalSizeConfig
import dev.meloda.fast.ui.theme.LocalThemeConfig
import dev.meloda.fast.ui.theme.LocalUser
import dev.meloda.fast.ui.util.isNeedToEnableDarkMode
import org.koin.androidx.compose.koinViewModel import org.koin.androidx.compose.koinViewModel
import org.koin.compose.KoinContext
import org.koin.compose.koinInject
import dev.meloda.fast.ui.R as UiR
class MainActivity : AppCompatActivity() { class MainActivity : AppCompatActivity() {
@@ -89,177 +64,33 @@ class MainActivity : AppCompatActivity() {
requestNotificationPermissions() requestNotificationPermissions()
setContent { setContent {
KoinContext { val viewModel: MainViewModel = koinViewModel<MainViewModelImpl>()
val context = LocalContext.current LaunchedEffect(viewModel) {
Log.d("VM_CREATE", "onCreate: viewModel: $viewModel")
val userSettings: UserSettings = koinInject()
val longPollController: LongPollController = koinInject()
val longPollCurrentState by longPollController.currentState.collectAsStateWithLifecycle()
val longPollStateToApply by longPollController.stateToApply.collectAsStateWithLifecycle()
val viewModel: MainViewModel = koinViewModel<MainViewModelImpl>()
val currentUser: VkUser? by viewModel.currentUser.collectAsStateWithLifecycle()
LifecycleResumeEffect(true) {
viewModel.onAppResumed(intent)
onPauseOrDispose {}
}
val permissionState =
rememberPermissionState(permission = Manifest.permission.POST_NOTIFICATIONS)
val isNeedToCheckPermission by viewModel.isNeedToCheckNotificationsPermission.collectAsStateWithLifecycle()
val isNeedToRequestPermission by viewModel.isNeedToRequestNotifications.collectAsStateWithLifecycle()
LaunchedEffect(isNeedToCheckPermission) {
if (isNeedToCheckPermission) {
viewModel.onPermissionCheckStatus(permissionState.status)
if (permissionState.status.isGranted) {
if (longPollCurrentState == LongPollState.InApp) {
toggleLongPollService(false)
}
toggleLongPollService(
enable = true,
inBackground = true
)
}
}
}
LaunchedEffect(isNeedToRequestPermission) {
if (isNeedToRequestPermission) {
viewModel.onPermissionsRequested()
permissionState.launchPermissionRequest()
}
}
LifecycleResumeEffect(longPollStateToApply) {
Log.d("LongPollMainActivity", "longPollStateToApply: $longPollStateToApply")
if (longPollStateToApply != LongPollState.Background) {
if (longPollStateToApply.isLaunched() && longPollCurrentState.isLaunched()
&& longPollCurrentState != longPollStateToApply
) {
toggleLongPollService(false)
Log.d("LongPoll", "recreate()")
}
toggleLongPollService(
enable = longPollStateToApply.isLaunched(),
inBackground = longPollStateToApply == LongPollState.Background
)
}
onPauseOrDispose {}
}
val sendOnline by userSettings.sendOnlineStatus.collectAsStateWithLifecycle()
LifecycleResumeEffect(sendOnline) {
toggleOnlineService(sendOnline)
onPauseOrDispose {
toggleOnlineService(false)
}
}
val deviceWidthDp = remember(true) {
context.resources.displayMetrics.widthPixels.pxToDp()
}
val deviceHeightDp = remember(true) {
context.resources.displayMetrics.heightPixels.pxToDp()
}
val deviceWidthSize by remember(deviceWidthDp) {
derivedStateOf {
when {
deviceWidthDp <= 360 -> DeviceSize.Small
deviceWidthDp <= 600 -> DeviceSize.Compact
deviceWidthDp <= 840 -> DeviceSize.Medium
else -> DeviceSize.Expanded
}
}
}
val deviceHeightSize by remember(deviceHeightDp) {
derivedStateOf {
when {
deviceHeightDp <= 480 -> DeviceSize.Small
deviceHeightDp <= 700 -> DeviceSize.Compact
deviceHeightDp <= 900 -> DeviceSize.Medium
else -> DeviceSize.Expanded
}
}
}
val sizeConfig by remember(deviceWidthSize, deviceHeightSize) {
mutableStateOf(
SizeConfig(
widthSize = deviceWidthSize,
heightSize = deviceHeightSize
)
)
}
val darkMode by userSettings.darkMode.collectAsStateWithLifecycle()
val dynamicColors by userSettings.enableDynamicColors.collectAsStateWithLifecycle()
val amoledDark by userSettings.enableAmoledDark.collectAsStateWithLifecycle()
val enableBlur by userSettings.useBlur.collectAsStateWithLifecycle()
val enableMultiline by userSettings.enableMultiline.collectAsStateWithLifecycle()
val useSystemFont by userSettings.useSystemFont.collectAsStateWithLifecycle()
val enableAnimations by userSettings.enableAnimations.collectAsStateWithLifecycle()
val setDarkMode = isNeedToEnableDarkMode(darkMode = darkMode)
val themeConfig by remember(
darkMode,
dynamicColors,
amoledDark,
enableBlur,
enableMultiline,
setDarkMode,
useSystemFont
) {
derivedStateOf {
ThemeConfig(
darkMode = setDarkMode,
dynamicColors = dynamicColors,
selectedColorScheme = 0,
amoledDark = amoledDark,
enableBlur = enableBlur,
enableMultiline = enableMultiline,
useSystemFont = useSystemFont,
enableAnimations = enableAnimations
)
}
}
CompositionLocalProvider(
LocalThemeConfig provides themeConfig,
LocalSizeConfig provides sizeConfig,
LocalUser provides currentUser
) {
AppTheme(
useDarkTheme = themeConfig.darkMode,
useDynamicColors = themeConfig.dynamicColors,
selectedColorScheme = themeConfig.selectedColorScheme,
useAmoledBackground = themeConfig.amoledDark,
useSystemFont = themeConfig.useSystemFont
) {
RootScreen(viewModel = viewModel)
}
}
} }
LifecycleResumeEffect(true) {
viewModel.onAppResumed(intent)
onPauseOrDispose {}
}
RootScreen(
toggleLongPollService = { enable, inBackground ->
toggleLongPollService(
enable = enable,
inBackground = inBackground ?: AppSettings.Experimental.longPollInBackground
)
},
toggleOnlineService = ::toggleOnlineService
)
} }
} }
private fun createNotificationChannels() { private fun createNotificationChannels() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val noCategoryName = getString(UiR.string.notification_channel_no_category_name) val noCategoryName = getString(R.string.notification_channel_no_category_name)
val noCategoryDescriptionText = val noCategoryDescriptionText =
getString(UiR.string.notification_channel_no_category_description) getString(R.string.notification_channel_no_category_description)
val noCategoryChannel = val noCategoryChannel =
NotificationChannel( NotificationChannel(
AppConstants.NOTIFICATION_CHANNEL_UNCATEGORIZED, AppConstants.NOTIFICATION_CHANNEL_UNCATEGORIZED,
@@ -269,9 +100,9 @@ class MainActivity : AppCompatActivity() {
description = noCategoryDescriptionText description = noCategoryDescriptionText
} }
val longPollName = getString(UiR.string.notification_channel_long_polling_service_name) val longPollName = getString(R.string.notification_channel_long_polling_service_name)
val longPollDescriptionText = val longPollDescriptionText =
getString(UiR.string.notification_channel_long_polling_service_description) getString(R.string.notification_channel_long_polling_service_description)
val longPollChannel = val longPollChannel =
NotificationChannel( NotificationChannel(
AppConstants.NOTIFICATION_CHANNEL_LONG_POLLING, AppConstants.NOTIFICATION_CHANNEL_LONG_POLLING,
@@ -282,7 +113,7 @@ class MainActivity : AppCompatActivity() {
} }
val notificationManager: NotificationManager = val notificationManager: NotificationManager =
getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager getSystemService(NOTIFICATION_SERVICE) as NotificationManager
notificationManager.createNotificationChannels( notificationManager.createNotificationChannels(
listOf( listOf(
@@ -1,6 +1,8 @@
package dev.meloda.fast.presentation package dev.meloda.fast.presentation
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.activity.compose.LocalActivity
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
@@ -16,7 +18,6 @@ import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -37,7 +38,6 @@ import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeEffect import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials import dev.chrisbanes.haze.materials.HazeMaterials
import dev.meloda.fast.conversations.navigation.Conversations
import dev.meloda.fast.conversations.navigation.ConversationsGraph import dev.meloda.fast.conversations.navigation.ConversationsGraph
import dev.meloda.fast.conversations.navigation.conversationsGraph import dev.meloda.fast.conversations.navigation.conversationsGraph
import dev.meloda.fast.friends.navigation.Friends import dev.meloda.fast.friends.navigation.Friends
@@ -65,8 +65,9 @@ fun MainScreen(
onMessageClicked: (userid: Long) -> Unit = {}, onMessageClicked: (userid: Long) -> Unit = {},
onNavigateToCreateChat: () -> Unit = {} onNavigateToCreateChat: () -> Unit = {}
) { ) {
val activity = LocalActivity.current as? AppCompatActivity ?: return
val theme = LocalThemeConfig.current val theme = LocalThemeConfig.current
val hazeState = remember { HazeState() } val hazeState = remember { HazeState(true) }
val navController = rememberNavController() val navController = rememberNavController()
var selectedItemIndex by rememberSaveable { var selectedItemIndex by rememberSaveable {
@@ -74,28 +75,20 @@ fun MainScreen(
} }
BackHandler(enabled = selectedItemIndex != 1) { BackHandler(enabled = selectedItemIndex != 1) {
val index = 1
val currentRoute = navigationItems[selectedItemIndex].route val currentRoute = navigationItems[selectedItemIndex].route
selectedItemIndex = 1 selectedItemIndex = 1
navController.navigate(navigationItems[index].route) { navController.navigate(navigationItems[selectedItemIndex].route) {
popUpTo(route = currentRoute) { popUpTo(route = currentRoute) {
inclusive = true inclusive = true
} }
} }
} }
val user = LocalUser.current val profileImageUrl = LocalUser.current?.photo100
val profileImageUrl by remember(user) {
derivedStateOf { user?.photo100 }
}
var tabReselected by remember { var tabReselected by remember {
mutableStateOf( mutableStateOf(navigationItems.associate { it.route to false })
navigationItems.associate {
it.route to false
}
)
} }
Scaffold( Scaffold(
@@ -107,7 +100,7 @@ fun MainScreen(
if (theme.enableBlur) { if (theme.enableBlur) {
Modifier.hazeEffect( Modifier.hazeEffect(
state = hazeState, state = hazeState,
style = HazeMaterials.thick() style = HazeMaterials.regular(NavigationBarDefaults.containerColor)
) )
} else Modifier } else Modifier
), ),
@@ -194,6 +187,7 @@ fun MainScreen(
exitTransition = { fadeOut(animationSpec = tween(200)) } exitTransition = { fadeOut(animationSpec = tween(200)) }
) { ) {
friendsScreen( friendsScreen(
activity = activity,
onError = onError, onError = onError,
onPhotoClicked = onPhotoClicked, onPhotoClicked = onPhotoClicked,
onMessageClicked = onMessageClicked, onMessageClicked = onMessageClicked,
@@ -204,6 +198,7 @@ fun MainScreen(
}, },
) )
conversationsGraph( conversationsGraph(
activity = activity,
onError = onError, onError = onError,
onNavigateToMessagesHistory = onNavigateToMessagesHistory, onNavigateToMessagesHistory = onNavigateToMessagesHistory,
onNavigateToCreateChat = onNavigateToCreateChat, onNavigateToCreateChat = onNavigateToCreateChat,
@@ -214,6 +209,7 @@ fun MainScreen(
} }
) )
profileScreen( profileScreen(
activity = activity,
onError = onError, onError = onError,
onSettingsButtonClicked = onSettingsButtonClicked, onSettingsButtonClicked = onSettingsButtonClicked,
onPhotoClicked = onPhotoClicked onPhotoClicked = onPhotoClicked
@@ -1,171 +1,388 @@
package dev.meloda.fast.presentation package dev.meloda.fast.presentation
import android.Manifest
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.provider.Settings import android.provider.Settings
import android.util.Log
import androidx.activity.compose.LocalActivity
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavController import androidx.navigation.NavController
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import com.conena.nanokt.android.content.pxToDp
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import dev.meloda.fast.MainViewModel import dev.meloda.fast.MainViewModel
import dev.meloda.fast.MainViewModelImpl
import dev.meloda.fast.auth.authNavGraph import dev.meloda.fast.auth.authNavGraph
import dev.meloda.fast.auth.navigateToAuth import dev.meloda.fast.auth.navigateToAuth
import dev.meloda.fast.chatmaterials.navigation.chatMaterialsScreen import dev.meloda.fast.chatmaterials.navigation.chatMaterialsScreen
import dev.meloda.fast.chatmaterials.navigation.navigateToChatMaterials import dev.meloda.fast.chatmaterials.navigation.navigateToChatMaterials
import dev.meloda.fast.common.LongPollController
import dev.meloda.fast.common.model.LongPollState
import dev.meloda.fast.conversations.navigation.createChatScreen import dev.meloda.fast.conversations.navigation.createChatScreen
import dev.meloda.fast.conversations.navigation.navigateToCreateChat import dev.meloda.fast.conversations.navigation.navigateToCreateChat
import dev.meloda.fast.datastore.UserSettings
import dev.meloda.fast.languagepicker.navigation.languagePickerScreen import dev.meloda.fast.languagepicker.navigation.languagePickerScreen
import dev.meloda.fast.languagepicker.navigation.navigateToLanguagePicker import dev.meloda.fast.languagepicker.navigation.navigateToLanguagePicker
import dev.meloda.fast.messageshistory.navigation.messagesHistoryScreen import dev.meloda.fast.messageshistory.navigation.messagesHistoryScreen
import dev.meloda.fast.messageshistory.navigation.navigateToMessagesHistory import dev.meloda.fast.messageshistory.navigation.navigateToMessagesHistory
import dev.meloda.fast.model.api.domain.VkUser
import dev.meloda.fast.navigation.Main import dev.meloda.fast.navigation.Main
import dev.meloda.fast.navigation.mainScreen import dev.meloda.fast.navigation.mainScreen
import dev.meloda.fast.photoviewer.navigation.navigateToPhotoView import dev.meloda.fast.photoviewer.presentation.PhotoViewDialog
import dev.meloda.fast.photoviewer.navigation.photoViewScreen
import dev.meloda.fast.settings.navigation.navigateToSettings import dev.meloda.fast.settings.navigation.navigateToSettings
import dev.meloda.fast.settings.navigation.settingsScreen import dev.meloda.fast.settings.navigation.settingsScreen
import dev.meloda.fast.ui.R import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.common.LocalSizeConfig
import dev.meloda.fast.ui.model.DeviceSize
import dev.meloda.fast.ui.model.SizeConfig
import dev.meloda.fast.ui.model.ThemeConfig
import dev.meloda.fast.ui.theme.AppTheme
import dev.meloda.fast.ui.theme.LocalNavController import dev.meloda.fast.ui.theme.LocalNavController
import dev.meloda.fast.ui.theme.LocalNavRootController import dev.meloda.fast.ui.theme.LocalNavRootController
import dev.meloda.fast.ui.theme.LocalThemeConfig
import dev.meloda.fast.ui.theme.LocalUser
import dev.meloda.fast.ui.util.ImmutableList
import dev.meloda.fast.ui.util.ImmutableList.Companion.toImmutableList
import dev.meloda.fast.ui.util.immutableListOf
import dev.meloda.fast.ui.util.isNeedToEnableDarkMode
import org.koin.androidx.compose.koinViewModel
import org.koin.compose.koinInject
@OptIn(ExperimentalPermissionsApi::class)
@Composable @Composable
fun RootScreen( fun RootScreen(
navController: NavHostController = rememberNavController(), toggleLongPollService: (enable: Boolean, inBackground: Boolean?) -> Unit,
viewModel: MainViewModel toggleOnlineService: (enable: Boolean) -> Unit
) { ) {
val context = LocalContext.current val resources = LocalResources.current
val startDestination by viewModel.startDestination.collectAsStateWithLifecycle()
val isNeedToOpenAuth by viewModel.isNeedToReplaceWithAuth.collectAsStateWithLifecycle()
val isNeedToShowDeniedDialog by viewModel.isNeedToShowNotificationsDeniedDialog.collectAsStateWithLifecycle()
val isNeedToShowRationaleDialog by viewModel.isNeedToShowNotificationsRationaleDialog.collectAsStateWithLifecycle()
LaunchedEffect(isNeedToOpenAuth) { val userSettings: UserSettings = koinInject()
if (isNeedToOpenAuth) { val longPollController: LongPollController = koinInject()
viewModel.onNavigatedToAuth()
navController.navigateToAuth(clearBackStack = true) val longPollCurrentState by longPollController.currentState.collectAsStateWithLifecycle()
val longPollStateToApply by longPollController.stateToApply.collectAsStateWithLifecycle()
val viewModel: MainViewModel = koinViewModel<MainViewModelImpl>()
LaunchedEffect(viewModel) {
Log.d("VM_CREATE", "RootScreen(): viewModel: $viewModel")
}
val currentUser: VkUser? by viewModel.currentUser.collectAsStateWithLifecycle()
val permissionState =
rememberPermissionState(permission = Manifest.permission.POST_NOTIFICATIONS)
val isNeedToCheckPermission by viewModel.isNeedToCheckNotificationsPermission.collectAsStateWithLifecycle()
val isNeedToRequestPermission by viewModel.isNeedToRequestNotifications.collectAsStateWithLifecycle()
LaunchedEffect(isNeedToCheckPermission) {
if (isNeedToCheckPermission) {
viewModel.onPermissionCheckStatus(permissionState.status)
if (permissionState.status.isGranted) {
if (longPollCurrentState == LongPollState.InApp) {
toggleLongPollService(false, null)
}
toggleLongPollService(true, true)
}
} }
} }
if (isNeedToShowDeniedDialog) { LaunchedEffect(isNeedToRequestPermission) {
AlertDialog( if (isNeedToRequestPermission) {
onDismissRequest = viewModel::onNotificationsDeniedDialogDismissed, viewModel.onPermissionsRequested()
title = { Text(text = stringResource(id = R.string.warning)) }, permissionState.launchPermissionRequest()
text = { Text(text = stringResource(id = R.string.background_long_poll_denied_text)) }, }
confirmButton = { }
TextButton(onClick = viewModel::onNotificationsDeniedDialogConfirmClicked) {
Text(text = stringResource(id = R.string.action_request)) LifecycleResumeEffect(longPollStateToApply) {
} Log.d("LongPollMainActivity", "longPollStateToApply: $longPollStateToApply")
}, if (longPollStateToApply != LongPollState.Background) {
dismissButton = { if (longPollStateToApply.isLaunched() && longPollCurrentState.isLaunched()
TextButton(onClick = viewModel::onNotificationsDeniedDialogCancelClicked) { && longPollCurrentState != longPollStateToApply
Text(text = stringResource(id = R.string.action_disable)) ) {
} toggleLongPollService(false, null)
}, Log.d("LongPoll", "recreate()")
properties = DialogProperties( }
dismissOnBackPress = false,
dismissOnClickOutside = false toggleLongPollService(
longPollStateToApply.isLaunched(),
longPollStateToApply == LongPollState.Background
)
}
onPauseOrDispose {}
}
val sendOnline by userSettings.sendOnlineStatus.collectAsStateWithLifecycle()
LifecycleResumeEffect(sendOnline) {
toggleOnlineService(sendOnline)
onPauseOrDispose {
toggleOnlineService(false)
}
}
val deviceWidthDp = remember(true) {
resources.displayMetrics.widthPixels.pxToDp()
}
val deviceHeightDp = remember(true) {
resources.displayMetrics.heightPixels.pxToDp()
}
val deviceWidthSize by remember(deviceWidthDp) {
derivedStateOf {
when {
deviceWidthDp <= 360 -> DeviceSize.Small
deviceWidthDp <= 600 -> DeviceSize.Compact
deviceWidthDp <= 840 -> DeviceSize.Medium
else -> DeviceSize.Expanded
}
}
}
val deviceHeightSize by remember(deviceHeightDp) {
derivedStateOf {
when {
deviceHeightDp <= 480 -> DeviceSize.Small
deviceHeightDp <= 700 -> DeviceSize.Compact
deviceHeightDp <= 900 -> DeviceSize.Medium
else -> DeviceSize.Expanded
}
}
}
val sizeConfig by remember(deviceWidthSize, deviceHeightSize) {
mutableStateOf(
SizeConfig(
widthSize = deviceWidthSize,
heightSize = deviceHeightSize
) )
) )
} }
if (isNeedToShowRationaleDialog) { val darkMode by userSettings.darkMode.collectAsStateWithLifecycle()
AlertDialog( val dynamicColors by userSettings.enableDynamicColors.collectAsStateWithLifecycle()
onDismissRequest = viewModel::onNotificationsRationaleDialogDismissed, val amoledDark by userSettings.enableAmoledDark.collectAsStateWithLifecycle()
title = { Text(text = stringResource(id = R.string.warning)) }, val enableBlur by userSettings.useBlur.collectAsStateWithLifecycle()
text = { Text(text = stringResource(id = R.string.background_long_poll_rationale_text)) }, val enableMultiline by userSettings.enableMultiline.collectAsStateWithLifecycle()
confirmButton = { val useSystemFont by userSettings.useSystemFont.collectAsStateWithLifecycle()
TextButton( val enableAnimations by userSettings.enableAnimations.collectAsStateWithLifecycle()
onClick = {
context.startActivity( val setDarkMode = isNeedToEnableDarkMode(darkMode = darkMode)
Intent(
Settings.ACTION_APPLICATION_DETAILS_SETTINGS, val themeConfig by remember(
Uri.fromParts("package", context.packageName, null) darkMode,
dynamicColors,
amoledDark,
enableBlur,
enableMultiline,
setDarkMode,
useSystemFont
) {
derivedStateOf {
ThemeConfig(
darkMode = setDarkMode,
dynamicColors = dynamicColors,
selectedColorScheme = 0,
amoledDark = amoledDark,
enableBlur = enableBlur,
enableMultiline = enableMultiline,
useSystemFont = useSystemFont,
enableAnimations = enableAnimations
)
}
}
CompositionLocalProvider(
LocalThemeConfig provides themeConfig,
LocalSizeConfig provides sizeConfig,
LocalUser provides currentUser
) {
AppTheme(
useDarkTheme = themeConfig.darkMode,
useDynamicColors = themeConfig.dynamicColors,
selectedColorScheme = themeConfig.selectedColorScheme,
useAmoledBackground = themeConfig.amoledDark,
useSystemFont = themeConfig.useSystemFont
) {
val navController: NavHostController = rememberNavController()
val activity = LocalActivity.current
val context = LocalContext.current
val startDestination by viewModel.startDestination.collectAsStateWithLifecycle()
val isNeedToOpenAuth by viewModel.isNeedToReplaceWithAuth.collectAsStateWithLifecycle()
val isNeedToShowDeniedDialog by viewModel.isNeedToShowNotificationsDeniedDialog.collectAsStateWithLifecycle()
val isNeedToShowRationaleDialog by viewModel.isNeedToShowNotificationsRationaleDialog.collectAsStateWithLifecycle()
LaunchedEffect(isNeedToOpenAuth) {
if (isNeedToOpenAuth) {
viewModel.onNavigatedToAuth()
navController.navigateToAuth(clearBackStack = true)
}
}
if (isNeedToShowDeniedDialog) {
AlertDialog(
onDismissRequest = viewModel::onNotificationsDeniedDialogDismissed,
title = { Text(text = stringResource(id = R.string.warning)) },
text = { Text(text = stringResource(id = R.string.background_long_poll_denied_text)) },
confirmButton = {
TextButton(onClick = viewModel::onNotificationsDeniedDialogConfirmClicked) {
Text(text = stringResource(id = R.string.action_request))
}
},
dismissButton = {
TextButton(onClick = viewModel::onNotificationsDeniedDialogCancelClicked) {
Text(text = stringResource(id = R.string.action_disable))
}
},
properties = DialogProperties(
dismissOnBackPress = false,
dismissOnClickOutside = false
)
)
}
if (isNeedToShowRationaleDialog) {
AlertDialog(
onDismissRequest = viewModel::onNotificationsRationaleDialogDismissed,
title = { Text(text = stringResource(id = R.string.warning)) },
text = { Text(text = stringResource(id = R.string.background_long_poll_rationale_text)) },
confirmButton = {
TextButton(
onClick = {
context.startActivity(
Intent(
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
Uri.fromParts("package", context.packageName, null)
)
)
}
) {
Text(text = stringResource(id = R.string.title_settings))
}
},
dismissButton = {
TextButton(onClick = viewModel::onNotificationsRationaleDialogCancelClicked) {
Text(text = stringResource(id = R.string.action_disable))
}
},
properties = DialogProperties(
dismissOnBackPress = false,
dismissOnClickOutside = false
)
)
}
if (startDestination != null) {
CompositionLocalProvider(
LocalNavRootController provides navController,
LocalNavController provides navController
) {
var photoViewerInfo by rememberSaveable {
mutableStateOf<Pair<ImmutableList<String>, Int?>?>(null)
}
Box(modifier = Modifier.fillMaxSize()) {
NavHost(
navController = navController,
startDestination = requireNotNull(startDestination),
enterTransition = { fadeIn(animationSpec = tween(200)) },
exitTransition = { fadeOut(animationSpec = tween(200)) }
) {
authNavGraph(
onNavigateToMain = {
viewModel.onUserAuthenticated()
navController.navigateToMain()
},
onNavigateToSettings = navController::navigateToSettings,
navController = navController
) )
mainScreen(
onError = viewModel::onError,
onSettingsButtonClicked = navController::navigateToSettings,
onNavigateToMessagesHistory = navController::navigateToMessagesHistory,
onPhotoClicked = { url ->
photoViewerInfo = immutableListOf(url) to null
},
onMessageClicked = navController::navigateToMessagesHistory,
onNavigateToCreateChat = navController::navigateToCreateChat
)
messagesHistoryScreen(
onError = viewModel::onError,
onBack = navController::navigateUp,
onNavigateToChatMaterials = navController::navigateToChatMaterials,
onNavigateToPhotoViewer = { photos, index ->
photoViewerInfo = photos.toImmutableList() to index
}
)
chatMaterialsScreen(
onBack = navController::navigateUp,
onPhotoClicked = { url ->
photoViewerInfo = immutableListOf(url) to null
}
)
createChatScreen(
onChatCreated = { conversationId ->
navController.popBackStack()
navController.navigateToMessagesHistory(conversationId)
},
navController = navController
)
settingsScreen(
onBack = navController::navigateUp,
onLogOutButtonClicked = { navController.navigateToAuth(true) },
onLanguageItemClicked = navController::navigateToLanguagePicker,
onRestartRequired = {
activity?.let {
val intent = Intent(activity, MainActivity::class.java)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
activity.startActivity(intent)
activity.finish()
}
}
)
languagePickerScreen(onBack = navController::navigateUp)
}
PhotoViewDialog(
photoViewerInfo = photoViewerInfo,
onDismiss = { photoViewerInfo = null }
) )
} }
) {
Text(text = stringResource(id = R.string.title_settings))
} }
},
dismissButton = {
TextButton(onClick = viewModel::onNotificationsRationaleDialogCancelClicked) {
Text(text = stringResource(id = R.string.action_disable))
}
},
properties = DialogProperties(
dismissOnBackPress = false,
dismissOnClickOutside = false
)
)
}
if (startDestination != null) {
CompositionLocalProvider(
LocalNavRootController provides navController,
LocalNavController provides navController
) {
NavHost(
navController = navController,
startDestination = requireNotNull(startDestination),
enterTransition = { fadeIn(animationSpec = tween(200)) },
exitTransition = { fadeOut(animationSpec = tween(200)) }
) {
authNavGraph(
onNavigateToMain = {
viewModel.onUserAuthenticated()
navController.navigateToMain()
},
navController = navController
)
mainScreen(
onError = viewModel::onError,
onSettingsButtonClicked = navController::navigateToSettings,
onNavigateToMessagesHistory = navController::navigateToMessagesHistory,
onPhotoClicked = { url -> navController.navigateToPhotoView(listOf(url)) },
onMessageClicked = navController::navigateToMessagesHistory,
onNavigateToCreateChat = navController::navigateToCreateChat
)
messagesHistoryScreen(
onError = viewModel::onError,
onBack = navController::navigateUp,
onNavigateToChatMaterials = navController::navigateToChatMaterials
)
chatMaterialsScreen(
onBack = navController::navigateUp,
onPhotoClicked = { url -> navController.navigateToPhotoView(listOf(url)) }
)
createChatScreen(
onChatCreated = { conversationId ->
navController.popBackStack()
navController.navigateToMessagesHistory(conversationId)
},
navController = navController
)
settingsScreen(
onBack = navController::navigateUp,
onLogOutButtonClicked = { navController.navigateToAuth(true) },
onLanguageItemClicked = navController::navigateToLanguagePicker
)
languagePickerScreen(onBack = navController::navigateUp)
photoViewScreen(onBack = navController::navigateUp)
} }
} }
} }
@@ -6,7 +6,7 @@ import android.content.Context
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import dev.meloda.fast.common.AppConstants import dev.meloda.fast.common.AppConstants
import dev.meloda.fast.ui.R as UiR import dev.meloda.fast.ui.R
object NotificationsUtils { object NotificationsUtils {
@@ -28,7 +28,7 @@ object NotificationsUtils {
actions: List<NotificationCompat.Action> = emptyList(), actions: List<NotificationCompat.Action> = emptyList(),
): NotificationCompat.Builder { ): NotificationCompat.Builder {
val builder = NotificationCompat.Builder(context, channelId) val builder = NotificationCompat.Builder(context, channelId)
.setSmallIcon(UiR.drawable.ic_fast_logo) .setSmallIcon(R.drawable.ic_fast_logo)
.setContentTitle(title) .setContentTitle(title)
.setPriority(priority.value) .setPriority(priority.value)
.setContentIntent(contentIntent) .setContentIntent(contentIntent)
@@ -1,8 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<network-security-config xmlns:tools="http://schemas.android.com/tools"> <network-security-config xmlns:tools="http://schemas.android.com/tools">
<!-- Allow cleartext network traffic -->
<base-config <base-config
cleartextTrafficPermitted="true" cleartextTrafficPermitted="false"
tools:ignore="InsecureBaseConfiguration"> tools:ignore="InsecureBaseConfiguration">
<trust-anchors> <trust-anchors>
<!-- Trust pre-installed CAs --> <!-- Trust pre-installed CAs -->
@@ -15,7 +15,7 @@ class AndroidApplicationConventionPlugin : Plugin<Project> {
extensions.configure<ApplicationExtension> { extensions.configure<ApplicationExtension> {
configureKotlinAndroid(this) configureKotlinAndroid(this)
defaultConfig.targetSdk = libs.findVersion("targetSdk").get().toString().toInt() defaultConfig.targetSdk = 36
} }
} }
} }
@@ -12,6 +12,7 @@ class AndroidLibraryComposeConventionPlugin : Plugin<Project> {
apply(plugin = "org.jetbrains.kotlin.plugin.compose") apply(plugin = "org.jetbrains.kotlin.plugin.compose")
val extension = extensions.getByType<LibraryExtension>() val extension = extensions.getByType<LibraryExtension>()
extension.androidResources.enable = false
configureAndroidCompose(extension) configureAndroidCompose(extension)
} }
} }
@@ -2,7 +2,6 @@ import com.android.build.api.variant.LibraryAndroidComponentsExtension
import com.android.build.gradle.LibraryExtension import com.android.build.gradle.LibraryExtension
import dev.meloda.fast.configureKotlinAndroid import dev.meloda.fast.configureKotlinAndroid
import dev.meloda.fast.disableUnnecessaryAndroidTests import dev.meloda.fast.disableUnnecessaryAndroidTests
import dev.meloda.fast.libs
import org.gradle.api.Plugin import org.gradle.api.Plugin
import org.gradle.api.Project import org.gradle.api.Project
import org.gradle.kotlin.dsl.configure import org.gradle.kotlin.dsl.configure
@@ -21,7 +20,8 @@ class AndroidLibraryConventionPlugin : Plugin<Project> {
extensions.configure<LibraryExtension> { extensions.configure<LibraryExtension> {
configureKotlinAndroid(this) configureKotlinAndroid(this)
defaultConfig.targetSdk = libs.findVersion("targetSdk").get().toString().toInt() androidResources.enable = false
defaultConfig.targetSdk = 36
defaultConfig.testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" defaultConfig.testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }
extensions.configure<LibraryAndroidComponentsExtension> { extensions.configure<LibraryAndroidComponentsExtension> {
@@ -15,7 +15,7 @@ class AndroidTestConventionPlugin : Plugin<Project> {
extensions.configure<TestExtension> { extensions.configure<TestExtension> {
configureKotlinAndroid(this) configureKotlinAndroid(this)
defaultConfig.targetSdk = libs.findVersion("targetSdk").get().toString().toInt() defaultConfig.targetSdk = 36
} }
} }
} }
@@ -16,10 +16,10 @@ internal fun Project.configureKotlinAndroid(
commonExtension: CommonExtension<*, *, *, *, *, *>, commonExtension: CommonExtension<*, *, *, *, *, *>,
) { ) {
commonExtension.apply { commonExtension.apply {
compileSdk = libs.findVersion("compileSdk").get().toString().toInt() compileSdk = 36
defaultConfig { defaultConfig {
minSdk = libs.findVersion("minSdk").get().toString().toInt() minSdk = 23
} }
compileOptions { compileOptions {
@@ -5,8 +5,8 @@ object AppConstants {
const val INSTALL_APP_MIME_TYPE = "application/vnd.android.package-archive" const val INSTALL_APP_MIME_TYPE = "application/vnd.android.package-archive"
const val API_VERSION = "5.238" const val API_VERSION = "5.238"
const val URL_OAUTH = "https://oauth.vk.com" const val URL_OAUTH = "https://oauth.vk.ru"
const val URL_API = "https://api.vk.com/method" const val URL_API = "https://api.vk.ru/method"
const val NOTIFICATION_CHANNEL_UNCATEGORIZED = "uncategorized" const val NOTIFICATION_CHANNEL_UNCATEGORIZED = "uncategorized"
const val NOTIFICATION_CHANNEL_LONG_POLLING = "long_polling" const val NOTIFICATION_CHANNEL_LONG_POLLING = "long_polling"
@@ -1,5 +1,7 @@
package dev.meloda.fast.common.extensions package dev.meloda.fast.common.extensions
import android.os.Build
import android.os.Bundle
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@@ -11,6 +13,7 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlin.reflect.KClass
import kotlin.time.Duration import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
@@ -103,7 +106,7 @@ fun Any.asInt(): Int {
} }
fun Any.asLong(): Long { fun Any.asLong(): Long {
return when(this) { return when (this) {
is Number -> this.toLong() is Number -> this.toLong()
else -> throw IllegalArgumentException("Object is not numeric") else -> throw IllegalArgumentException("Object is not numeric")
@@ -117,3 +120,21 @@ fun <T> Any.toList(mapper: (old: Any) -> T): List<T> {
else -> emptyList() else -> emptyList()
} }
} }
fun <T> Bundle.getParcelableCompat(key: String, clazz: Class<T>): T? {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
getParcelable(key, clazz)
} else {
@Suppress("DEPRECATION")
getParcelable(key)
}
}
fun <T : Any> Bundle.getParcelableCompat(key: String, clazz: KClass<T>): T? {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
getParcelable(key, clazz.java)
} else {
@Suppress("DEPRECATION")
getParcelable(key)
}
}
@@ -1,6 +1,5 @@
package dev.meloda.fast.common.util package dev.meloda.fast.common.util
import android.content.res.Resources
import com.conena.nanokt.jvm.util.dayOfMonth import com.conena.nanokt.jvm.util.dayOfMonth
import com.conena.nanokt.jvm.util.hour import com.conena.nanokt.jvm.util.hour
import com.conena.nanokt.jvm.util.hourOfDay import com.conena.nanokt.jvm.util.hourOfDay
@@ -9,7 +8,6 @@ import com.conena.nanokt.jvm.util.minute
import com.conena.nanokt.jvm.util.month import com.conena.nanokt.jvm.util.month
import com.conena.nanokt.jvm.util.second import com.conena.nanokt.jvm.util.second
import com.conena.nanokt.jvm.util.year import com.conena.nanokt.jvm.util.year
import dev.meloda.fast.common.R
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Calendar import java.util.Calendar
import java.util.Date import java.util.Date
@@ -27,7 +25,11 @@ object TimeUtils {
}.timeInMillis }.timeInMillis
} }
fun getLocalizedDate(resources: Resources, date: Long): String { fun getLocalizedDate(
date: Long,
yesterday: () -> String,
today: () -> String
): String {
val now = Calendar.getInstance() val now = Calendar.getInstance()
val then = Calendar.getInstance().also { it.timeInMillis = date } val then = Calendar.getInstance().also { it.timeInMillis = date }
@@ -36,43 +38,50 @@ object TimeUtils {
now.month != then.month -> "dd MMMM" now.month != then.month -> "dd MMMM"
now.dayOfMonth != then.dayOfMonth -> { now.dayOfMonth != then.dayOfMonth -> {
if (now.dayOfMonth - then.dayOfMonth == 1) { if (now.dayOfMonth - then.dayOfMonth == 1) {
return resources.getString(R.string.yesterday) return yesterday()
} else { } else {
"dd MMMM" "dd MMMM"
} }
} }
else -> return resources.getString(R.string.today) else -> return today()
} }
return SimpleDateFormat(pattern, Locale.getDefault()).format(date) return SimpleDateFormat(pattern, Locale.getDefault()).format(date)
} }
fun getLocalizedTime(resources: Resources, date: Long): String { fun getLocalizedTime(
date: Long,
yearShort: () -> String,
monthShort: () -> String,
weekShort: () -> String,
dayShort: () -> String,
now: () -> String
): String {
val now = Calendar.getInstance() val now = Calendar.getInstance()
val then = Calendar.getInstance().also { it.timeInMillis = date } val then = Calendar.getInstance().also { it.timeInMillis = date }
return when { return when {
now.year != then.year -> { now.year != then.year -> {
"${now.year - then.year}${resources.getString(R.string.year_short).lowercase()}" "${now.year - then.year}${yearShort().lowercase()}"
} }
now.month != then.month -> { now.month != then.month -> {
"${now.month - then.month}${resources.getString(R.string.month_short).lowercase()}" "${now.month - then.month}${monthShort().lowercase()}"
} }
now.dayOfMonth != then.dayOfMonth -> { now.dayOfMonth != then.dayOfMonth -> {
val change = now.dayOfMonth - then.dayOfMonth val change = now.dayOfMonth - then.dayOfMonth
if (change % 7 == 0) { if (change % 7 == 0) {
"${change / 7}${resources.getString(R.string.week_short).lowercase()}" "${change / 7}${weekShort().lowercase()}"
} else { } else {
"$change${resources.getString(R.string.day_short).lowercase()}" "$change${dayShort().lowercase()}"
} }
} }
now.hour == then.hour && now.minute == then.minute -> { now.hour == then.hour && now.minute == then.minute -> {
resources.getString(R.string.time_now).lowercase() now().lowercase()
} }
else -> { else -> {
@@ -0,0 +1,7 @@
package dev.meloda.fast.common.util
import java.net.URLEncoder
fun String.urlEncode(encoding: String = "utf-8"): String {
return URLEncoder.encode(this, encoding)
}
@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="yesterday">Вчера</string>
<string name="today">Сегодня</string>
<string name="year_short">Г</string>
<string name="month_short">М</string>
<string name="week_short">Н</string>
<string name="day_short">Д</string>
<string name="time_now">Сейчас</string>
</resources>
@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="yesterday">Yesterday</string>
<string name="today">Today</string>
<string name="year_short">Y</string>
<string name="month_short">M</string>
<string name="week_short">W</string>
<string name="day_short">D</string>
<string name="time_now">Now</string>
</resources>
@@ -36,7 +36,7 @@ class VkUsersMap(
if (message.fromId > 0) map[message.fromId] if (message.fromId > 0) map[message.fromId]
else null else null
fun user(userid: Long): VkUser? = map[userId] fun user(userId: Long): VkUser? = map[userId]
companion object { companion object {
@@ -75,7 +75,13 @@ class ConversationsRepositoryImpl(
user = usersMap.messageUser(message), user = usersMap.messageUser(message),
group = groupsMap.messageGroup(message), group = groupsMap.messageGroup(message),
actionUser = usersMap.messageActionUser(message), actionUser = usersMap.messageActionUser(message),
actionGroup = groupsMap.messageActionGroup(message) actionGroup = groupsMap.messageActionGroup(message),
replyMessage = message.replyMessage?.copy(
user = usersMap.messageUser(message),
group = groupsMap.messageGroup(message),
actionUser = usersMap.messageActionUser(message),
actionGroup = groupsMap.messageActionGroup(message),
)
).also { VkMemoryCache[message.id] = it } ).also { VkMemoryCache[message.id] = it }
} }
item.conversation.asDomain(lastMessage).let { conversation -> item.conversation.asDomain(lastMessage).let { conversation ->
@@ -6,6 +6,7 @@ import dev.meloda.fast.model.api.domain.VkAttachment
import dev.meloda.fast.model.api.domain.VkAttachmentHistoryMessage import dev.meloda.fast.model.api.domain.VkAttachmentHistoryMessage
import dev.meloda.fast.model.api.domain.VkMessage import dev.meloda.fast.model.api.domain.VkMessage
import dev.meloda.fast.model.api.responses.MessagesGetConversationMembersResponse import dev.meloda.fast.model.api.responses.MessagesGetConversationMembersResponse
import dev.meloda.fast.model.api.responses.MessagesGetReadPeersResponse
import dev.meloda.fast.model.api.responses.MessagesSendResponse import dev.meloda.fast.model.api.responses.MessagesSendResponse
import dev.meloda.fast.network.RestApiErrorDomain import dev.meloda.fast.network.RestApiErrorDomain
@@ -32,7 +33,7 @@ interface MessagesRepository {
peerId: Long, peerId: Long,
randomId: Long, randomId: Long,
message: String?, message: String?,
replyTo: Long?, forward: String?,
attachments: List<VkAttachment>?, attachments: List<VkAttachment>?,
formatData: VkMessage.FormatData? formatData: VkMessage.FormatData?
): ApiResult<MessagesSendResponse, RestApiErrorDomain> ): ApiResult<MessagesSendResponse, RestApiErrorDomain>
@@ -110,4 +111,9 @@ interface MessagesRepository {
chatId: Long, chatId: Long,
memberId: Long memberId: Long
): ApiResult<Int, RestApiErrorDomain> ): ApiResult<Int, RestApiErrorDomain>
suspend fun getMessageReadPeers(
peerId: Long,
cmId: Long
): ApiResult<MessagesGetReadPeersResponse, RestApiErrorDomain>
} }
@@ -37,6 +37,7 @@ import dev.meloda.fast.model.api.requests.MessagesRemoveChatUserRequest
import dev.meloda.fast.model.api.requests.MessagesSendRequest import dev.meloda.fast.model.api.requests.MessagesSendRequest
import dev.meloda.fast.model.api.requests.MessagesUnpinMessageRequest import dev.meloda.fast.model.api.requests.MessagesUnpinMessageRequest
import dev.meloda.fast.model.api.responses.MessagesGetConversationMembersResponse import dev.meloda.fast.model.api.responses.MessagesGetConversationMembersResponse
import dev.meloda.fast.model.api.responses.MessagesGetReadPeersResponse
import dev.meloda.fast.model.api.responses.MessagesSendResponse import dev.meloda.fast.model.api.responses.MessagesSendResponse
import dev.meloda.fast.network.RestApiErrorDomain import dev.meloda.fast.network.RestApiErrorDomain
import dev.meloda.fast.network.mapApiDefault import dev.meloda.fast.network.mapApiDefault
@@ -90,7 +91,15 @@ class MessagesRepositoryImpl(
user = usersMap.messageUser(message), user = usersMap.messageUser(message),
group = groupsMap.messageGroup(message), group = groupsMap.messageGroup(message),
actionUser = usersMap.messageActionUser(message), actionUser = usersMap.messageActionUser(message),
actionGroup = groupsMap.messageActionGroup(message) actionGroup = groupsMap.messageActionGroup(message),
replyMessage = message.replyMessage.let { replyMessage ->
replyMessage?.copy(
user = usersMap.messageUser(replyMessage),
group = groupsMap.messageGroup(replyMessage),
actionUser = usersMap.messageActionUser(replyMessage),
actionGroup = groupsMap.messageActionGroup(replyMessage),
)
}
).also { VkMemoryCache[message.id] = it } ).also { VkMemoryCache[message.id] = it }
} }
} }
@@ -159,7 +168,15 @@ class MessagesRepositoryImpl(
user = usersMap.messageUser(message), user = usersMap.messageUser(message),
group = groupsMap.messageGroup(message), group = groupsMap.messageGroup(message),
actionUser = usersMap.messageActionUser(message), actionUser = usersMap.messageActionUser(message),
actionGroup = groupsMap.messageActionGroup(message) actionGroup = groupsMap.messageActionGroup(message),
replyMessage = message.replyMessage?.asDomain().let { replyMessage ->
replyMessage?.copy(
user = usersMap.messageUser(replyMessage),
group = groupsMap.messageGroup(replyMessage),
actionUser = usersMap.messageActionUser(replyMessage),
actionGroup = groupsMap.messageActionGroup(replyMessage),
)
}
) )
} }
@@ -183,7 +200,7 @@ class MessagesRepositoryImpl(
peerId: Long, peerId: Long,
randomId: Long, randomId: Long,
message: String?, message: String?,
replyTo: Long?, forward: String?,
attachments: List<VkAttachment>?, attachments: List<VkAttachment>?,
formatData: VkMessage.FormatData? formatData: VkMessage.FormatData?
): ApiResult<MessagesSendResponse, RestApiErrorDomain> = withContext(Dispatchers.IO) { ): ApiResult<MessagesSendResponse, RestApiErrorDomain> = withContext(Dispatchers.IO) {
@@ -191,7 +208,7 @@ class MessagesRepositoryImpl(
peerId = peerId, peerId = peerId,
randomId = randomId, randomId = randomId,
message = message, message = message,
replyTo = replyTo, forward = forward,
attachments = attachments, attachments = attachments,
formatData = formatData formatData = formatData
) )
@@ -308,7 +325,12 @@ class MessagesRepositoryImpl(
messagesIds = messageIds.orEmpty(), messagesIds = messageIds.orEmpty(),
important = important important = important
) )
messagesService.markAsImportant(requestModel.map).mapApiDefault() messagesService.markAsImportant(requestModel.map).mapApiResult(
successMapper = { apiResponse ->
apiResponse.requireResponse().marked.map { it.cmId }
},
errorMapper = { error -> error?.toDomain() }
)
} }
override suspend fun delete( override suspend fun delete(
@@ -402,4 +424,18 @@ class MessagesRepositoryImpl(
messagesService.removeChatUser(requestModel.map).mapApiDefault() messagesService.removeChatUser(requestModel.map).mapApiDefault()
} }
override suspend fun getMessageReadPeers(
peerId: Long,
cmId: Long
): ApiResult<MessagesGetReadPeersResponse, RestApiErrorDomain> = withContext(Dispatchers.IO) {
messagesService.getMessageReadPeers(
mapOf(
"peer_id" to peerId.toString(),
"cmid" to cmId.toString(),
"extended" to "1",
"fields" to VkConstants.USER_FIELDS
)
).mapApiDefault()
}
} }
@@ -17,7 +17,7 @@ val databaseModule = module {
single { single {
Room.databaseBuilder(get(), CacheDatabase::class.java, "cache") Room.databaseBuilder(get(), CacheDatabase::class.java, "cache")
.fallbackToDestructiveMigration() .fallbackToDestructiveMigration(true)
.build() .build()
} }
single { cacheDB().userDao() } single { cacheDB().userDao() }
@@ -96,6 +96,20 @@ object AppSettings {
) )
set(value) = put(SettingsKeys.KEY_SHOW_EMOJI_BUTTON, value) set(value) = put(SettingsKeys.KEY_SHOW_EMOJI_BUTTON, value)
var showAttachmentButton: Boolean
get() = get(
SettingsKeys.KEY_SHOW_ATTACHMENT_BUTTON,
SettingsKeys.DEFAULT_VALUE_SHOW_ATTACHMENT_BUTTON
)
set(value) = put(SettingsKeys.KEY_SHOW_ATTACHMENT_BUTTON, value)
var showManualRefreshOptions: Boolean
get() = get(
SettingsKeys.KEY_SHOW_MANUAL_REFRESH_OPTIONS,
SettingsKeys.DEFAULT_VALUE_SHOW_MANUAL_REFRESH_OPTIONS
)
set(value) = put(SettingsKeys.KEY_SHOW_MANUAL_REFRESH_OPTIONS, value)
var enableHaptic: Boolean var enableHaptic: Boolean
get() = get( get() = get(
SettingsKeys.KEY_ENABLE_HAPTIC, SettingsKeys.KEY_ENABLE_HAPTIC,
@@ -11,6 +11,10 @@ object SettingsKeys {
const val DEFAULT_VALUE_USE_CONTACT_NAMES = false const val DEFAULT_VALUE_USE_CONTACT_NAMES = false
const val KEY_SHOW_EMOJI_BUTTON = "show_emoji_button" const val KEY_SHOW_EMOJI_BUTTON = "show_emoji_button"
const val DEFAULT_VALUE_KEY_SHOW_EMOJI_BUTTON = false const val DEFAULT_VALUE_KEY_SHOW_EMOJI_BUTTON = false
const val KEY_SHOW_ATTACHMENT_BUTTON = "show_attachment_button"
const val DEFAULT_VALUE_SHOW_ATTACHMENT_BUTTON = false
const val KEY_SHOW_MANUAL_REFRESH_OPTIONS = "show_manual_refresh_options"
const val DEFAULT_VALUE_SHOW_MANUAL_REFRESH_OPTIONS = false
const val KEY_APPEARANCE = "appearance" const val KEY_APPEARANCE = "appearance"
const val KEY_APPEARANCE_MULTILINE = "appearance_multiline" const val KEY_APPEARANCE_MULTILINE = "appearance_multiline"
@@ -46,6 +50,9 @@ object SettingsKeys {
const val DEFAULT_ENABLE_HAPTIC = true const val DEFAULT_ENABLE_HAPTIC = true
const val KEY_DEBUG_NETWORK_LOG_LEVEL = "debug_network_log_level" const val KEY_DEBUG_NETWORK_LOG_LEVEL = "debug_network_log_level"
const val DEFAULT_NETWORK_LOG_LEVEL = 3 const val DEFAULT_NETWORK_LOG_LEVEL = 3
const val KEY_DEBUG_IMPORT_AUTH_DATA = "debug_import_auth_data"
const val KEY_DEBUG_EXPORT_AUTH_DATA = "debug_export_auth_data"
const val KEY_USE_SYSTEM_FONT = "use_system_font" const val KEY_USE_SYSTEM_FONT = "use_system_font"
const val DEFAULT_USE_SYSTEM_FONT = false const val DEFAULT_USE_SYSTEM_FONT = false
const val KEY_MORE_ANIMATIONS = "more_animations" const val KEY_MORE_ANIMATIONS = "more_animations"
@@ -14,15 +14,10 @@ interface UserSettings {
val enableDynamicColors: StateFlow<Boolean> val enableDynamicColors: StateFlow<Boolean>
val appLanguage: StateFlow<String> val appLanguage: StateFlow<String>
val fastText: StateFlow<String>
val sendOnlineStatus: StateFlow<Boolean> val sendOnlineStatus: StateFlow<Boolean>
val showAlertAfterCrash: StateFlow<Boolean>
val longPollInBackground: StateFlow<Boolean> val longPollInBackground: StateFlow<Boolean>
val useBlur: StateFlow<Boolean> val useBlur: StateFlow<Boolean>
val showEmojiButton: StateFlow<Boolean>
val showTimeInActionMessages: StateFlow<Boolean>
val useSystemFont: StateFlow<Boolean> val useSystemFont: StateFlow<Boolean>
val enableAnimations: StateFlow<Boolean> val enableAnimations: StateFlow<Boolean>
val showDebugCategory: StateFlow<Boolean> val showDebugCategory: StateFlow<Boolean>
@@ -35,15 +30,10 @@ interface UserSettings {
fun onEnableDynamicColorsChanged(enable: Boolean) fun onEnableDynamicColorsChanged(enable: Boolean)
fun onAppLanguageChanged(language: String) fun onAppLanguageChanged(language: String)
fun onFastTextChanged(text: String)
fun onSendOnlineStatusChanged(send: Boolean) fun onSendOnlineStatusChanged(send: Boolean)
fun onShowAlertAfterCrashChanged(show: Boolean)
fun onLongPollInBackgroundChanged(inBackground: Boolean) fun onLongPollInBackgroundChanged(inBackground: Boolean)
fun onUseBlurChanged(use: Boolean) fun onUseBlurChanged(use: Boolean)
fun onShowEmojiButtonChanged(show: Boolean)
fun onShowTimeInActionMessagesChanged(show: Boolean)
fun onUseSystemFontChanged(use: Boolean) fun onUseSystemFontChanged(use: Boolean)
fun onShowDebugCategoryChanged(show: Boolean) fun onShowDebugCategoryChanged(show: Boolean)
} }
@@ -58,16 +48,11 @@ class UserSettingsImpl : UserSettings {
override val enableDynamicColors = MutableStateFlow(AppSettings.Appearance.enableDynamicColors) override val enableDynamicColors = MutableStateFlow(AppSettings.Appearance.enableDynamicColors)
override val appLanguage = MutableStateFlow(AppSettings.Appearance.appLanguage) override val appLanguage = MutableStateFlow(AppSettings.Appearance.appLanguage)
override val fastText = MutableStateFlow(AppSettings.Features.fastText)
override val sendOnlineStatus = MutableStateFlow(AppSettings.Activity.sendOnlineStatus) override val sendOnlineStatus = MutableStateFlow(AppSettings.Activity.sendOnlineStatus)
override val showAlertAfterCrash = MutableStateFlow(AppSettings.Debug.showAlertAfterCrash) override val longPollInBackground =
override val longPollInBackground = MutableStateFlow(AppSettings.Experimental.longPollInBackground) MutableStateFlow(AppSettings.Experimental.longPollInBackground)
override val useBlur = MutableStateFlow(AppSettings.Experimental.useBlur) override val useBlur = MutableStateFlow(AppSettings.Experimental.useBlur)
override val showEmojiButton = MutableStateFlow(AppSettings.General.showEmojiButton)
override val showTimeInActionMessages =
MutableStateFlow(AppSettings.Experimental.showTimeInActionMessages)
override val useSystemFont = MutableStateFlow(AppSettings.Appearance.useSystemFont) override val useSystemFont = MutableStateFlow(AppSettings.Appearance.useSystemFont)
override val enableAnimations = MutableStateFlow(AppSettings.Experimental.moreAnimations) override val enableAnimations = MutableStateFlow(AppSettings.Experimental.moreAnimations)
override val showDebugCategory = MutableStateFlow(AppSettings.Debug.showDebugCategory) override val showDebugCategory = MutableStateFlow(AppSettings.Debug.showDebugCategory)
@@ -96,18 +81,10 @@ class UserSettingsImpl : UserSettings {
appLanguage.value = language appLanguage.value = language
} }
override fun onFastTextChanged(text: String) {
fastText.value = text
}
override fun onSendOnlineStatusChanged(send: Boolean) { override fun onSendOnlineStatusChanged(send: Boolean) {
sendOnlineStatus.value = send sendOnlineStatus.value = send
} }
override fun onShowAlertAfterCrashChanged(show: Boolean) {
showAlertAfterCrash.value = show
}
override fun onLongPollInBackgroundChanged(inBackground: Boolean) { override fun onLongPollInBackgroundChanged(inBackground: Boolean) {
longPollInBackground.value = inBackground longPollInBackground.value = inBackground
} }
@@ -116,14 +93,6 @@ class UserSettingsImpl : UserSettings {
useBlur.value = use useBlur.value = use
} }
override fun onShowEmojiButtonChanged(show: Boolean) {
showEmojiButton.value = show
}
override fun onShowTimeInActionMessagesChanged(show: Boolean) {
showTimeInActionMessages.value = show
}
override fun onUseSystemFontChanged(use: Boolean) { override fun onUseSystemFontChanged(use: Boolean) {
useSystemFont.value = use useSystemFont.value = use
} }
@@ -0,0 +1,21 @@
package dev.meloda.fast.domain
import dev.meloda.fast.data.State
import dev.meloda.fast.data.api.messages.MessagesRepository
import dev.meloda.fast.data.mapToState
import kotlinx.coroutines.flow.Flow
class GetMessageReadPeersUseCase(
private val repository: MessagesRepository
) : BaseUseCase {
operator fun invoke(
peerId: Long,
cmId: Long
): Flow<State<Int>> = flowNewState {
repository.getMessageReadPeers(
peerId = peerId,
cmId = cmId
).mapToState(successMapper = { it.totalCount })
}
}
@@ -32,7 +32,7 @@ interface MessagesUseCase : BaseUseCase {
peerId: Long, peerId: Long,
randomId: Long, randomId: Long,
message: String?, message: String?,
replyTo: Long?, forward: String?,
attachments: List<VkAttachment>?, attachments: List<VkAttachment>?,
formatData: VkMessage.FormatData? formatData: VkMessage.FormatData?
): Flow<State<MessagesSendResponse>> ): Flow<State<MessagesSendResponse>>
@@ -56,7 +56,7 @@ class MessagesUseCaseImpl(
peerId: Long, peerId: Long,
randomId: Long, randomId: Long,
message: String?, message: String?,
replyTo: Long?, forward: String?,
attachments: List<VkAttachment>?, attachments: List<VkAttachment>?,
formatData: VkMessage.FormatData? formatData: VkMessage.FormatData?
): Flow<State<MessagesSendResponse>> = flowNewState { ): Flow<State<MessagesSendResponse>> = flowNewState {
@@ -64,7 +64,7 @@ class MessagesUseCaseImpl(
peerId = peerId, peerId = peerId,
randomId = randomId, randomId = randomId,
message = message, message = message,
replyTo = replyTo, forward = forward,
attachments = attachments, attachments = attachments,
formatData = formatData formatData = formatData
).mapToState() ).mapToState()
@@ -6,6 +6,7 @@ import dev.meloda.fast.domain.AccountUseCaseImpl
import dev.meloda.fast.domain.GetCurrentAccountUseCase import dev.meloda.fast.domain.GetCurrentAccountUseCase
import dev.meloda.fast.domain.GetLocalUserByIdUseCase import dev.meloda.fast.domain.GetLocalUserByIdUseCase
import dev.meloda.fast.domain.GetLocalUsersByIdsUseCase import dev.meloda.fast.domain.GetLocalUsersByIdsUseCase
import dev.meloda.fast.domain.GetMessageReadPeersUseCase
import dev.meloda.fast.domain.LoadConversationsByIdUseCase import dev.meloda.fast.domain.LoadConversationsByIdUseCase
import dev.meloda.fast.domain.LoadUserByIdUseCase import dev.meloda.fast.domain.LoadUserByIdUseCase
import dev.meloda.fast.domain.LoadUsersByIdsUseCase import dev.meloda.fast.domain.LoadUsersByIdsUseCase
@@ -27,4 +28,6 @@ val domainModule = module {
singleOf(::GetCurrentAccountUseCase) singleOf(::GetCurrentAccountUseCase)
singleOf(::LoadConversationsByIdUseCase) singleOf(::LoadConversationsByIdUseCase)
singleOf(::GetMessageReadPeersUseCase)
} }
@@ -1,12 +1,13 @@
package dev.meloda.fast.model.api.data package dev.meloda.fast.model.api.data
import dev.meloda.fast.model.api.domain.VkGiftDomain
import com.squareup.moshi.Json import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass import com.squareup.moshi.JsonClass
import dev.meloda.fast.model.api.domain.VkGiftDomain
@JsonClass(generateAdapter = true) @JsonClass(generateAdapter = true)
data class VkGiftData( data class VkGiftData(
@Json(name = "id") val id: Long, @Json(name = "id") val id: Long,
@Json(name = "thumb_512") val thumb512: String?,
@Json(name = "thumb_256") val thumb256: String?, @Json(name = "thumb_256") val thumb256: String?,
@Json(name = "thumb_96") val thumb96: String?, @Json(name = "thumb_96") val thumb96: String?,
@Json(name = "thumb_48") val thumb48: String @Json(name = "thumb_48") val thumb48: String
@@ -14,6 +15,7 @@ data class VkGiftData(
fun toDomain() = VkGiftDomain( fun toDomain() = VkGiftDomain(
id = id, id = id,
thumb512 = thumb512,
thumb256 = thumb256, thumb256 = thumb256,
thumb96 = thumb96, thumb96 = thumb96,
thumb48 = thumb48 thumb48 = thumb48
@@ -105,7 +105,7 @@ fun VkMessageData.asDomain(): VkMessage = VkMessage(
actionConversationMessageId = action?.conversationMessageId, actionConversationMessageId = action?.conversationMessageId,
actionMessage = action?.message, actionMessage = action?.message,
geoType = geo?.type, geoType = geo?.type,
isImportant = important ?: false, isImportant = important == true,
updateTime = updateTime, updateTime = updateTime,
forwards = fwdMessages.orEmpty().map(VkMessageData::asDomain), forwards = fwdMessages.orEmpty().map(VkMessageData::asDomain),
attachments = attachments.map(VkAttachmentItemData::toDomain), attachments = attachments.map(VkAttachmentItemData::toDomain),
@@ -37,7 +37,7 @@ data class VkUserData(
@JsonClass(generateAdapter = true) @JsonClass(generateAdapter = true)
data class LastSeen( data class LastSeen(
@Json(name = "platform") val platform: Int, @Json(name = "platform") val platform: Int?,
@Json(name = "time") val time: Int @Json(name = "time") val time: Int
) )
@@ -54,9 +54,9 @@ data class VkVideoMessageData(
@JsonClass(generateAdapter = true) @JsonClass(generateAdapter = true)
data class Image( data class Image(
val height: Int?, val height: Int,
val url: String?, val url: String,
val width: Int?, val width: Int,
val with_padding: Int?, val with_padding: Int?,
) )
@@ -73,6 +73,7 @@ data class VkVideoMessageData(
) )
fun toDomain(): VkVideoMessageDomain = VkVideoMessageDomain( fun toDomain(): VkVideoMessageDomain = VkVideoMessageDomain(
id = id id = id,
image = image.orEmpty().filter { it.width / it.height == 1 }.maxByOrNull { it.width }?.url
) )
} }
@@ -4,10 +4,14 @@ import dev.meloda.fast.model.api.data.AttachmentType
data class VkGiftDomain( data class VkGiftDomain(
val id: Long, val id: Long,
val thumb512: String?,
val thumb256: String?, val thumb256: String?,
val thumb96: String?, val thumb96: String?,
val thumb48: String val thumb48: String
) : VkAttachment { ) : VkAttachment {
override val type: AttachmentType = AttachmentType.GIFT override val type: AttachmentType = AttachmentType.GIFT
fun getMaxSizeThumb(): String = thumb512 ?: thumb256 ?: thumb96 ?: thumb48
fun getDefaultThumbSizeOrLess(): String = thumb256 ?: thumb96 ?: thumb48
} }
@@ -35,7 +35,7 @@ data class VkMessage(
val user: VkUser?, val user: VkUser?,
val group: VkGroupDomain?, val group: VkGroupDomain?,
val actionUser: VkUser?, val actionUser: VkUser?,
val actionGroup: VkGroupDomain? val actionGroup: VkGroupDomain?,
) { ) {
fun isPeerChat() = peerId > 2_000_000_000 fun isPeerChat() = peerId > 2_000_000_000
@@ -21,4 +21,12 @@ data class VkStickerDomain(
return null return null
} }
fun getUrl(width: Int = 256, withBackground: Boolean = false): String? = when {
withBackground && backgroundImages != null -> {
backgroundImages.firstOrNull { it.width >= width }?.url
}
images != null -> images.firstOrNull { it.width >= width }?.url
else -> "https://vk.ru/sticker/1-${id}-${width}b"
}
} }
@@ -3,7 +3,8 @@ package dev.meloda.fast.model.api.domain
import dev.meloda.fast.model.api.data.AttachmentType import dev.meloda.fast.model.api.data.AttachmentType
data class VkVideoMessageDomain( data class VkVideoMessageDomain(
val id: Long val id: Long,
val image: String?
) : VkAttachment { ) : VkAttachment {
override val type: AttachmentType = AttachmentType.VIDEO_MESSAGE override val type: AttachmentType = AttachmentType.VIDEO_MESSAGE
@@ -34,7 +34,7 @@ data class MessagesSendRequest(
val message: String?, val message: String?,
val lat: Int? = null, val lat: Int? = null,
val lon: Int? = null, val lon: Int? = null,
val replyTo: Long? = null, val forward: String? = null,
val stickerId: Long? = null, val stickerId: Long? = null,
val disableMentions: Boolean? = null, val disableMentions: Boolean? = null,
val doNotParseLinks: Boolean? = null, val doNotParseLinks: Boolean? = null,
@@ -51,7 +51,7 @@ data class MessagesSendRequest(
message?.let { this["message"] = it } message?.let { this["message"] = it }
lat?.let { this["lat"] = it.toString() } lat?.let { this["lat"] = it.toString() }
lon?.let { this["lon"] = it.toString() } lon?.let { this["lon"] = it.toString() }
replyTo?.let { this["reply_to"] = it.toString() } forward?.let { this["forward"] = it }
stickerId?.let { this["sticker_id"] = it.toString() } stickerId?.let { this["sticker_id"] = it.toString() }
disableMentions?.let { this["disable_mentions"] = it.asInt().toString() } disableMentions?.let { this["disable_mentions"] = it.asInt().toString() }
doNotParseLinks?.let { this["dont_parse_links"] = it.asInt().toString() } doNotParseLinks?.let { this["dont_parse_links"] = it.asInt().toString() }
@@ -35,7 +35,7 @@ data class AuthDirectRequest(
} }
data class AuthWithAppRequest( data class AuthWithAppRequest(
val redirectUrl: String = "https://oauth.vk.com/blank.html", val redirectUrl: String = "https://oauth.vk.ru/blank.html",
val display: String = "page", val display: String = "page",
val responseType: String = "token", val responseType: String = "token",
val accessToken: String, val accessToken: String,
@@ -58,3 +58,22 @@ data class MessagesSendResponse(
@Json(name = "message_id") val messageId: Long, @Json(name = "message_id") val messageId: Long,
@Json(name = "cmid") val cmId: Long @Json(name = "cmid") val cmId: Long
) )
@JsonClass(generateAdapter = true)
data class MessagesMarkAsImportantResponse(
@Json(name = "marked") val marked: List<Mark>
) {
@JsonClass(generateAdapter = true)
data class Mark(
@Json(name = "cmid") val cmId: Long,
@Json(name = "message_id") val messageId: Long,
@Json(name = "peer_id") val peerId: Long
)
}
@JsonClass(generateAdapter = true)
data class MessagesGetReadPeersResponse(
@Json(name = "items") val items: List<Long>,
@Json(name = "total_count") val totalCount: Int,
@Json(name = "profiles") val profiles: List<VkUserData>?,
)
@@ -9,6 +9,8 @@ import dev.meloda.fast.model.api.responses.MessagesGetByIdResponse
import dev.meloda.fast.model.api.responses.MessagesGetConversationMembersResponse import dev.meloda.fast.model.api.responses.MessagesGetConversationMembersResponse
import dev.meloda.fast.model.api.responses.MessagesGetHistoryAttachmentsResponse import dev.meloda.fast.model.api.responses.MessagesGetHistoryAttachmentsResponse
import dev.meloda.fast.model.api.responses.MessagesGetHistoryResponse import dev.meloda.fast.model.api.responses.MessagesGetHistoryResponse
import dev.meloda.fast.model.api.responses.MessagesGetReadPeersResponse
import dev.meloda.fast.model.api.responses.MessagesMarkAsImportantResponse
import dev.meloda.fast.model.api.responses.MessagesSendResponse import dev.meloda.fast.model.api.responses.MessagesSendResponse
import dev.meloda.fast.network.ApiResponse import dev.meloda.fast.network.ApiResponse
import dev.meloda.fast.network.RestApiError import dev.meloda.fast.network.RestApiError
@@ -76,7 +78,7 @@ interface MessagesService {
@POST(MessagesUrls.MARK_AS_IMPORTANT) @POST(MessagesUrls.MARK_AS_IMPORTANT)
suspend fun markAsImportant( suspend fun markAsImportant(
@FieldMap params: Map<String, String> @FieldMap params: Map<String, String>
): ApiResult<ApiResponse<List<Long>>, RestApiError> ): ApiResult<ApiResponse<MessagesMarkAsImportantResponse>, RestApiError>
@FormUrlEncoded @FormUrlEncoded
@POST(MessagesUrls.DELETE) @POST(MessagesUrls.DELETE)
@@ -107,4 +109,10 @@ interface MessagesService {
suspend fun removeChatUser( suspend fun removeChatUser(
@FieldMap params: Map<String, String> @FieldMap params: Map<String, String>
): ApiResult<ApiResponse<Int>, RestApiError> ): ApiResult<ApiResponse<Int>, RestApiError>
@FormUrlEncoded
@POST(MessagesUrls.GET_MESSAGE_READ_PEERS)
suspend fun getMessageReadPeers(
@FieldMap params: Map<String, String>
): ApiResult<ApiResponse<MessagesGetReadPeersResponse>, RestApiError>
} }
@@ -22,4 +22,5 @@ object MessagesUrls {
const val REMOVE_CHAT_USER = "$URL/messages.removeChatUser" const val REMOVE_CHAT_USER = "$URL/messages.removeChatUser"
const val GET_HISTORY_ATTACHMENTS = "$URL/messages.getHistoryAttachments" const val GET_HISTORY_ATTACHMENTS = "$URL/messages.getHistoryAttachments"
const val CREATE_CHAT = "$URL/messages.createChat" const val CREATE_CHAT = "$URL/messages.createChat"
const val GET_MESSAGE_READ_PEERS = "$URL/messages.getMessageReadPeers"
} }
+3
View File
@@ -6,6 +6,7 @@ plugins {
android { android {
namespace = "dev.meloda.fast.ui" namespace = "dev.meloda.fast.ui"
androidResources.enable = true
} }
dependencies { dependencies {
@@ -19,6 +20,8 @@ dependencies {
implementation(platform(libs.compose.bom)) implementation(platform(libs.compose.bom))
implementation(libs.bundles.compose) implementation(libs.bundles.compose)
implementation(libs.bundles.nanokt)
implementation(libs.androidx.navigation.compose) implementation(libs.androidx.navigation.compose)
implementation(libs.kotlin.serialization) implementation(libs.kotlin.serialization)
implementation(libs.koin.androidx.compose.navigation) implementation(libs.koin.androidx.compose.navigation)
@@ -0,0 +1,12 @@
package dev.meloda.fast.ui.common
import androidx.compose.runtime.compositionLocalOf
import dev.meloda.fast.ui.model.DeviceSize
import dev.meloda.fast.ui.model.SizeConfig
val LocalSizeConfig = compositionLocalOf {
SizeConfig(
widthSize = DeviceSize.Compact,
heightSize = DeviceSize.Compact
)
}
@@ -20,12 +20,12 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import dev.meloda.fast.ui.R as UiR import dev.meloda.fast.ui.R
@Composable @Composable
fun ErrorView( fun ErrorView(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
iconResId: Int? = UiR.drawable.round_error_24, iconResId: Int? = R.drawable.round_error_24,
text: String, text: String,
buttonText: String? = null, buttonText: String? = null,
onButtonClick: (() -> Unit)? = null, onButtonClick: (() -> Unit)? = null,
@@ -1,19 +1,16 @@
package dev.meloda.fast.ui.components package dev.meloda.fast.ui.components
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Indication
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.ripple.rememberRipple import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.IconButtonColors import androidx.compose.material3.IconButtonColors
import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.LocalContentColor import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalUseFallbackRippleImplementation
import androidx.compose.material3.minimumInteractiveComponentSize import androidx.compose.material3.minimumInteractiveComponentSize
import androidx.compose.material3.ripple import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -23,12 +20,11 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
fun IconButton( fun FastIconButton(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onClick: () -> Unit = {}, onClick: () -> Unit = {},
onLongClick: (() -> Unit)? = null, onLongClick: (() -> Unit)? = null,
@@ -39,21 +35,18 @@ fun IconButton(
) { ) {
Box( Box(
modifier = modifier =
modifier modifier
.minimumInteractiveComponentSize() .minimumInteractiveComponentSize()
.size(IconButtonTokens.StateLayerSize) .size(IconButtonTokens.StateLayerSize)
.clip(IconButtonTokens.StateLayerShape) .clip(IconButtonTokens.StateLayerShape)
.background(color = colors.containerColor(enabled)) .background(color = colors.containerColor(enabled))
.combinedClickable( .combinedClickable(
onClick = onClick, onClick = onClick,
onLongClick = onLongClick, onLongClick = onLongClick,
enabled = enabled, enabled = enabled,
interactionSource = interactionSource, interactionSource = interactionSource,
indication = rippleOrFallbackImplementation( indication = ripple()
bounded = false, ),
radius = IconButtonTokens.StateLayerSize / 2
)
),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
val contentColor = colors.contentColor(enabled) val contentColor = colors.contentColor(enabled)
@@ -61,21 +54,6 @@ fun IconButton(
} }
} }
@Suppress("DEPRECATION_ERROR")
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun rippleOrFallbackImplementation(
bounded: Boolean = true,
radius: Dp = Dp.Unspecified,
color: Color = Color.Unspecified
): Indication {
return if (LocalUseFallbackRippleImplementation.current) {
rememberRipple(bounded, radius, color)
} else {
ripple(bounded, radius, color)
}
}
internal object IconButtonTokens { internal object IconButtonTokens {
val StateLayerShape = CircleShape val StateLayerShape = CircleShape
val StateLayerSize = 40.0.dp val StateLayerSize = 40.0.dp
@@ -0,0 +1,109 @@
package dev.meloda.fast.ui.components
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.selection.LocalTextSelectionColors
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.TextFieldColors
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.takeOrElse
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun FastTextField(
value: TextFieldValue,
onValueChange: (TextFieldValue) -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
readOnly: Boolean = false,
textStyle: TextStyle = LocalTextStyle.current,
label: @Composable (() -> Unit)? = null,
placeholder: @Composable (() -> Unit)? = null,
leadingIcon: @Composable (() -> Unit)? = null,
trailingIcon: @Composable (() -> Unit)? = null,
prefix: @Composable (() -> Unit)? = null,
suffix: @Composable (() -> Unit)? = null,
supportingText: @Composable (() -> Unit)? = null,
isError: Boolean = false,
visualTransformation: VisualTransformation = VisualTransformation.None,
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
keyboardActions: KeyboardActions = KeyboardActions.Default,
singleLine: Boolean = false,
maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE,
minLines: Int = 1,
interactionSource: MutableInteractionSource? = null,
shape: Shape = TextFieldDefaults.shape,
colors: TextFieldColors = TextFieldDefaults.colors(),
) {
@Suppress("NAME_SHADOWING")
val interactionSource = interactionSource ?: remember { MutableInteractionSource() }
// If color is not provided via the text style, use content color as a default
val textColor =
textStyle.color.takeOrElse {
val focused = interactionSource.collectIsFocusedAsState().value
colors.textColor(enabled, isError, focused)
}
val mergedTextStyle = textStyle.merge(TextStyle(color = textColor))
CompositionLocalProvider(LocalTextSelectionColors provides colors.textSelectionColors) {
BasicTextField(
value = value,
modifier =
modifier,
/* .defaultMinSize(
minWidth = TextFieldDefaults.MinWidth,
minHeight = TextFieldDefaults.MinHeight,
)*/
onValueChange = onValueChange,
enabled = enabled,
readOnly = readOnly,
textStyle = mergedTextStyle,
cursorBrush = SolidColor(colors.cursorColor(isError)),
visualTransformation = visualTransformation,
keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions,
interactionSource = interactionSource,
singleLine = singleLine,
maxLines = maxLines,
minLines = minLines,
decorationBox =
@Composable { innerTextField ->
// places leading icon, text field with label and placeholder, trailing icon
TextFieldDefaults.DecorationBox(
value = value.text,
visualTransformation = visualTransformation,
innerTextField = innerTextField,
placeholder = placeholder,
label = label,
leadingIcon = leadingIcon,
trailingIcon = trailingIcon,
prefix = prefix,
suffix = suffix,
supportingText = supportingText,
shape = shape,
singleLine = singleLine,
enabled = enabled,
isError = isError,
interactionSource = interactionSource,
colors = colors,
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 12.dp)
)
},
)
}
}
@@ -0,0 +1,34 @@
package dev.meloda.fast.ui.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.zIndex
@Composable
fun FullScreenDialog(
modifier: Modifier = Modifier,
onDismiss: () -> Unit = {},
content: @Composable () -> Unit,
) {
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(
usePlatformDefaultWidth = false,
decorFitsSystemWindows = false,
)
) {
Box(
modifier = modifier
.fillMaxSize()
.zIndex(10F),
contentAlignment = Alignment.Center
) {
content()
}
}
}
@@ -1,28 +0,0 @@
package dev.meloda.fast.ui.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
@Composable
fun FullScreenLoader(modifier: Modifier = Modifier) {
Box(
modifier = modifier
.fillMaxSize()
.navigationBarsPadding(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
@Preview
@Composable
private fun FullScreenLoaderPreview() {
FullScreenLoader()
}
@@ -0,0 +1,79 @@
package dev.meloda.fast.ui.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.material3.ContainedLoadingIndicator
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
@Composable
@Preview
fun FullScreenContainedLoader(
modifier: Modifier = Modifier,
containerColor: Color = MaterialTheme.colorScheme.primary,
indicatorColor: Color = MaterialTheme.colorScheme.primaryContainer
) {
Box(
modifier = modifier
.fillMaxSize()
.navigationBarsPadding(),
contentAlignment = Alignment.Center
) {
ContainedLoader(
containerColor = containerColor,
indicatorColor = indicatorColor
)
}
}
@Preview
@Composable
fun FullScreenLoader(
modifier: Modifier = Modifier,
color: Color = MaterialTheme.colorScheme.primary
) {
Box(
modifier = modifier
.fillMaxSize()
.navigationBarsPadding(),
contentAlignment = Alignment.Center
) {
Loader(color = color)
}
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
@Preview
fun ContainedLoader(
modifier: Modifier = Modifier,
containerColor: Color = MaterialTheme.colorScheme.primary,
indicatorColor: Color = MaterialTheme.colorScheme.primaryContainer
) {
ContainedLoadingIndicator(
modifier = modifier,
containerColor = containerColor,
indicatorColor = indicatorColor
)
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
@Preview
fun Loader(
modifier: Modifier = Modifier,
color: Color = MaterialTheme.colorScheme.primary
) {
LoadingIndicator(
modifier = modifier,
color = color
)
}
@@ -0,0 +1,35 @@
package dev.meloda.fast.ui.components
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.unit.dp
@Composable
fun RippledClickContainer(
modifier: Modifier = Modifier,
shape: Shape = RoundedCornerShape(4.dp),
onClick: () -> Unit,
onLongClick: (() -> Unit)? = null,
content: @Composable () -> Unit
) {
Box(
modifier = modifier
.clip(shape)
.combinedClickable(
interactionSource = null,
indication = ripple(),
onClick = onClick,
onLongClick = onLongClick
),
contentAlignment = Alignment.Center
) {
content()
}
}
@@ -0,0 +1,9 @@
package dev.meloda.fast.ui.extensions
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ProvidableCompositionLocal
@Composable
fun <T> ProvidableCompositionLocal<T?>.getOrThrow(): T {
return requireNotNull(current)
}
@@ -4,112 +4,27 @@ import android.app.Activity
import android.os.Build import android.os.Build
import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateColorAsState
import androidx.compose.material3.ColorScheme import androidx.compose.material3.ColorScheme
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialExpressiveTheme
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.ProvidableCompositionLocal
import androidx.compose.runtime.SideEffect import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
import androidx.navigation.NavController import androidx.navigation.NavController
import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.HazeState
import dev.meloda.fast.model.api.domain.VkUser import dev.meloda.fast.model.api.domain.VkUser
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.model.DeviceSize
import dev.meloda.fast.ui.model.SizeConfig
import dev.meloda.fast.ui.model.ThemeConfig import dev.meloda.fast.ui.model.ThemeConfig
private val googleSansFonts = FontFamily(
Font(resId = R.font.google_sans_regular),
Font(
resId = R.font.google_sans_italic,
style = FontStyle.Italic
),
Font(
resId = R.font.google_sans_medium,
weight = FontWeight.Medium
),
Font(
resId = R.font.google_sans_medium_italic,
weight = FontWeight.Medium,
style = FontStyle.Italic
),
Font(
resId = R.font.google_sans_bold,
weight = FontWeight.Bold
),
Font(
resId = R.font.google_sans_bold_italic,
weight = FontWeight.Bold,
style = FontStyle.Italic
)
)
private val robotoFonts = FontFamily(
Font(
resId = R.font.roboto_thin,
weight = FontWeight.Thin
),
Font(
resId = R.font.roboto_thin_italic,
weight = FontWeight.Thin,
style = FontStyle.Italic
),
Font(
resId = R.font.roboto_light,
weight = FontWeight.Light
),
Font(
resId = R.font.roboto_light_italic,
weight = FontWeight.Light,
style = FontStyle.Italic
),
Font(resId = R.font.roboto_regular),
Font(
resId = R.font.roboto_italic,
style = FontStyle.Italic
),
Font(
resId = R.font.roboto_medium,
weight = FontWeight.Medium
),
Font(
resId = R.font.roboto_medium_italic,
weight = FontWeight.Medium,
style = FontStyle.Italic
),
Font(
resId = R.font.roboto_bold,
weight = FontWeight.Bold
),
Font(
resId = R.font.roboto_bold_italic,
weight = FontWeight.Bold,
style = FontStyle.Italic
),
Font(
resId = R.font.roboto_black,
weight = FontWeight.Black
),
Font(
resId = R.font.roboto_black_italic,
weight = FontWeight.Black,
style = FontStyle.Italic
)
)
val LocalThemeConfig = compositionLocalOf { val LocalThemeConfig = compositionLocalOf {
ThemeConfig( ThemeConfig(
darkMode = false, darkMode = false,
@@ -123,25 +38,14 @@ val LocalThemeConfig = compositionLocalOf {
) )
} }
val LocalSizeConfig = compositionLocalOf { val LocalHazeState = compositionLocalOf { HazeState(true) }
SizeConfig(
widthSize = DeviceSize.Compact,
heightSize = DeviceSize.Compact
)
}
val LocalHazeState = compositionLocalOf { HazeState() }
val LocalBottomPadding = compositionLocalOf { 0.dp } val LocalBottomPadding = compositionLocalOf { 0.dp }
val LocalUser = compositionLocalOf<VkUser?> { null } val LocalUser = compositionLocalOf<VkUser?> { null }
val LocalReselectedTab = compositionLocalOf { mapOf<Any, Boolean>() } val LocalReselectedTab = compositionLocalOf { mapOf<Any, Boolean>() }
val LocalNavRootController = compositionLocalOf<NavController?> { null } val LocalNavRootController = compositionLocalOf<NavController?> { null }
val LocalNavController = compositionLocalOf<NavController?> { null } val LocalNavController = compositionLocalOf<NavController?> { null }
@Composable @OptIn(ExperimentalMaterial3ExpressiveApi::class)
fun <T: NavController> ProvidableCompositionLocal<T?>.getOrThrow(): T {
return requireNotNull(current)
}
@Composable @Composable
fun AppTheme( fun AppTheme(
predefinedColorScheme: ColorScheme? = null, predefinedColorScheme: ColorScheme? = null,
@@ -186,21 +90,21 @@ fun AppTheme(
MaterialTheme.typography MaterialTheme.typography
} else { } else {
MaterialTheme.typography.copy( MaterialTheme.typography.copy(
displayLarge = MaterialTheme.typography.displayLarge.copy(fontFamily = googleSansFonts), displayLarge = MaterialTheme.typography.displayLarge.copy(fontFamily = GoogleSansFamily),
displayMedium = MaterialTheme.typography.displayMedium.copy(fontFamily = googleSansFonts), displayMedium = MaterialTheme.typography.displayMedium.copy(fontFamily = GoogleSansFamily),
displaySmall = MaterialTheme.typography.displaySmall.copy(fontFamily = googleSansFonts), displaySmall = MaterialTheme.typography.displaySmall.copy(fontFamily = GoogleSansFamily),
headlineLarge = MaterialTheme.typography.headlineLarge.copy(fontFamily = googleSansFonts), headlineLarge = MaterialTheme.typography.headlineLarge.copy(fontFamily = GoogleSansFamily),
headlineMedium = MaterialTheme.typography.headlineMedium.copy(fontFamily = googleSansFonts), headlineMedium = MaterialTheme.typography.headlineMedium.copy(fontFamily = GoogleSansFamily),
headlineSmall = MaterialTheme.typography.headlineSmall.copy(fontFamily = googleSansFonts), headlineSmall = MaterialTheme.typography.headlineSmall.copy(fontFamily = GoogleSansFamily),
titleLarge = MaterialTheme.typography.titleLarge.copy(fontFamily = robotoFonts), titleLarge = MaterialTheme.typography.titleLarge.copy(fontFamily = RobotoFamily),
titleMedium = MaterialTheme.typography.titleMedium.copy(fontFamily = robotoFonts), titleMedium = MaterialTheme.typography.titleMedium.copy(fontFamily = RobotoFamily),
titleSmall = MaterialTheme.typography.titleSmall.copy(fontFamily = robotoFonts), titleSmall = MaterialTheme.typography.titleSmall.copy(fontFamily = RobotoFamily),
bodyLarge = MaterialTheme.typography.bodyLarge.copy(fontFamily = robotoFonts), bodyLarge = MaterialTheme.typography.bodyLarge.copy(fontFamily = RobotoFamily),
bodyMedium = MaterialTheme.typography.bodyMedium.copy(fontFamily = robotoFonts), bodyMedium = MaterialTheme.typography.bodyMedium.copy(fontFamily = RobotoFamily),
bodySmall = MaterialTheme.typography.bodySmall.copy(fontFamily = robotoFonts), bodySmall = MaterialTheme.typography.bodySmall.copy(fontFamily = RobotoFamily),
labelLarge = MaterialTheme.typography.labelLarge.copy(fontFamily = robotoFonts), labelLarge = MaterialTheme.typography.labelLarge.copy(fontFamily = RobotoFamily),
labelMedium = MaterialTheme.typography.labelMedium.copy(fontFamily = robotoFonts), labelMedium = MaterialTheme.typography.labelMedium.copy(fontFamily = RobotoFamily),
labelSmall = MaterialTheme.typography.labelSmall.copy(fontFamily = robotoFonts), labelSmall = MaterialTheme.typography.labelSmall.copy(fontFamily = RobotoFamily),
) )
} }
@@ -213,7 +117,7 @@ fun AppTheme(
} }
} }
MaterialTheme( MaterialExpressiveTheme(
colorScheme = (predefinedColorScheme ?: colorScheme) colorScheme = (predefinedColorScheme ?: colorScheme)
.copy( .copy(
primary = colorPrimary, primary = colorPrimary,
@@ -0,0 +1,86 @@
package dev.meloda.fast.ui.theme
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import dev.meloda.fast.ui.R
val GoogleSansFamily = FontFamily(
Font(resId = R.font.google_sans_regular),
Font(
resId = R.font.google_sans_italic,
style = FontStyle.Italic
),
Font(
resId = R.font.google_sans_medium,
weight = FontWeight.Medium
),
Font(
resId = R.font.google_sans_medium_italic,
weight = FontWeight.Medium,
style = FontStyle.Italic
),
Font(
resId = R.font.google_sans_bold,
weight = FontWeight.Bold
),
Font(
resId = R.font.google_sans_bold_italic,
weight = FontWeight.Bold,
style = FontStyle.Italic
)
)
val RobotoFamily = FontFamily(
Font(
resId = R.font.roboto_thin,
weight = FontWeight.Thin
),
Font(
resId = R.font.roboto_thin_italic,
weight = FontWeight.Thin,
style = FontStyle.Italic
),
Font(
resId = R.font.roboto_light,
weight = FontWeight.Light
),
Font(
resId = R.font.roboto_light_italic,
weight = FontWeight.Light,
style = FontStyle.Italic
),
Font(resId = R.font.roboto_regular),
Font(
resId = R.font.roboto_italic,
style = FontStyle.Italic
),
Font(
resId = R.font.roboto_medium,
weight = FontWeight.Medium
),
Font(
resId = R.font.roboto_medium_italic,
weight = FontWeight.Medium,
style = FontStyle.Italic
),
Font(
resId = R.font.roboto_bold,
weight = FontWeight.Bold
),
Font(
resId = R.font.roboto_bold_italic,
weight = FontWeight.Bold,
style = FontStyle.Italic
),
Font(
resId = R.font.roboto_black,
weight = FontWeight.Black
),
Font(
resId = R.font.roboto_black_italic,
weight = FontWeight.Black,
style = FontStyle.Italic
)
)
@@ -1,9 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M19,11H7.83l4.88,-4.88c0.39,-0.39 0.39,-1.03 0,-1.42 -0.39,-0.39 -1.02,-0.39 -1.41,0l-6.59,6.59c-0.39,0.39 -0.39,1.02 0,1.41l6.59,6.59c0.39,0.39 1.02,0.39 1.41,0 0.39,-0.39 0.39,-1.02 0,-1.41L7.83,13H19c0.55,0 1,-0.45 1,-1s-0.45,-1 -1,-1z" />
</vector>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:autoMirrored="true"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#ffffff"
android:pathData="M313,520L509,716Q521,728 520.5,744Q520,760 508,772Q496,783 480,783.5Q464,784 452,772L188,508Q182,502 179.5,495Q177,488 177,480Q177,472 179.5,465Q182,458 188,452L452,188Q463,177 479.5,177Q496,177 508,188Q520,200 520,216.5Q520,233 508,245L313,440L760,440Q777,440 788.5,451.5Q800,463 800,480Q800,497 788.5,508.5Q777,520 760,520L313,520Z" />
</vector>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#ffffff"
android:pathData="M382,606L721,267Q733,255 749,255Q765,255 777,267Q789,279 789,295.5Q789,312 777,324L410,692Q398,704 382,704Q366,704 354,692L182,520Q170,508 170.5,491.5Q171,475 183,463Q195,451 211.5,451Q228,451 240,463L382,606Z"/>
</vector>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#ffffff"
android:pathData="M480,536L284,732Q273,743 256,743Q239,743 228,732Q217,721 217,704Q217,687 228,676L424,480L228,284Q217,273 217,256Q217,239 228,228Q239,217 256,217Q273,217 284,228L480,424L676,228Q687,217 704,217Q721,217 732,228Q743,239 743,256Q743,273 732,284L536,480L732,676Q743,687 743,704Q743,721 732,732Q721,743 704,743Q687,743 676,732L480,536Z"/>
</vector>
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#ffffff"
android:pathData="M200,760L257,760L648,369L591,312L200,703L200,760ZM160,840Q143,840 131.5,828.5Q120,817 120,800L120,703Q120,687 126,672.5Q132,658 143,647L648,143Q660,132 674.5,126Q689,120 705,120Q721,120 736,126Q751,132 762,144L817,200Q829,211 834.5,226Q840,241 840,256Q840,272 834.5,286.5Q829,301 817,313L313,817Q302,828 287.5,834Q273,840 257,840L160,840ZM760,256L760,256L704,200L704,200L760,256ZM619,341L591,312L591,312L648,369L648,369L619,341Z"/>
</vector>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#ffffff"
android:pathData="M480,432L324,588Q313,599 296,599Q279,599 268,588Q257,577 257,560Q257,543 268,532L452,348Q464,336 480,336Q496,336 508,348L692,532Q703,543 703,560Q703,577 692,588Q681,599 664,599Q647,599 636,588L480,432Z"/>
</vector>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#ffffff"
android:pathData="M160,800Q127,800 103.5,776.5Q80,753 80,720L80,240Q80,207 103.5,183.5Q127,160 160,160L800,160Q833,160 856.5,183.5Q880,207 880,240L880,720Q880,753 856.5,776.5Q833,800 800,800L160,800ZM800,320L501,507Q496,510 490.5,511.5Q485,513 480,513Q475,513 469.5,511.5Q464,510 459,507L160,320L160,720Q160,720 160,720Q160,720 160,720L800,720Q800,720 800,720Q800,720 800,720L800,320ZM480,440L800,240L160,240L480,440ZM160,320L160,330Q160,325 160,317.5Q160,310 160,301Q160,281 160,271Q160,261 160,272L160,240L160,240L160,272Q160,261 160,271.5Q160,282 160,301Q160,311 160,318.5Q160,326 160,330L160,320L160,720Q160,720 160,720Q160,720 160,720L160,720Q160,720 160,720Q160,720 160,720L160,320Z"/>
</vector>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#ffffff"
android:pathData="M480,800Q447,800 423.5,776.5Q400,753 400,720Q400,687 423.5,663.5Q447,640 480,640Q513,640 536.5,663.5Q560,687 560,720Q560,753 536.5,776.5Q513,800 480,800ZM480,560Q447,560 423.5,536.5Q400,513 400,480Q400,447 423.5,423.5Q447,400 480,400Q513,400 536.5,423.5Q560,447 560,480Q560,513 536.5,536.5Q513,560 480,560ZM480,320Q447,320 423.5,296.5Q400,273 400,240Q400,207 423.5,183.5Q447,160 480,160Q513,160 536.5,183.5Q560,207 560,240Q560,273 536.5,296.5Q513,320 480,320Z"/>
</vector>
@@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M8,19c1.1,0 2,-0.9 2,-2L10,7c0,-1.1 -0.9,-2 -2,-2s-2,0.9 -2,2v10c0,1.1 0.9,2 2,2zM14,7v10c0,1.1 0.9,2 2,2s2,-0.9 2,-2L18,7c0,-1.1 -0.9,-2 -2,-2s-2,0.9 -2,2z"/>
</vector>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#ffffff"
android:pathData="M320,687L320,273Q320,256 332,244.5Q344,233 360,233Q365,233 370.5,234.5Q376,236 381,239L707,446Q716,452 720.5,461Q725,470 725,480Q725,490 720.5,499Q716,508 707,514L381,721Q376,724 370.5,725.5Q365,727 360,727Q344,727 332,715.5Q320,704 320,687ZM400,480L400,480L400,480ZM400,614L610,480L400,346L400,614Z" />
</vector>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#ffffff"
android:pathData="M480,800Q346,800 253,707Q160,614 160,480Q160,346 253,253Q346,160 480,160Q549,160 612,188.5Q675,217 720,270L720,200Q720,183 731.5,171.5Q743,160 760,160Q777,160 788.5,171.5Q800,183 800,200L800,400Q800,417 788.5,428.5Q777,440 760,440L560,440Q543,440 531.5,428.5Q520,417 520,400Q520,383 531.5,371.5Q543,360 560,360L688,360Q656,304 600.5,272Q545,240 480,240Q380,240 310,310Q240,380 240,480Q240,580 310,650Q380,720 480,720Q548,720 604.5,685.5Q661,651 692,593Q700,579 714.5,573.5Q729,568 744,573Q760,578 767,594Q774,610 766,624Q725,704 649,752Q573,800 480,800Z"/>
</vector>
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:autoMirrored="true"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#ffffff"
android:pathData="M760,760L760,600Q760,550 725,515Q690,480 640,480L273,480L417,624L360,680L120,440L360,200L417,256L273,400L640,400Q723,400 781.5,458.5Q840,517 840,600L840,760L760,760Z" />
</vector>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#ffffff"
android:pathData="M433,880Q406,880 386.5,862Q367,844 363,818L354,752Q341,747 329.5,740Q318,733 307,725L245,751Q220,762 195,753Q170,744 156,721L109,639Q95,616 101,590Q107,564 128,547L181,507Q180,500 180,493.5Q180,487 180,480Q180,473 180,466.5Q180,460 181,453L128,413Q107,396 101,370Q95,344 109,321L156,239Q170,216 195,207Q220,198 245,209L307,235Q318,227 330,220Q342,213 354,208L363,142Q367,116 386.5,98Q406,80 433,80L527,80Q554,80 573.5,98Q593,116 597,142L606,208Q619,213 630.5,220Q642,227 653,235L715,209Q740,198 765,207Q790,216 804,239L851,321Q865,344 859,370Q853,396 832,413L779,453Q780,460 780,466.5Q780,473 780,480Q780,487 780,493.5Q780,500 778,507L831,547Q852,564 858,590Q864,616 850,639L802,721Q788,744 763,753Q738,762 713,751L653,725Q642,733 630,740Q618,747 606,752L597,818Q593,844 573.5,862Q554,880 527,880L433,880ZM440,800L519,800L533,694Q564,686 590.5,670.5Q617,655 639,633L738,674L777,606L691,541Q696,527 698,511.5Q700,496 700,480Q700,464 698,448.5Q696,433 691,419L777,354L738,286L639,328Q617,305 590.5,289.5Q564,274 533,266L520,160L441,160L427,266Q396,274 369.5,289.5Q343,305 321,327L222,286L183,354L269,418Q264,433 262,448Q260,463 260,480Q260,496 262,511Q264,526 269,541L183,606L222,674L321,632Q343,655 369.5,670.5Q396,686 427,694L440,800ZM482,620Q540,620 581,579Q622,538 622,480Q622,422 581,381Q540,340 482,340Q423,340 382.5,381Q342,422 342,480Q342,538 382.5,579Q423,620 482,620ZM480,480L480,480Q480,480 480,480Q480,480 480,480L480,480L480,480L480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480L480,480L480,480L480,480Q480,480 480,480Q480,480 480,480L480,480L480,480L480,480Q480,480 480,480Q480,480 480,480L480,480L480,480L480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480L480,480L480,480L480,480Q480,480 480,480Q480,480 480,480L480,480Z"/>
</vector>
@@ -1,9 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M12,4C7,4 2.73,7.11 1,11.5 2.73,15.89 7,19 12,19s9.27,-3.11 11,-7.5C21.27,7.11 17,4 12,4zM12,16.5c-2.76,0 -5,-2.24 -5,-5s2.24,-5 5,-5 5,2.24 5,5 -2.24,5 -5,5zM12,8.5c-1.66,0 -3,1.34 -3,3s1.34,3 3,3 3,-1.34 3,-3 -1.34,-3 -3,-3z" />
</vector>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#ffffff"
android:pathData="M480,640Q555,640 607.5,587.5Q660,535 660,460Q660,385 607.5,332.5Q555,280 480,280Q405,280 352.5,332.5Q300,385 300,460Q300,535 352.5,587.5Q405,640 480,640ZM480,568Q435,568 403.5,536.5Q372,505 372,460Q372,415 403.5,383.5Q435,352 480,352Q525,352 556.5,383.5Q588,415 588,460Q588,505 556.5,536.5Q525,568 480,568ZM480,760Q334,760 214,678.5Q94,597 40,460Q94,323 214,241.5Q334,160 480,160Q626,160 746,241.5Q866,323 920,460Q866,597 746,678.5Q626,760 480,760ZM480,460Q480,460 480,460Q480,460 480,460Q480,460 480,460Q480,460 480,460Q480,460 480,460Q480,460 480,460Q480,460 480,460Q480,460 480,460ZM480,680Q593,680 687.5,620.5Q782,561 832,460Q782,359 687.5,299.5Q593,240 480,240Q367,240 272.5,299.5Q178,359 128,460Q178,561 272.5,620.5Q367,680 480,680Z" />
</vector>
@@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:pathData="M480,700Q555,700 607.5,647.5Q660,595 660,520Q660,445 607.5,392.5Q555,340 480,340Q405,340 352.5,392.5Q300,445 300,520Q300,595 352.5,647.5Q405,700 480,700ZM480,620Q438,620 409,591Q380,562 380,520Q380,478 409,449Q438,420 480,420Q522,420 551,449Q580,478 580,520Q580,562 551,591Q522,620 480,620ZM160,840Q127,840 103.5,816.5Q80,793 80,760L80,280Q80,247 103.5,223.5Q127,200 160,200L286,200L336,146Q347,134 362.5,127Q378,120 395,120L565,120Q582,120 597.5,127Q613,134 624,146L674,200L800,200Q833,200 856.5,223.5Q880,247 880,280L880,760Q880,793 856.5,816.5Q833,840 800,840L160,840ZM160,760L800,760Q800,760 800,760Q800,760 800,760L800,280Q800,280 800,280Q800,280 800,280L638,280L565,200L395,200L322,280L160,280Q160,280 160,280Q160,280 160,280L160,760Q160,760 160,760Q160,760 160,760ZM480,520Q480,520 480,520Q480,520 480,520L480,520Q480,520 480,520Q480,520 480,520L480,520L480,520L480,520L480,520L480,520Q480,520 480,520Q480,520 480,520L480,520Q480,520 480,520Q480,520 480,520Z" />
</vector>
+21 -1
View File
@@ -194,6 +194,7 @@
<string name="settings_general_title">Основное</string> <string name="settings_general_title">Основное</string>
<string name="settings_general_contact_names_title">Использовать имена контактов</string> <string name="settings_general_contact_names_title">Использовать имена контактов</string>
<string name="settings_general_contact_names_summary">Приложение будет использовать доступные имена контактов для пользователей</string> <string name="settings_general_contact_names_summary">Приложение будет использовать доступные имена контактов для пользователей</string>
<string name="settings_general_show_attachment_button_summary">Показывать кнопку вложений на панели чата</string>
<string name="settings_general_enable_haptic_title">Включить тактильную отдачу</string> <string name="settings_general_enable_haptic_title">Включить тактильную отдачу</string>
<string name="settings_appearance_title">Внешний вид</string> <string name="settings_appearance_title">Внешний вид</string>
<string name="settings_appearance_multiline_title">Многострочные заголовки и сообщения</string> <string name="settings_appearance_multiline_title">Многострочные заголовки и сообщения</string>
@@ -222,7 +223,7 @@
<string name="settings_general_show_emoji_button_summary">Показывать кнопку эмоджи на панели чата</string> <string name="settings_general_show_emoji_button_summary">Показывать кнопку эмоджи на панели чата</string>
<string name="settings_features_show_time_in_action_messages_title">Показывать время в сервисных сообщениях</string> <string name="settings_features_show_time_in_action_messages_title">Показывать время в сервисных сообщениях</string>
<string name="settings_experimental_use_blur_title">Использовать размытие</string> <string name="settings_experimental_use_blur_title">Использовать размытие</string>
<string name="settings_experimental_use_blur_summary">Добавлять размытие везде, где возможно.\\nРаботает только с 12 версии Android</string> <string name="settings_experimental_use_blur_summary">Добавлять размытие везде, где возможно</string>
<string name="settings_experimental_more_animations_title">Больше анимаций</string> <string name="settings_experimental_more_animations_title">Больше анимаций</string>
<string name="warning_confirmation">Подтверждение</string> <string name="warning_confirmation">Подтверждение</string>
<string name="captcha_exit_warning">Вы уверены? Процесс ввода капчи будет отменён</string> <string name="captcha_exit_warning">Вы уверены? Процесс ввода капчи будет отменён</string>
@@ -269,4 +270,23 @@
<string name="italic">Курсив</string> <string name="italic">Курсив</string>
<string name="underline">Подчёркнутый</string> <string name="underline">Подчёркнутый</string>
<string name="link">Ссылка</string> <string name="link">Ссылка</string>
<string name="regular">Обычный</string>
<string name="login_sign_up">Регистрация</string>
<string name="login_forgot_password">Забыли пароль?</string>
<string name="settings_general_show_attachment_button_title">Показывать кнопку вложений</string>
<string name="action_copy_link">Скопировать ссылку</string>
<string name="action_copy">Скопировать</string>
<string name="action_copy_image">Скопировать изображение</string>
<string name="action_open_in">Открыть в…</string>
<string name="action_share">Поделиться</string>
<string name="yesterday">Вчера</string>
<string name="today">Сегодня</string>
<string name="year_short">Г</string>
<string name="month_short">М</string>
<string name="week_short">Н</string>
<string name="day_short">Д</string>
<string name="time_now">Сейчас</string>
<string name="confirm_chat_create_with_title">Вы действительно хотите создать чат «%s»?</string>
<string name="confirm_chat_create_empty_with_title">Вы действительно хотите создать чат «%s» только с собой?</string>
</resources> </resources>
+23 -2
View File
@@ -260,6 +260,8 @@
<string name="settings_general_contact_names_summary">App will use available contact names for users</string> <string name="settings_general_contact_names_summary">App will use available contact names for users</string>
<string name="settings_general_show_emoji_button_title">Show emoji button</string> <string name="settings_general_show_emoji_button_title">Show emoji button</string>
<string name="settings_general_show_emoji_button_summary">Show emoji button in chat panel</string> <string name="settings_general_show_emoji_button_summary">Show emoji button in chat panel</string>
<string name="settings_general_show_attachment_button_title">Show attachment button</string>
<string name="settings_general_show_attachment_button_summary">Show attachment button in chat panel</string>
<string name="settings_general_enable_haptic_title">Enable haptic</string> <string name="settings_general_enable_haptic_title">Enable haptic</string>
<string name="settings_appearance_title">Appearance</string> <string name="settings_appearance_title">Appearance</string>
<string name="settings_appearance_multiline_title">Multiline titles and messages</string> <string name="settings_appearance_multiline_title">Multiline titles and messages</string>
@@ -270,7 +272,7 @@
<string name="settings_features_long_poll_in_background_summary">Your messages will be updating even when app is not on the screen</string> <string name="settings_features_long_poll_in_background_summary">Your messages will be updating even when app is not on the screen</string>
<string name="settings_features_show_time_in_action_messages_title">Show time in action messages</string> <string name="settings_features_show_time_in_action_messages_title">Show time in action messages</string>
<string name="settings_experimental_use_blur_title">Use blur</string> <string name="settings_experimental_use_blur_title">Use blur</string>
<string name="settings_experimental_use_blur_summary">Adds blur wherever possible.\nWorks on android 12 and newer</string> <string name="settings_experimental_use_blur_summary">Adds blur wherever possible</string>
<string name="settings_experimental_more_animations_title">More animations</string> <string name="settings_experimental_more_animations_title">More animations</string>
<string name="settings_experimental_more_animations_summary">Use animations wherever possible</string> <string name="settings_experimental_more_animations_summary">Use animations wherever possible</string>
@@ -340,9 +342,28 @@
<string name="unspam_message_text">Are you sure you want to unmark this message as spam?</string> <string name="unspam_message_text">Are you sure you want to unmark this message as spam?</string>
<string name="copied_to_clipboard">Copied to clipboard</string> <string name="copied_to_clipboard">Copied to clipboard</string>
<string name="autofill">Autofill</string> <string name="autofill" tools:ignore="PrivateResource">Autofill</string>
<string name="bold">Bold</string> <string name="bold">Bold</string>
<string name="italic">Italic</string> <string name="italic">Italic</string>
<string name="underline">Underline</string> <string name="underline">Underline</string>
<string name="link">Link</string> <string name="link">Link</string>
<string name="regular">Regular</string>
<string name="login_sign_up">Sign up</string>
<string name="login_forgot_password">Forgot password?</string>
<string name="action_copy_link">Copy link</string>
<string name="action_copy">Copy</string>
<string name="action_copy_image">Copy image</string>
<string name="action_open_in">Open in…</string>
<string name="action_share">Share</string>
<string name="yesterday">Yesterday</string>
<string name="today">Today</string>
<string name="year_short">Y</string>
<string name="month_short">M</string>
<string name="week_short">W</string>
<string name="day_short">D</string>
<string name="time_now">Now</string>
<string name="confirm_chat_create_with_title">Are you sure you want to create chat «%s»?</string>
<string name="confirm_chat_create_empty_with_title">Are you sure you want to create chat «%s» only with yourself?</string>
</resources> </resources>
@@ -23,6 +23,7 @@ object AuthGraph
fun NavGraphBuilder.authNavGraph( fun NavGraphBuilder.authNavGraph(
onNavigateToMain: () -> Unit, onNavigateToMain: () -> Unit,
onNavigateToSettings: () -> Unit,
navController: NavController navController: NavController
) { ) {
navigation<AuthGraph>(startDestination = Login) { navigation<AuthGraph>(startDestination = Login) {
@@ -54,6 +55,7 @@ fun NavGraphBuilder.authNavGraph(
) )
) )
}, },
onNavigateToSettings = onNavigateToSettings,
navController = navController navController = navController
) )
@@ -19,9 +19,6 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Close
import androidx.compose.material.icons.rounded.Done
import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@@ -50,15 +47,16 @@ import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner
import coil.compose.AsyncImage import coil.compose.AsyncImage
import dev.meloda.fast.auth.captcha.CaptchaViewModel import dev.meloda.fast.auth.captcha.CaptchaViewModel
import dev.meloda.fast.auth.captcha.CaptchaViewModelImpl import dev.meloda.fast.auth.captcha.CaptchaViewModelImpl
import dev.meloda.fast.auth.captcha.model.CaptchaScreenState import dev.meloda.fast.auth.captcha.model.CaptchaScreenState
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.components.ActionInvokeDismiss import dev.meloda.fast.ui.components.ActionInvokeDismiss
import dev.meloda.fast.ui.components.MaterialDialog import dev.meloda.fast.ui.components.MaterialDialog
import dev.meloda.fast.ui.components.TextFieldErrorText import dev.meloda.fast.ui.components.TextFieldErrorText
import org.koin.androidx.compose.koinViewModel import org.koin.androidx.compose.koinViewModel
import dev.meloda.fast.ui.R as UiR
@Composable @Composable
fun CaptchaRoute( fun CaptchaRoute(
@@ -66,6 +64,7 @@ fun CaptchaRoute(
onResult: (String) -> Unit, onResult: (String) -> Unit,
viewModel: CaptchaViewModel = koinViewModel<CaptchaViewModelImpl>() viewModel: CaptchaViewModel = koinViewModel<CaptchaViewModelImpl>()
) { ) {
LocalViewModelStoreOwner.current
val screenState by viewModel.screenState.collectAsStateWithLifecycle() val screenState by viewModel.screenState.collectAsStateWithLifecycle()
val isNeedToOpenLogin by viewModel.isNeedToOpenLogin.collectAsStateWithLifecycle() val isNeedToOpenLogin by viewModel.isNeedToOpenLogin.collectAsStateWithLifecycle()
@@ -116,11 +115,11 @@ fun CaptchaScreen(
if (showExitAlert) { if (showExitAlert) {
MaterialDialog( MaterialDialog(
onDismissRequest = { showExitAlert = false }, onDismissRequest = { showExitAlert = false },
title = stringResource(id = UiR.string.warning_confirmation), title = stringResource(id = R.string.warning_confirmation),
text = stringResource(id = UiR.string.captcha_exit_warning), text = stringResource(id = R.string.captcha_exit_warning),
confirmAction = { confirmedExit = true }, confirmAction = { confirmedExit = true },
confirmText = stringResource(id = UiR.string.yes), confirmText = stringResource(id = R.string.yes),
cancelText = stringResource(id = UiR.string.no), cancelText = stringResource(id = R.string.no),
actionInvokeDismiss = ActionInvokeDismiss.Always actionInvokeDismiss = ActionInvokeDismiss.Always
) )
} }
@@ -147,7 +146,7 @@ fun CaptchaScreen(
}, },
icon = { icon = {
Icon( Icon(
imageVector = Icons.Rounded.Close, painter = painterResource(R.drawable.round_close_24px),
contentDescription = "Close icon", contentDescription = "Close icon",
tint = MaterialTheme.colorScheme.onPrimaryContainer, tint = MaterialTheme.colorScheme.onPrimaryContainer,
) )
@@ -187,7 +186,7 @@ fun CaptchaScreen(
if (LocalView.current.isInEditMode) { if (LocalView.current.isInEditMode) {
Image( Image(
painter = painterResource(id = UiR.drawable.test_captcha), painter = painterResource(id = R.drawable.test_captcha),
contentDescription = "Captcha image", contentDescription = "Captcha image",
modifier = imageModifier modifier = imageModifier
) )
@@ -219,7 +218,7 @@ fun CaptchaScreen(
.clip(RoundedCornerShape(10.dp)), .clip(RoundedCornerShape(10.dp)),
leadingIcon = { leadingIcon = {
Icon( Icon(
painter = painterResource(id = UiR.drawable.round_qr_code_24), painter = painterResource(id = R.drawable.round_qr_code_24),
contentDescription = "QR code icon", contentDescription = "QR code icon",
tint = if (showError) { tint = if (showError) {
MaterialTheme.colorScheme.error MaterialTheme.colorScheme.error
@@ -250,7 +249,7 @@ fun CaptchaScreen(
modifier = Modifier.align(Alignment.CenterHorizontally) modifier = Modifier.align(Alignment.CenterHorizontally)
) { ) {
Icon( Icon(
imageVector = Icons.Rounded.Done, painter = painterResource(R.drawable.round_check_24px),
contentDescription = "Done icon", contentDescription = "Done icon",
tint = MaterialTheme.colorScheme.onSecondaryContainer tint = MaterialTheme.colorScheme.onSecondaryContainer
) )
@@ -1,5 +1,6 @@
package dev.meloda.fast.auth.login package dev.meloda.fast.auth.login
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.util.Log import android.util.Log
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
@@ -24,6 +25,7 @@ import dev.meloda.fast.data.db.AccountsRepository
import dev.meloda.fast.data.processState import dev.meloda.fast.data.processState
import dev.meloda.fast.data.success import dev.meloda.fast.data.success
import dev.meloda.fast.datastore.AppSettings import dev.meloda.fast.datastore.AppSettings
import dev.meloda.fast.datastore.UserSettings
import dev.meloda.fast.domain.LoadUserByIdUseCase import dev.meloda.fast.domain.LoadUserByIdUseCase
import dev.meloda.fast.domain.OAuthUseCase import dev.meloda.fast.domain.OAuthUseCase
import dev.meloda.fast.model.database.AccountEntity import dev.meloda.fast.model.database.AccountEntity
@@ -33,65 +35,44 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
interface LoginViewModel { class LoginViewModel(
val screenState: StateFlow<LoginScreenState>
val loginDialog: StateFlow<LoginDialog?>
val validationArguments: StateFlow<LoginValidationArguments?>
val captchaArguments: StateFlow<CaptchaArguments?>
val userBannedArguments: StateFlow<LoginUserBannedArguments?>
val isNeedToOpenMain: StateFlow<Boolean>
val isNeedToClearCaptchaCode: StateFlow<Boolean>
val isNeedToClearValidationCode: StateFlow<Boolean>
fun onDialogConfirmed(dialog: LoginDialog, bundle: Bundle)
fun onDialogDismissed(dialog: LoginDialog)
fun onBackPressed()
fun onPasswordVisibilityButtonClicked()
fun onLoginInputChanged(newLogin: String)
fun onPasswordInputChanged(newPassword: String)
fun onSignInButtonClicked()
fun onNavigatedToMain()
fun onNavigatedToUserBanned()
fun onNavigatedToCaptcha()
fun onNavigatedToValidation()
fun onValidationCodeReceived(code: String?)
fun onValidationCodeCleared()
fun onCaptchaCodeReceived(code: String?)
fun onCaptchaCodeCleared()
}
class LoginViewModelImpl(
private val oAuthUseCase: OAuthUseCase, private val oAuthUseCase: OAuthUseCase,
private val authRepository: AuthRepository, private val authRepository: AuthRepository,
private val loadUserByIdUseCase: LoadUserByIdUseCase, private val loadUserByIdUseCase: LoadUserByIdUseCase,
private val accountsRepository: AccountsRepository, private val accountsRepository: AccountsRepository,
private val loginValidator: LoginValidator, private val loginValidator: LoginValidator,
private val longPollController: LongPollController private val longPollController: LongPollController,
) : ViewModel(), LoginViewModel { private val userSettings: UserSettings
) : ViewModel() {
private val _screenState = MutableStateFlow(LoginScreenState.EMPTY)
val screenState = _screenState.asStateFlow()
override val screenState = MutableStateFlow(LoginScreenState.EMPTY) private val _loginDialog = MutableStateFlow<LoginDialog?>(null)
override val loginDialog = MutableStateFlow<LoginDialog?>(null) val loginDialog = _loginDialog.asStateFlow()
override val validationArguments = MutableStateFlow<LoginValidationArguments?>(null) private val _validationArguments = MutableStateFlow<LoginValidationArguments?>(null)
override val captchaArguments = MutableStateFlow<CaptchaArguments?>(null) val validationArguments = _validationArguments.asStateFlow()
override val userBannedArguments = MutableStateFlow<LoginUserBannedArguments?>(null)
override val isNeedToOpenMain = MutableStateFlow(false)
override val isNeedToClearCaptchaCode = MutableStateFlow(false) private val _captchaArguments = MutableStateFlow<CaptchaArguments?>(null)
override val isNeedToClearValidationCode = MutableStateFlow(false) val captchaArguments = _captchaArguments.asStateFlow()
private val _userBannedArguments = MutableStateFlow<LoginUserBannedArguments?>(null)
val userBannedArguments = _userBannedArguments.asStateFlow()
private val _isNeedToOpenMain = MutableStateFlow(false)
val isNeedToOpenMain = _isNeedToOpenMain.asStateFlow()
private val _isNeedToClearCaptchaCode = MutableStateFlow(false)
val isNeedToClearCaptchaCode = _isNeedToClearCaptchaCode.asStateFlow()
private val _isNeedToClearValidationCode = MutableStateFlow(false)
val isNeedToClearValidationCode = _isNeedToClearValidationCode.asStateFlow()
private val validationState: StateFlow<List<LoginValidationResult>> = private val validationState: StateFlow<List<LoginValidationResult>> =
screenState.map(loginValidator::validate) screenState.map(loginValidator::validate)
@@ -115,7 +96,7 @@ class LoginViewModelImpl(
} }
} }
override fun onDialogConfirmed(dialog: LoginDialog, bundle: Bundle) { fun onDialogConfirmed(dialog: LoginDialog, bundle: Bundle) {
onDialogDismissed(dialog) onDialogDismissed(dialog)
when (dialog) { when (dialog) {
@@ -123,20 +104,24 @@ class LoginViewModelImpl(
} }
} }
override fun onDialogDismissed(dialog: LoginDialog) { fun onDialogDismissed(dialog: LoginDialog) {
loginDialog.setValue { null } when (dialog) {
is LoginDialog.Error -> Unit
}
_loginDialog.setValue { null }
} }
override fun onBackPressed() { fun onBackPressed() {
screenState.setValue { old -> old.copy(showLogo = true) } _screenState.setValue { old -> old.copy(showLogo = true) }
} }
override fun onPasswordVisibilityButtonClicked() { fun onPasswordVisibilityButtonClicked() {
screenState.setValue { old -> old.copy(passwordVisible = !old.passwordVisible) } _screenState.setValue { old -> old.copy(passwordVisible = !old.passwordVisible) }
} }
override fun onLoginInputChanged(newLogin: String) { fun onLoginInputChanged(newLogin: String) {
screenState.setValue { old -> _screenState.setValue { old ->
old.copy( old.copy(
login = newLogin.trim(), login = newLogin.trim(),
loginError = false loginError = false
@@ -144,8 +129,8 @@ class LoginViewModelImpl(
} }
} }
override fun onPasswordInputChanged(newPassword: String) { fun onPasswordInputChanged(newPassword: String) {
screenState.setValue { old -> _screenState.setValue { old ->
old.copy( old.copy(
password = newPassword.trim(), password = newPassword.trim(),
passwordError = false passwordError = false
@@ -153,47 +138,55 @@ class LoginViewModelImpl(
} }
} }
override fun onSignInButtonClicked() { fun onSignInButtonClicked() {
if (screenState.value.isLoading) return if (screenState.value.isLoading) return
if (screenState.value.showLogo) { if (screenState.value.showLogo) {
screenState.setValue { old -> old.copy(showLogo = false) } _screenState.setValue { old -> old.copy(showLogo = false) }
return return
} }
login() login()
} }
override fun onNavigatedToMain() { fun onLogoClicked() {
isNeedToOpenMain.update { false } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
userSettings.onEnableDynamicColorsChanged(
!userSettings.enableDynamicColors.value
)
}
} }
override fun onNavigatedToUserBanned() { fun onNavigatedToMain() {
userBannedArguments.update { null } _isNeedToOpenMain.update { false }
} }
override fun onNavigatedToCaptcha() { fun onNavigatedToUserBanned() {
captchaArguments.update { null } _userBannedArguments.update { null }
} }
override fun onNavigatedToValidation() { fun onNavigatedToCaptcha() {
validationArguments.update { null } _captchaArguments.update { null }
} }
override fun onValidationCodeReceived(code: String?) { fun onNavigatedToValidation() {
_validationArguments.update { null }
}
fun onValidationCodeReceived(code: String?) {
validationCode.update { code } validationCode.update { code }
} }
override fun onValidationCodeCleared() { fun onValidationCodeCleared() {
isNeedToClearValidationCode.update { false } _isNeedToClearValidationCode.update { false }
} }
override fun onCaptchaCodeReceived(code: String?) { fun onCaptchaCodeReceived(code: String?) {
captchaCode.update { code } captchaCode.update { code }
} }
override fun onCaptchaCodeCleared() { fun onCaptchaCodeCleared() {
isNeedToClearCaptchaCode.update { false } _isNeedToClearCaptchaCode.update { false }
} }
private fun login(forceSms: Boolean = false) { private fun login(forceSms: Boolean = false) {
@@ -210,7 +203,7 @@ class LoginViewModelImpl(
processValidation() processValidation()
if (!validationState.value.contains(LoginValidationResult.Valid)) return if (!validationState.value.contains(LoginValidationResult.Valid)) return
screenState.updateValue { copy(isLoading = false) } _screenState.updateValue { copy(isLoading = true) }
val currentValidationSid = validationSid.value val currentValidationSid = validationSid.value
val currentValidationCode = validationCode.value?.takeIf { currentValidationSid != null } val currentValidationCode = validationCode.value?.takeIf { currentValidationSid != null }
@@ -229,7 +222,7 @@ class LoginViewModelImpl(
error = { error -> error = { error ->
Log.d("LoginViewModelImpl", "login: error: $error") Log.d("LoginViewModelImpl", "login: error: $error")
screenState.updateValue { copy(isLoading = false) } _screenState.updateValue { copy(isLoading = false) }
captchaSid.setValue { null } captchaSid.setValue { null }
parseError(error) parseError(error)
@@ -237,8 +230,8 @@ class LoginViewModelImpl(
success = { response -> success = { response ->
val exceptionHandler = val exceptionHandler =
CoroutineExceptionHandler { _, _ -> CoroutineExceptionHandler { _, _ ->
screenState.updateValue { copy(isLoading = false) } _screenState.updateValue { copy(isLoading = false) }
loginDialog.setValue { LoginDialog.Error() } _loginDialog.setValue { LoginDialog.Error() }
} }
viewModelScope.launch(Dispatchers.IO + exceptionHandler) { viewModelScope.launch(Dispatchers.IO + exceptionHandler) {
@@ -264,8 +257,8 @@ class LoginViewModelImpl(
} }
if (exchangeToken == null) { if (exchangeToken == null) {
screenState.updateValue { copy(isLoading = false) } _screenState.updateValue { copy(isLoading = false) }
loginDialog.setValue { LoginDialog.Error() } _loginDialog.setValue { LoginDialog.Error() }
return@launch return@launch
} }
@@ -303,15 +296,15 @@ class LoginViewModelImpl(
).listenValue(viewModelScope) { state -> ).listenValue(viewModelScope) { state ->
state.processState( state.processState(
any = { any = {
screenState.updateValue { copy(isLoading = false) } _screenState.updateValue { copy(isLoading = false) }
}, },
error = ::parseError, error = ::parseError,
success = { user -> success = { user ->
if (user == null) { if (user == null) {
loginDialog.update { LoginDialog.Error() } _loginDialog.update { LoginDialog.Error() }
} else { } else {
screenState.updateValue { copy(login = "", password = "") } _screenState.updateValue { copy(login = "", password = "") }
isNeedToOpenMain.update { true } _isNeedToOpenMain.update { true }
} }
} }
) )
@@ -334,7 +327,7 @@ class LoginViewModelImpl(
validationType = error.validationType.value, validationType = error.validationType.value,
canResendSms = error.validationResend == "sms" canResendSms = error.validationResend == "sms"
) )
validationArguments.update { arguments } _validationArguments.update { arguments }
validationSid.update { error.validationSid } validationSid.update { error.validationSid }
} }
@@ -343,12 +336,12 @@ class LoginViewModelImpl(
captchaSid = error.captchaSid, captchaSid = error.captchaSid,
captchaImageUrl = error.captchaImageUrl captchaImageUrl = error.captchaImageUrl
) )
captchaArguments.update { arguments } _captchaArguments.update { arguments }
captchaSid.update { error.captchaSid } captchaSid.update { error.captchaSid }
} }
OAuthErrorDomain.InvalidCredentialsError -> { OAuthErrorDomain.InvalidCredentialsError -> {
loginDialog.setValue { _loginDialog.setValue {
LoginDialog.Error(errorText = "Wrong login or password.") LoginDialog.Error(errorText = "Wrong login or password.")
} }
} }
@@ -360,33 +353,33 @@ class LoginViewModelImpl(
restoreUrl = error.restoreUrl, restoreUrl = error.restoreUrl,
accessToken = error.accessToken accessToken = error.accessToken
) )
userBannedArguments.update { arguments } _userBannedArguments.update { arguments }
} }
OAuthErrorDomain.WrongValidationCode -> { OAuthErrorDomain.WrongValidationCode -> {
isNeedToClearValidationCode.update { true } _isNeedToClearValidationCode.update { true }
validationCode.update { null } validationCode.update { null }
loginDialog.setValue { _loginDialog.setValue {
LoginDialog.Error(errorText = "Wrong validation code.") LoginDialog.Error(errorText = "Wrong validation code.")
} }
} }
OAuthErrorDomain.WrongValidationCodeFormat -> { OAuthErrorDomain.WrongValidationCodeFormat -> {
isNeedToClearValidationCode.update { true } _isNeedToClearValidationCode.update { true }
validationCode.update { null } validationCode.update { null }
loginDialog.setValue { _loginDialog.setValue {
LoginDialog.Error(errorText = "Wrong validation code format.") LoginDialog.Error(errorText = "Wrong validation code format.")
} }
} }
OAuthErrorDomain.TooManyTriesError -> { OAuthErrorDomain.TooManyTriesError -> {
loginDialog.setValue { _loginDialog.setValue {
LoginDialog.Error(errorText = "Too many tries. Try in another hour or later.") LoginDialog.Error(errorText = "Too many tries. Try in another hour or later.")
} }
} }
OAuthErrorDomain.UnknownError -> { OAuthErrorDomain.UnknownError -> {
loginDialog.setValue { LoginDialog.Error() } _loginDialog.setValue { LoginDialog.Error() }
} }
} }
} }
@@ -399,11 +392,11 @@ class LoginViewModelImpl(
validationState.value.forEach { result -> validationState.value.forEach { result ->
when (result) { when (result) {
LoginValidationResult.LoginEmpty -> { LoginValidationResult.LoginEmpty -> {
screenState.setValue { old -> old.copy(loginError = true) } _screenState.setValue { old -> old.copy(loginError = true) }
} }
LoginValidationResult.PasswordEmpty -> { LoginValidationResult.PasswordEmpty -> {
screenState.setValue { old -> old.copy(passwordError = true) } _screenState.setValue { old -> old.copy(passwordError = true) }
} }
LoginValidationResult.Empty -> Unit LoginValidationResult.Empty -> Unit
@@ -1,9 +1,9 @@
package dev.meloda.fast.auth.login.di package dev.meloda.fast.auth.login.di
import dev.meloda.fast.auth.login.LoginViewModelImpl import dev.meloda.fast.auth.login.LoginViewModel
import dev.meloda.fast.auth.login.validation.LoginValidator
import dev.meloda.fast.domain.OAuthUseCase import dev.meloda.fast.domain.OAuthUseCase
import dev.meloda.fast.domain.OAuthUseCaseImpl import dev.meloda.fast.domain.OAuthUseCaseImpl
import dev.meloda.fast.auth.login.validation.LoginValidator
import org.koin.core.module.dsl.singleOf import org.koin.core.module.dsl.singleOf
import org.koin.core.module.dsl.viewModelOf import org.koin.core.module.dsl.viewModelOf
import org.koin.dsl.bind import org.koin.dsl.bind
@@ -11,6 +11,6 @@ import org.koin.dsl.module
val loginModule = module { val loginModule = module {
singleOf(::LoginValidator) singleOf(::LoginValidator)
viewModelOf(::LoginViewModelImpl) bind dev.meloda.fast.auth.login.LoginViewModel::class viewModelOf(::LoginViewModel)
singleOf(::OAuthUseCaseImpl) bind OAuthUseCase::class singleOf(::OAuthUseCaseImpl) bind OAuthUseCase::class
} }
@@ -3,12 +3,12 @@ package dev.meloda.fast.auth.login.navigation
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner
import androidx.navigation.NavBackStackEntry import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavController import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import dev.meloda.fast.auth.login.LoginViewModel import dev.meloda.fast.auth.login.LoginViewModel
import dev.meloda.fast.auth.login.LoginViewModelImpl
import dev.meloda.fast.auth.login.model.CaptchaArguments import dev.meloda.fast.auth.login.model.CaptchaArguments
import dev.meloda.fast.auth.login.model.LoginUserBannedArguments import dev.meloda.fast.auth.login.model.LoginUserBannedArguments
import dev.meloda.fast.auth.login.model.LoginValidationArguments import dev.meloda.fast.auth.login.model.LoginValidationArguments
@@ -24,11 +24,12 @@ fun NavGraphBuilder.loginScreen(
onNavigateToValidation: (LoginValidationArguments) -> Unit, onNavigateToValidation: (LoginValidationArguments) -> Unit,
onNavigateToMain: () -> Unit, onNavigateToMain: () -> Unit,
onNavigateToUserBanned: (LoginUserBannedArguments) -> Unit, onNavigateToUserBanned: (LoginUserBannedArguments) -> Unit,
onNavigateToSettings: () -> Unit,
navController: NavController navController: NavController
) { ) {
composable<Login> { backStackEntry -> composable<Login> { backStackEntry ->
val viewModel: LoginViewModel = val viewModel: LoginViewModel =
backStackEntry.sharedViewModel<LoginViewModelImpl>(navController = navController) backStackEntry.sharedViewModel<LoginViewModel>(navController = navController)
val clearValidationCode by viewModel.isNeedToClearValidationCode.collectAsStateWithLifecycle() val clearValidationCode by viewModel.isNeedToClearValidationCode.collectAsStateWithLifecycle()
val clearCaptchaCode by viewModel.isNeedToClearCaptchaCode.collectAsStateWithLifecycle() val clearCaptchaCode by viewModel.isNeedToClearCaptchaCode.collectAsStateWithLifecycle()
@@ -55,6 +56,7 @@ fun NavGraphBuilder.loginScreen(
onNavigateToMain = onNavigateToMain, onNavigateToMain = onNavigateToMain,
onNavigateToCaptcha = onNavigateToCaptcha, onNavigateToCaptcha = onNavigateToCaptcha,
onNavigateToValidation = onNavigateToValidation, onNavigateToValidation = onNavigateToValidation,
onNavigateToSettings = onNavigateToSettings,
validationCode = validationCode, validationCode = validationCode,
captchaCode = captchaCode, captchaCode = captchaCode,
viewModel = viewModel viewModel = viewModel
@@ -1,5 +1,6 @@
package dev.meloda.fast.auth.login.presentation package dev.meloda.fast.auth.login.presentation
import android.content.Intent
import android.os.Bundle import android.os.Bundle
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
@@ -8,6 +9,7 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@@ -27,6 +29,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.ScaffoldDefaults import androidx.compose.material3.ScaffoldDefaults
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TextField import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@@ -36,34 +39,37 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.autofill.ContentType import androidx.compose.ui.autofill.ContentType
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.FocusRequester.Companion.FocusRequesterFactory.component1
import androidx.compose.ui.focus.FocusRequester.Companion.FocusRequesterFactory.component2
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentType import androidx.compose.ui.semantics.contentType
import androidx.compose.ui.semantics.password
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.meloda.fast.auth.login.LoginViewModel import dev.meloda.fast.auth.login.LoginViewModel
import dev.meloda.fast.auth.login.LoginViewModelImpl
import dev.meloda.fast.auth.login.model.CaptchaArguments import dev.meloda.fast.auth.login.model.CaptchaArguments
import dev.meloda.fast.auth.login.model.LoginDialog import dev.meloda.fast.auth.login.model.LoginDialog
import dev.meloda.fast.auth.login.model.LoginScreenState import dev.meloda.fast.auth.login.model.LoginScreenState
import dev.meloda.fast.auth.login.model.LoginUserBannedArguments import dev.meloda.fast.auth.login.model.LoginUserBannedArguments
import dev.meloda.fast.auth.login.model.LoginValidationArguments import dev.meloda.fast.auth.login.model.LoginValidationArguments
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.common.LocalSizeConfig
import dev.meloda.fast.ui.components.MaterialDialog import dev.meloda.fast.ui.components.MaterialDialog
import dev.meloda.fast.ui.components.TextFieldErrorText import dev.meloda.fast.ui.components.TextFieldErrorText
import dev.meloda.fast.ui.theme.LocalSizeConfig
import dev.meloda.fast.ui.util.handleEnterKey import dev.meloda.fast.ui.util.handleEnterKey
import dev.meloda.fast.ui.util.handleTabKey import dev.meloda.fast.ui.util.handleTabKey
import org.koin.androidx.compose.koinViewModel import org.koin.androidx.compose.koinViewModel
import dev.meloda.fast.ui.R as UiR
@Composable @Composable
fun LoginRoute( fun LoginRoute(
@@ -71,9 +77,10 @@ fun LoginRoute(
onNavigateToMain: () -> Unit, onNavigateToMain: () -> Unit,
onNavigateToCaptcha: (CaptchaArguments) -> Unit, onNavigateToCaptcha: (CaptchaArguments) -> Unit,
onNavigateToValidation: (LoginValidationArguments) -> Unit, onNavigateToValidation: (LoginValidationArguments) -> Unit,
onNavigateToSettings: () -> Unit,
validationCode: String?, validationCode: String?,
captchaCode: String?, captchaCode: String?,
viewModel: LoginViewModel = koinViewModel<LoginViewModelImpl>() viewModel: LoginViewModel = koinViewModel()
) { ) {
val screenState by viewModel.screenState.collectAsStateWithLifecycle() val screenState by viewModel.screenState.collectAsStateWithLifecycle()
val isNeedToOpenMain by viewModel.isNeedToOpenMain.collectAsStateWithLifecycle() val isNeedToOpenMain by viewModel.isNeedToOpenMain.collectAsStateWithLifecycle()
@@ -126,7 +133,9 @@ fun LoginRoute(
onPasswordFieldEnterKeyClicked = viewModel::onSignInButtonClicked, onPasswordFieldEnterKeyClicked = viewModel::onSignInButtonClicked,
onPasswordVisibilityButtonClicked = viewModel::onPasswordVisibilityButtonClicked, onPasswordVisibilityButtonClicked = viewModel::onPasswordVisibilityButtonClicked,
onPasswordFieldGoAction = viewModel::onSignInButtonClicked, onPasswordFieldGoAction = viewModel::onSignInButtonClicked,
onSignInButtonClicked = viewModel::onSignInButtonClicked onSignInButtonClicked = viewModel::onSignInButtonClicked,
onLogoClicked = viewModel::onLogoClicked,
onLogoLongClicked = onNavigateToSettings
) )
HandleDialogs( HandleDialogs(
@@ -144,13 +153,22 @@ fun LoginScreen(
onPasswordFieldEnterKeyClicked: () -> Unit = {}, onPasswordFieldEnterKeyClicked: () -> Unit = {},
onPasswordVisibilityButtonClicked: () -> Unit = {}, onPasswordVisibilityButtonClicked: () -> Unit = {},
onPasswordFieldGoAction: () -> Unit = {}, onPasswordFieldGoAction: () -> Unit = {},
onSignInButtonClicked: () -> Unit = {} onSignInButtonClicked: () -> Unit = {},
onLogoClicked: () -> Unit = {},
onLogoLongClicked: () -> Unit = {}
) { ) {
val context = LocalContext.current
val size = LocalSizeConfig.current val size = LocalSizeConfig.current
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
val titleSpacerSize by animateDpAsState(if (size.isHeightSmall) 24.dp else 58.dp) val titleSpacerSize by animateDpAsState(
val bottomPadding by animateDpAsState(if (size.isHeightSmall) 10.dp else 30.dp) targetValue = if (size.isHeightSmall) 24.dp else 58.dp,
label = "title spacer size"
)
val bottomPadding by animateDpAsState(
targetValue = if (size.isHeightSmall) 10.dp else 30.dp,
label = "bottom padding"
)
val (loginFocusable, passwordFocusable) = val (loginFocusable, passwordFocusable) =
FocusRequester.createRefs() FocusRequester.createRefs()
@@ -163,15 +181,18 @@ fun LoginScreen(
.padding(padding) .padding(padding)
.padding(top = 30.dp) .padding(top = 30.dp)
.padding(horizontal = 30.dp) .padding(horizontal = 30.dp)
.padding(bottom = bottomPadding)
.fillMaxSize() .fillMaxSize()
) { ) {
AnimatedVisibility( AnimatedVisibility(
visible = screenState.showLogo, visible = screenState.showLogo,
enter = fadeIn(), enter = fadeIn(),
exit = fadeOut() exit = fadeOut(),
label = "Logo visibility"
) { ) {
Logo() Logo(
onLogoClicked = onLogoClicked,
onLogoLongClicked = onLogoLongClicked
)
} }
AnimatedVisibility( AnimatedVisibility(
@@ -180,7 +201,8 @@ fun LoginScreen(
.align(Alignment.Center), .align(Alignment.Center),
visible = !screenState.showLogo, visible = !screenState.showLogo,
enter = fadeIn(), enter = fadeIn(),
exit = fadeOut() exit = fadeOut(),
label = "Login visibility"
) { ) {
Column( Column(
modifier = Modifier modifier = Modifier
@@ -188,7 +210,7 @@ fun LoginScreen(
.align(Alignment.Center) .align(Alignment.Center)
) { ) {
Text( Text(
text = stringResource(id = UiR.string.sign_in_to_vk), text = stringResource(id = R.string.sign_in_to_vk),
color = MaterialTheme.colorScheme.onBackground, color = MaterialTheme.colorScheme.onBackground,
style = MaterialTheme.typography.displayMedium style = MaterialTheme.typography.displayMedium
) )
@@ -214,11 +236,11 @@ fun LoginScreen(
}, },
value = screenState.login, value = screenState.login,
onValueChange = onLoginInputChanged, onValueChange = onLoginInputChanged,
label = { Text(text = stringResource(id = UiR.string.login_hint)) }, label = { Text(text = stringResource(id = R.string.login_hint)) },
placeholder = { Text(text = stringResource(id = UiR.string.login_hint)) }, placeholder = { Text(text = stringResource(id = R.string.login_hint)) },
leadingIcon = { leadingIcon = {
Icon( Icon(
painter = painterResource(id = UiR.drawable.ic_round_person_24), painter = painterResource(id = R.drawable.ic_round_person_24),
contentDescription = "Login icon", contentDescription = "Login icon",
tint = if (screenState.loginError) { tint = if (screenState.loginError) {
MaterialTheme.colorScheme.error MaterialTheme.colorScheme.error
@@ -235,8 +257,11 @@ fun LoginScreen(
isError = screenState.loginError, isError = screenState.loginError,
singleLine = true singleLine = true
) )
AnimatedVisibility(visible = screenState.loginError) { AnimatedVisibility(
TextFieldErrorText(text = stringResource(id = UiR.string.error_empty_field)) visible = screenState.loginError,
label = "Login error visibility"
) {
TextFieldErrorText(text = stringResource(id = R.string.error_empty_field))
} }
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
@@ -255,11 +280,11 @@ fun LoginScreen(
.semantics { contentType = ContentType.Password }, .semantics { contentType = ContentType.Password },
value = screenState.password, value = screenState.password,
onValueChange = onPasswordInputChanged, onValueChange = onPasswordInputChanged,
label = { Text(text = stringResource(id = UiR.string.password_login_hint)) }, label = { Text(text = stringResource(id = R.string.password_login_hint)) },
placeholder = { Text(text = stringResource(id = UiR.string.password_login_hint)) }, placeholder = { Text(text = stringResource(id = R.string.password_login_hint)) },
leadingIcon = { leadingIcon = {
Icon( Icon(
painter = painterResource(id = UiR.drawable.round_vpn_key_24), painter = painterResource(id = R.drawable.round_vpn_key_24),
contentDescription = "Password icon", contentDescription = "Password icon",
tint = if (screenState.passwordError) { tint = if (screenState.passwordError) {
MaterialTheme.colorScheme.error MaterialTheme.colorScheme.error
@@ -270,8 +295,8 @@ fun LoginScreen(
}, },
trailingIcon = { trailingIcon = {
val imagePainter = painterResource( val imagePainter = painterResource(
id = if (screenState.passwordVisible) UiR.drawable.round_visibility_off_24 id = if (screenState.passwordVisible) R.drawable.round_visibility_off_24
else UiR.drawable.round_visibility_24 else R.drawable.round_visibility_24px
) )
IconButton(onClick = onPasswordVisibilityButtonClicked) { IconButton(onClick = onPasswordVisibilityButtonClicked) {
@@ -300,16 +325,18 @@ fun LoginScreen(
}, },
singleLine = true singleLine = true
) )
AnimatedVisibility(visible = screenState.passwordError) { AnimatedVisibility(
TextFieldErrorText(text = stringResource(id = UiR.string.error_empty_field)) visible = screenState.passwordError,
label = "Password error visibility"
) {
TextFieldErrorText(text = stringResource(id = R.string.error_empty_field))
} }
} }
} }
Column(
Box(
modifier = Modifier.align(Alignment.BottomCenter), modifier = Modifier.align(Alignment.BottomCenter),
contentAlignment = Alignment.Center horizontalAlignment = Alignment.CenterHorizontally
) { ) {
FloatingActionButton( FloatingActionButton(
onClick = { onClick = {
@@ -324,7 +351,8 @@ fun LoginScreen(
AnimatedVisibility( AnimatedVisibility(
visible = screenState.isLoading, visible = screenState.isLoading,
enter = fadeIn(), enter = fadeIn(),
exit = fadeOut() exit = fadeOut(),
label = "Progress indicator visibility"
) { ) {
CircularProgressIndicator() CircularProgressIndicator()
} }
@@ -332,21 +360,67 @@ fun LoginScreen(
AnimatedVisibility( AnimatedVisibility(
visible = !screenState.isLoading, visible = !screenState.isLoading,
enter = fadeIn(), enter = fadeIn(),
exit = fadeOut() exit = fadeOut(),
label = "Sign in icon visibility"
) { ) {
Icon( Icon(
painter = painterResource(id = UiR.drawable.ic_arrow_end), painter = painterResource(id = R.drawable.ic_arrow_end),
contentDescription = "Sign in icon", contentDescription = "Sign in icon",
tint = MaterialTheme.colorScheme.onSecondaryContainer tint = MaterialTheme.colorScheme.onSecondaryContainer
) )
} }
} }
AnimatedVisibility(
visible = screenState.showLogo,
label = "Bottom padding visibility"
) {
Spacer(Modifier.height(bottomPadding))
}
AnimatedVisibility(
visible = !screenState.showLogo,
label = "Spacer between fab and bottom text buttons visibility"
) {
Spacer(Modifier.height(4.dp))
}
AnimatedVisibility(
visible = !screenState.showLogo,
label = "Text button row visibility"
) {
Row(verticalAlignment = Alignment.CenterVertically) {
TextButton(
onClick = {
context.startActivity(
Intent(Intent.ACTION_VIEW, "https://vk.ru/join".toUri())
)
}
) {
Text(stringResource(R.string.login_sign_up))
}
Text(
text = "",
color = MaterialTheme.colorScheme.primary
)
TextButton(
onClick = {
context.startActivity(
Intent(Intent.ACTION_VIEW, "https://vk.ru/restore".toUri())
)
}
) {
Text(stringResource(R.string.login_forgot_password))
}
}
}
} }
} }
} }
} }
@Composable @Composable
fun HandleDialogs( fun HandleDialogs(
loginDialog: LoginDialog?, loginDialog: LoginDialog?,
@@ -359,12 +433,22 @@ fun HandleDialogs(
is LoginDialog.Error -> { is LoginDialog.Error -> {
MaterialDialog( MaterialDialog(
onDismissRequest = { onDismissed(loginDialog) }, onDismissRequest = { onDismissed(loginDialog) },
title = stringResource(UiR.string.title_error), title = stringResource(R.string.title_error),
text = loginDialog.errorTextResId?.let { stringResource(it) } text = loginDialog.errorTextResId?.let { stringResource(it) }
?: loginDialog.errorText ?: loginDialog.errorText
?: stringResource(UiR.string.unknown_error_occurred), ?: stringResource(R.string.unknown_error_occurred),
confirmText = stringResource(id = UiR.string.ok) confirmText = stringResource(id = R.string.ok)
) )
} }
} }
} }
@Preview
@Composable
private fun LoginScreenPreview() {
LoginScreen(
screenState = LoginScreenState.EMPTY.copy(
showLogo = false
)
)
}
@@ -1,9 +1,7 @@
package dev.meloda.fast.auth.login.presentation package dev.meloda.fast.auth.login.presentation
import android.os.Build
import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateIntAsState import androidx.compose.animation.core.animateIntAsState
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -26,22 +24,21 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import dev.meloda.fast.datastore.UserSettings
import dev.meloda.fast.ui.R import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.theme.LocalSizeConfig import dev.meloda.fast.ui.common.LocalSizeConfig
import org.koin.compose.koinInject
@OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun Logo(modifier: Modifier = Modifier) { fun Logo(
modifier: Modifier = Modifier,
onLogoClicked: () -> Unit = {},
onLogoLongClicked: () -> Unit = {}
) {
val size = LocalSizeConfig.current val size = LocalSizeConfig.current
val iconWidth by animateDpAsState(if (size.isWidthSmall) 110.dp else 134.dp) val iconWidth by animateDpAsState(if (size.isWidthSmall) 110.dp else 134.dp)
val appNameFontSize by animateIntAsState(if (size.isWidthSmall) 32 else 38) val appNameFontSize by animateIntAsState(if (size.isWidthSmall) 32 else 38)
val bottomAdditionalPadding by animateDpAsState(if (size.isHeightSmall) 10.dp else 30.dp) val bottomAdditionalPadding by animateDpAsState(if (size.isHeightSmall) 10.dp else 30.dp)
val userSettings: UserSettings = koinInject()
Box( Box(
modifier = modifier modifier = modifier
.fillMaxSize() .fillMaxSize()
@@ -64,14 +61,8 @@ fun Logo(modifier: Modifier = Modifier) {
.combinedClickable( .combinedClickable(
interactionSource = null, interactionSource = null,
indication = null, indication = null,
onLongClick = null, onLongClick = onLogoLongClicked,
onClick = { onClick = onLogoClicked
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
userSettings.onEnableDynamicColorsChanged(
!userSettings.enableDynamicColors.value
)
}
}
) )
) )
@@ -23,5 +23,4 @@ class LoginValidator {
return resultList return resultList
} }
} }
@@ -4,8 +4,6 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
@@ -16,6 +14,7 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.buildAnnotatedString
@@ -24,7 +23,7 @@ import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import dev.meloda.fast.auth.userbanned.model.UserBannedScreenState import dev.meloda.fast.auth.userbanned.model.UserBannedScreenState
import dev.meloda.fast.ui.R as UiR import dev.meloda.fast.ui.R
@Preview @Preview
@Composable @Composable
@@ -68,13 +67,13 @@ fun UserBannedScreen(
navigationIcon = { navigationIcon = {
IconButton(onClick = onBack) { IconButton(onClick = onBack) {
Icon( Icon(
imageVector = Icons.AutoMirrored.Rounded.ArrowBack, painter = painterResource(R.drawable.round_arrow_back_24px),
contentDescription = null contentDescription = null
) )
} }
}, },
title = { title = {
Text(text = stringResource(id = UiR.string.warning)) Text(text = stringResource(id = R.string.warning))
} }
) )
} }
@@ -86,7 +85,7 @@ fun UserBannedScreen(
.padding(vertical = 8.dp) .padding(vertical = 8.dp)
) { ) {
Text( Text(
text = stringResource(id = UiR.string.account_temporarily_blocked), text = stringResource(id = R.string.account_temporarily_blocked),
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
) )
@@ -94,7 +93,7 @@ fun UserBannedScreen(
Text( Text(
text = buildAnnotatedString { text = buildAnnotatedString {
withStyle(SpanStyle(fontWeight = FontWeight.Medium)) { withStyle(SpanStyle(fontWeight = FontWeight.Medium)) {
append(stringResource(id = UiR.string.user_name)) append(stringResource(id = R.string.user_name))
append(": ") append(": ")
} }
@@ -104,7 +103,7 @@ fun UserBannedScreen(
Text( Text(
text = buildAnnotatedString { text = buildAnnotatedString {
withStyle(SpanStyle(fontWeight = FontWeight.Medium)) { withStyle(SpanStyle(fontWeight = FontWeight.Medium)) {
append(stringResource(id = UiR.string.blocking_reason_title)) append(stringResource(id = R.string.blocking_reason_title))
append(": ") append(": ")
} }
append(screenState.message) append(screenState.message)
@@ -18,9 +18,6 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Close
import androidx.compose.material.icons.rounded.Done
import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@@ -56,11 +53,11 @@ import dev.meloda.fast.auth.validation.ValidationViewModel
import dev.meloda.fast.auth.validation.ValidationViewModelImpl import dev.meloda.fast.auth.validation.ValidationViewModelImpl
import dev.meloda.fast.auth.validation.model.ValidationScreenState import dev.meloda.fast.auth.validation.model.ValidationScreenState
import dev.meloda.fast.auth.validation.model.ValidationType import dev.meloda.fast.auth.validation.model.ValidationType
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.components.ActionInvokeDismiss import dev.meloda.fast.ui.components.ActionInvokeDismiss
import dev.meloda.fast.ui.components.MaterialDialog import dev.meloda.fast.ui.components.MaterialDialog
import dev.meloda.fast.ui.components.TextFieldErrorText import dev.meloda.fast.ui.components.TextFieldErrorText
import org.koin.androidx.compose.koinViewModel import org.koin.androidx.compose.koinViewModel
import dev.meloda.fast.ui.R as UiR
@Composable @Composable
fun ValidationRoute( fun ValidationRoute(
@@ -142,11 +139,11 @@ fun ValidationScreen(
if (showExitAlert) { if (showExitAlert) {
MaterialDialog( MaterialDialog(
onDismissRequest = { showExitAlert = false }, onDismissRequest = { showExitAlert = false },
title = stringResource(id = UiR.string.warning_confirmation), title = stringResource(id = R.string.warning_confirmation),
text = stringResource(id = UiR.string.validation_exit_warning), text = stringResource(id = R.string.validation_exit_warning),
confirmAction = { confirmedExit = true }, confirmAction = { confirmedExit = true },
confirmText = stringResource(id = UiR.string.yes), confirmText = stringResource(id = R.string.yes),
cancelText = stringResource(id = UiR.string.no), cancelText = stringResource(id = R.string.no),
actionInvokeDismiss = ActionInvokeDismiss.Always actionInvokeDismiss = ActionInvokeDismiss.Always
) )
} }
@@ -173,7 +170,7 @@ fun ValidationScreen(
}, },
icon = { icon = {
Icon( Icon(
imageVector = Icons.Rounded.Close, painter = painterResource(R.drawable.round_close_24px),
contentDescription = "Close icon", contentDescription = "Close icon",
tint = MaterialTheme.colorScheme.onPrimaryContainer, tint = MaterialTheme.colorScheme.onPrimaryContainer,
) )
@@ -223,7 +220,7 @@ fun ValidationScreen(
.semantics { contentType = ContentType.SmsOtpCode }, .semantics { contentType = ContentType.SmsOtpCode },
leadingIcon = { leadingIcon = {
Icon( Icon(
painter = painterResource(id = UiR.drawable.round_qr_code_24), painter = painterResource(id = R.drawable.round_qr_code_24),
contentDescription = "QR Code icon", contentDescription = "QR Code icon",
tint = if (screenState.codeError) { tint = if (screenState.codeError) {
MaterialTheme.colorScheme.error MaterialTheme.colorScheme.error
@@ -271,7 +268,7 @@ fun ValidationScreen(
}, },
icon = { icon = {
Icon( Icon(
painter = painterResource(id = UiR.drawable.round_sms_24), painter = painterResource(id = R.drawable.round_sms_24),
tint = MaterialTheme.colorScheme.onPrimary, tint = MaterialTheme.colorScheme.onPrimary,
contentDescription = "SMS icon" contentDescription = "SMS icon"
) )
@@ -287,7 +284,7 @@ fun ValidationScreen(
containerColor = MaterialTheme.colorScheme.secondaryContainer, containerColor = MaterialTheme.colorScheme.secondaryContainer,
) { ) {
Icon( Icon(
imageVector = Icons.Rounded.Done, painter = painterResource(R.drawable.round_check_24px),
contentDescription = "Done icon", contentDescription = "Done icon",
tint = MaterialTheme.colorScheme.onSecondaryContainer tint = MaterialTheme.colorScheme.onSecondaryContainer
) )
@@ -11,17 +11,13 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
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.PrimaryScrollableTabRow
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.ScrollableTabRow
import androidx.compose.material3.Tab import androidx.compose.material3.Tab
import androidx.compose.material3.TabRowDefaults
import androidx.compose.material3.TabRowDefaults.tabIndicatorOffset
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
@@ -36,6 +32,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -52,13 +49,13 @@ import dev.meloda.fast.chatmaterials.presentation.materials.FileMaterialsScreen
import dev.meloda.fast.chatmaterials.presentation.materials.LinkMaterialsScreen import dev.meloda.fast.chatmaterials.presentation.materials.LinkMaterialsScreen
import dev.meloda.fast.chatmaterials.presentation.materials.PhotoMaterialsScreen import dev.meloda.fast.chatmaterials.presentation.materials.PhotoMaterialsScreen
import dev.meloda.fast.chatmaterials.presentation.materials.VideoMaterialsScreen import dev.meloda.fast.chatmaterials.presentation.materials.VideoMaterialsScreen
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.model.TabItem import dev.meloda.fast.ui.model.TabItem
import dev.meloda.fast.ui.theme.LocalHazeState import dev.meloda.fast.ui.theme.LocalHazeState
import dev.meloda.fast.ui.theme.LocalThemeConfig import dev.meloda.fast.ui.theme.LocalThemeConfig
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.androidx.compose.koinViewModel import org.koin.androidx.compose.koinViewModel
import org.koin.core.qualifier.named import org.koin.core.qualifier.named
import dev.meloda.fast.ui.R as UiR
@Composable @Composable
fun ChatMaterialsRoute( fun ChatMaterialsRoute(
@@ -84,15 +81,15 @@ fun ChatMaterialsScreen(
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val currentTheme = LocalThemeConfig.current val currentTheme = LocalThemeConfig.current
val hazeState = remember { HazeState() } val hazeState = remember { HazeState(true) }
val titles = remember { val titles = remember {
listOf( listOf(
UiR.string.chat_attachment_photos, R.string.chat_attachment_photos,
UiR.string.chat_attachment_videos, R.string.chat_attachment_videos,
UiR.string.chat_attachment_music, R.string.chat_attachment_music,
UiR.string.chat_attachment_files, R.string.chat_attachment_files,
UiR.string.chat_attachment_links, R.string.chat_attachment_links,
) )
} }
@@ -157,7 +154,7 @@ fun ChatMaterialsScreen(
TopAppBar( TopAppBar(
title = { title = {
Text( Text(
text = stringResource(UiR.string.chat_materials_title), text = stringResource(R.string.chat_materials_title),
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.headlineSmall style = MaterialTheme.typography.headlineSmall
@@ -170,23 +167,17 @@ fun ChatMaterialsScreen(
navigationIcon = { navigationIcon = {
IconButton(onClick = onBack) { IconButton(onClick = onBack) {
Icon( Icon(
imageVector = Icons.AutoMirrored.Rounded.ArrowBack, painter = painterResource(R.drawable.round_arrow_back_24px),
contentDescription = null contentDescription = null
) )
} }
} }
) )
ScrollableTabRow( PrimaryScrollableTabRow(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
selectedTabIndex = selectedTabIndex, selectedTabIndex = selectedTabIndex,
containerColor = Color.Transparent, containerColor = Color.Transparent,
edgePadding = 0.dp, edgePadding = 0.dp
indicator = { tabPositions ->
TabRowDefaults.PrimaryIndicator(
modifier = Modifier.tabIndicatorOffset(tabPositions[selectedTabIndex]),
color = MaterialTheme.colorScheme.primary
)
}
) { ) {
tabItems.forEachIndexed { index, item -> tabItems.forEachIndexed { index, item ->
Tab( Tab(
@@ -18,8 +18,6 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.KeyboardArrowUp
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@@ -53,14 +51,13 @@ import dev.meloda.fast.model.BaseError
import dev.meloda.fast.ui.R import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.basic.ContentAlpha import dev.meloda.fast.ui.basic.ContentAlpha
import dev.meloda.fast.ui.basic.LocalContentAlpha import dev.meloda.fast.ui.basic.LocalContentAlpha
import dev.meloda.fast.ui.components.FullScreenLoader import dev.meloda.fast.ui.components.FullScreenContainedLoader
import dev.meloda.fast.ui.components.NoItemsView import dev.meloda.fast.ui.components.NoItemsView
import dev.meloda.fast.ui.components.VkErrorView import dev.meloda.fast.ui.components.VkErrorView
import dev.meloda.fast.ui.theme.LocalHazeState import dev.meloda.fast.ui.theme.LocalHazeState
import dev.meloda.fast.ui.theme.LocalThemeConfig import dev.meloda.fast.ui.theme.LocalThemeConfig
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import dev.meloda.fast.ui.R as UiR
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -105,7 +102,7 @@ fun AudioMaterialsScreen(
VkErrorView(baseError = baseError) VkErrorView(baseError = baseError)
} }
screenState.isLoading && screenState.materials.isEmpty() -> FullScreenLoader() screenState.isLoading && screenState.materials.isEmpty() -> FullScreenContainedLoader()
else -> { else -> {
PullToRefreshBox( PullToRefreshBox(
@@ -158,7 +155,7 @@ fun AudioMaterialsScreen(
.background(MaterialTheme.colorScheme.primary) .background(MaterialTheme.colorScheme.primary)
.size(42.dp) .size(42.dp)
.padding(4.dp), .padding(4.dp),
painter = painterResource(UiR.drawable.round_play_arrow_24), painter = painterResource(R.drawable.round_fill_play_arrow_24px),
contentDescription = null, contentDescription = null,
tint = contentColorFor(MaterialTheme.colorScheme.primary) tint = contentColorFor(MaterialTheme.colorScheme.primary)
) )
@@ -206,7 +203,7 @@ fun AudioMaterialsScreen(
colors = IconButtonDefaults.filledIconButtonColors() colors = IconButtonDefaults.filledIconButtonColors()
) { ) {
Icon( Icon(
imageVector = Icons.Rounded.KeyboardArrowUp, painter = painterResource(R.drawable.round_keyboard_arrow_up_24px),
contentDescription = null contentDescription = null
) )
} }
@@ -19,8 +19,6 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.KeyboardArrowUp
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@@ -46,6 +44,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
@@ -63,7 +62,7 @@ import dev.meloda.fast.model.BaseError
import dev.meloda.fast.ui.R import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.basic.ContentAlpha import dev.meloda.fast.ui.basic.ContentAlpha
import dev.meloda.fast.ui.basic.LocalContentAlpha import dev.meloda.fast.ui.basic.LocalContentAlpha
import dev.meloda.fast.ui.components.FullScreenLoader import dev.meloda.fast.ui.components.FullScreenContainedLoader
import dev.meloda.fast.ui.components.NoItemsView import dev.meloda.fast.ui.components.NoItemsView
import dev.meloda.fast.ui.components.VkErrorView import dev.meloda.fast.ui.components.VkErrorView
import dev.meloda.fast.ui.theme.LocalHazeState import dev.meloda.fast.ui.theme.LocalHazeState
@@ -114,7 +113,7 @@ fun FileMaterialsScreen(
VkErrorView(baseError = baseError) VkErrorView(baseError = baseError)
} }
screenState.isLoading && screenState.materials.isEmpty() -> FullScreenLoader() screenState.isLoading && screenState.materials.isEmpty() -> FullScreenContainedLoader()
else -> { else -> {
PullToRefreshBox( PullToRefreshBox(
@@ -238,7 +237,7 @@ fun FileMaterialsScreen(
colors = IconButtonDefaults.filledIconButtonColors() colors = IconButtonDefaults.filledIconButtonColors()
) { ) {
Icon( Icon(
imageVector = Icons.Rounded.KeyboardArrowUp, painter = painterResource(R.drawable.round_keyboard_arrow_up_24px),
contentDescription = null contentDescription = null
) )
} }
@@ -19,8 +19,6 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.KeyboardArrowUp
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@@ -46,6 +44,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
@@ -63,7 +62,7 @@ import dev.meloda.fast.model.BaseError
import dev.meloda.fast.ui.R import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.basic.ContentAlpha import dev.meloda.fast.ui.basic.ContentAlpha
import dev.meloda.fast.ui.basic.LocalContentAlpha import dev.meloda.fast.ui.basic.LocalContentAlpha
import dev.meloda.fast.ui.components.FullScreenLoader import dev.meloda.fast.ui.components.FullScreenContainedLoader
import dev.meloda.fast.ui.components.NoItemsView import dev.meloda.fast.ui.components.NoItemsView
import dev.meloda.fast.ui.components.VkErrorView import dev.meloda.fast.ui.components.VkErrorView
import dev.meloda.fast.ui.theme.LocalHazeState import dev.meloda.fast.ui.theme.LocalHazeState
@@ -114,7 +113,7 @@ fun LinkMaterialsScreen(
VkErrorView(baseError = baseError) VkErrorView(baseError = baseError)
} }
screenState.isLoading && screenState.materials.isEmpty() -> FullScreenLoader() screenState.isLoading && screenState.materials.isEmpty() -> FullScreenContainedLoader()
else -> { else -> {
PullToRefreshBox( PullToRefreshBox(
@@ -257,7 +256,7 @@ fun LinkMaterialsScreen(
colors = IconButtonDefaults.filledIconButtonColors() colors = IconButtonDefaults.filledIconButtonColors()
) { ) {
Icon( Icon(
imageVector = Icons.Rounded.KeyboardArrowUp, painter = painterResource(R.drawable.round_keyboard_arrow_up_24px),
contentDescription = null contentDescription = null
) )
} }
@@ -17,8 +17,6 @@ import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.KeyboardArrowUp
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@@ -37,6 +35,7 @@ import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -46,7 +45,7 @@ import dev.meloda.fast.chatmaterials.model.ChatMaterialsScreenState
import dev.meloda.fast.chatmaterials.model.UiChatMaterial import dev.meloda.fast.chatmaterials.model.UiChatMaterial
import dev.meloda.fast.model.BaseError import dev.meloda.fast.model.BaseError
import dev.meloda.fast.ui.R import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.components.FullScreenLoader import dev.meloda.fast.ui.components.FullScreenContainedLoader
import dev.meloda.fast.ui.components.NoItemsView import dev.meloda.fast.ui.components.NoItemsView
import dev.meloda.fast.ui.components.VkErrorView import dev.meloda.fast.ui.components.VkErrorView
import dev.meloda.fast.ui.theme.LocalHazeState import dev.meloda.fast.ui.theme.LocalHazeState
@@ -98,7 +97,7 @@ fun PhotoMaterialsScreen(
VkErrorView(baseError = baseError) VkErrorView(baseError = baseError)
} }
screenState.isLoading && screenState.materials.isEmpty() -> FullScreenLoader() screenState.isLoading && screenState.materials.isEmpty() -> FullScreenContainedLoader()
else -> { else -> {
PullToRefreshBox( PullToRefreshBox(
@@ -178,7 +177,7 @@ fun PhotoMaterialsScreen(
colors = IconButtonDefaults.filledIconButtonColors() colors = IconButtonDefaults.filledIconButtonColors()
) { ) {
Icon( Icon(
imageVector = Icons.Rounded.KeyboardArrowUp, painter = painterResource(R.drawable.round_keyboard_arrow_up_24px),
contentDescription = null contentDescription = null
) )
} }
@@ -19,8 +19,6 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.KeyboardArrowUp
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@@ -43,6 +41,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.LayoutDirection
@@ -56,7 +55,7 @@ import dev.meloda.fast.model.BaseError
import dev.meloda.fast.ui.R import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.basic.ContentAlpha import dev.meloda.fast.ui.basic.ContentAlpha
import dev.meloda.fast.ui.basic.LocalContentAlpha import dev.meloda.fast.ui.basic.LocalContentAlpha
import dev.meloda.fast.ui.components.FullScreenLoader import dev.meloda.fast.ui.components.FullScreenContainedLoader
import dev.meloda.fast.ui.components.NoItemsView import dev.meloda.fast.ui.components.NoItemsView
import dev.meloda.fast.ui.components.VkErrorView import dev.meloda.fast.ui.components.VkErrorView
import dev.meloda.fast.ui.theme.LocalHazeState import dev.meloda.fast.ui.theme.LocalHazeState
@@ -107,7 +106,7 @@ fun VideoMaterialsScreen(
VkErrorView(baseError = baseError) VkErrorView(baseError = baseError)
} }
screenState.isLoading && screenState.materials.isEmpty() -> FullScreenLoader() screenState.isLoading && screenState.materials.isEmpty() -> FullScreenContainedLoader()
else -> { else -> {
PullToRefreshBox( PullToRefreshBox(
@@ -230,7 +229,7 @@ fun VideoMaterialsScreen(
colors = IconButtonDefaults.filledIconButtonColors() colors = IconButtonDefaults.filledIconButtonColors()
) { ) {
Icon( Icon(
imageVector = Icons.Rounded.KeyboardArrowUp, painter = painterResource(R.drawable.round_keyboard_arrow_up_24px),
contentDescription = null contentDescription = null
) )
} }
@@ -38,54 +38,15 @@ import dev.meloda.fast.ui.model.api.UiConversation
import dev.meloda.fast.ui.util.ImmutableList.Companion.toImmutableList import dev.meloda.fast.ui.util.ImmutableList.Companion.toImmutableList
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
interface ConversationsViewModel { class ConversationsViewModel(
updatesParser: LongPollUpdatesParser,
val screenState: StateFlow<ConversationsScreenState>
val navigation: StateFlow<ConversationNavigation?>
val dialog: StateFlow<ConversationDialog?>
val conversations: StateFlow<List<VkConversation>>
val uiConversations: StateFlow<List<UiConversation>>
val baseError: StateFlow<BaseError?>
val currentOffset: StateFlow<Int>
val canPaginate: StateFlow<Boolean>
fun onNavigationConsumed()
fun onDialogConfirmed(dialog: ConversationDialog, bundle: Bundle)
fun onDialogDismissed(dialog: ConversationDialog)
fun onDialogItemPicked(dialog: ConversationDialog, bundle: Bundle)
fun onErrorButtonClicked()
fun onPaginationConditionsMet()
fun onOptionClicked(conversation: UiConversation, option: ConversationOption)
fun onRefresh()
fun onConversationItemClick(conversation: UiConversation)
fun onConversationItemLongClick(conversation: UiConversation)
fun onErrorConsumed()
fun setScrollIndex(index: Int)
fun setScrollOffset(offset: Int)
fun onCreateChatButtonClicked()
}
class ConversationsViewModelImpl(
private val filter: ConversationsFilter, private val filter: ConversationsFilter,
private val updatesParser: LongPollUpdatesParser,
private val conversationsUseCase: ConversationsUseCase, private val conversationsUseCase: ConversationsUseCase,
private val messagesUseCase: MessagesUseCase, private val messagesUseCase: MessagesUseCase,
private val resources: Resources, private val resources: Resources,
@@ -93,23 +54,34 @@ class ConversationsViewModelImpl(
private val imageLoader: ImageLoader, private val imageLoader: ImageLoader,
private val applicationContext: Context, private val applicationContext: Context,
private val loadConversationsByIdUseCase: LoadConversationsByIdUseCase private val loadConversationsByIdUseCase: LoadConversationsByIdUseCase
) : ConversationsViewModel, ViewModel() { ) : ViewModel() {
private val _screenState = MutableStateFlow(ConversationsScreenState.EMPTY)
val screenState = _screenState.asStateFlow()
override val screenState = MutableStateFlow(ConversationsScreenState.EMPTY) private val _navigation = MutableStateFlow<ConversationNavigation?>(null)
override val navigation = MutableStateFlow<ConversationNavigation?>(null) val navigation = _navigation.asStateFlow()
override val dialog = MutableStateFlow<ConversationDialog?>(null)
override val conversations = MutableStateFlow<List<VkConversation>>(emptyList()) private val _dialog = MutableStateFlow<ConversationDialog?>(null)
override val uiConversations = MutableStateFlow<List<UiConversation>>(emptyList()) val dialog = _dialog.asStateFlow()
private val _conversations = MutableStateFlow<List<VkConversation>>(emptyList())
val conversations = _conversations.asStateFlow()
private val _uiConversations = MutableStateFlow<List<UiConversation>>(emptyList())
val uiConversations = _uiConversations.asStateFlow()
private val pinnedConversationsCount = conversations.map { conversations -> private val pinnedConversationsCount = conversations.map { conversations ->
conversations.count(VkConversation::isPinned) conversations.count(VkConversation::isPinned)
}.stateIn(viewModelScope, SharingStarted.Eagerly, 0) }.stateIn(viewModelScope, SharingStarted.Eagerly, 0)
override val baseError = MutableStateFlow<BaseError?>(null) private val _baseError = MutableStateFlow<BaseError?>(null)
val baseError = _baseError.asStateFlow()
override val currentOffset = MutableStateFlow(0) private val _currentOffset = MutableStateFlow(0)
override val canPaginate = MutableStateFlow(false) val currentOffset = _currentOffset.asStateFlow()
private val _canPaginate = MutableStateFlow(false)
val canPaginate = _canPaginate.asStateFlow()
private val expandedConversationId = MutableStateFlow(0L) private val expandedConversationId = MutableStateFlow(0L)
@@ -118,7 +90,7 @@ class ConversationsViewModelImpl(
private val interactionsTimers = hashMapOf<Long, InteractionJob?>() private val interactionsTimers = hashMapOf<Long, InteractionJob?>()
init { init {
screenState.updateValue { copy(isArchive = filter == ConversationsFilter.ARCHIVE) } _screenState.updateValue { copy(isArchive = filter == ConversationsFilter.ARCHIVE) }
loadConversations() loadConversations()
@@ -137,11 +109,11 @@ class ConversationsViewModelImpl(
} }
} }
override fun onNavigationConsumed() { fun onNavigationConsumed() {
navigation.setValue { null } _navigation.setValue { null }
} }
override fun onDialogConfirmed(dialog: ConversationDialog, bundle: Bundle) { fun onDialogConfirmed(dialog: ConversationDialog, bundle: Bundle) {
onDialogDismissed(dialog) onDialogDismissed(dialog)
when (dialog) { when (dialog) {
@@ -170,11 +142,11 @@ class ConversationsViewModelImpl(
syncUiConversation() syncUiConversation()
} }
override fun onDialogDismissed(dialog: ConversationDialog) { fun onDialogDismissed(dialog: ConversationDialog) {
this.dialog.setValue { null } _dialog.setValue { null }
} }
override fun onDialogItemPicked(dialog: ConversationDialog, bundle: Bundle) { fun onDialogItemPicked(dialog: ConversationDialog, bundle: Bundle) {
when (dialog) { when (dialog) {
is ConversationDialog.ConversationDelete -> Unit is ConversationDialog.ConversationDelete -> Unit
is ConversationDialog.ConversationPin -> Unit is ConversationDialog.ConversationPin -> Unit
@@ -184,7 +156,7 @@ class ConversationsViewModelImpl(
} }
} }
override fun onErrorButtonClicked() { fun onErrorButtonClicked() {
when (baseError.value) { when (baseError.value) {
null -> Unit null -> Unit
@@ -197,22 +169,22 @@ class ConversationsViewModelImpl(
} }
} }
override fun onPaginationConditionsMet() { fun onPaginationConditionsMet() {
currentOffset.update { conversations.value.size } _currentOffset.update { conversations.value.size }
loadConversations() loadConversations()
} }
override fun onRefresh() { fun onRefresh() {
onErrorConsumed() onErrorConsumed()
loadConversations(offset = 0) loadConversations(offset = 0)
} }
override fun onConversationItemClick(conversation: UiConversation) { fun onConversationItemClick(conversation: UiConversation) {
collapseConversations() collapseConversations()
navigation.setValue { ConversationNavigation.MessagesHistory(peerId = conversation.id) } _navigation.setValue { ConversationNavigation.MessagesHistory(peerId = conversation.id) }
} }
override fun onConversationItemLongClick(conversation: UiConversation) { fun onConversationItemLongClick(conversation: UiConversation) {
expandedConversationId.setValue { expandedConversationId.setValue {
if (conversation.isExpanded) 0 if (conversation.isExpanded) 0
else conversation.id else conversation.id
@@ -220,13 +192,13 @@ class ConversationsViewModelImpl(
syncUiConversation() syncUiConversation()
} }
override fun onOptionClicked( fun onOptionClicked(
conversation: UiConversation, conversation: UiConversation,
option: ConversationOption option: ConversationOption
) { ) {
when (option) { when (option) {
ConversationOption.Delete -> { ConversationOption.Delete -> {
dialog.setValue { ConversationDialog.ConversationDelete(conversation.id) } _dialog.setValue { ConversationDialog.ConversationDelete(conversation.id) }
} }
ConversationOption.MarkAsRead -> { ConversationOption.MarkAsRead -> {
@@ -240,37 +212,37 @@ class ConversationsViewModelImpl(
} }
ConversationOption.Pin -> { ConversationOption.Pin -> {
dialog.setValue { ConversationDialog.ConversationPin(conversation.id) } _dialog.setValue { ConversationDialog.ConversationPin(conversation.id) }
} }
ConversationOption.Unpin -> { ConversationOption.Unpin -> {
dialog.setValue { ConversationDialog.ConversationUnpin(conversation.id) } _dialog.setValue { ConversationDialog.ConversationUnpin(conversation.id) }
} }
ConversationOption.Archive -> { ConversationOption.Archive -> {
dialog.setValue { ConversationDialog.ConversationArchive(conversation.id) } _dialog.setValue { ConversationDialog.ConversationArchive(conversation.id) }
} }
ConversationOption.Unarchive -> { ConversationOption.Unarchive -> {
dialog.setValue { ConversationDialog.ConversationUnarchive(conversation.id) } _dialog.setValue { ConversationDialog.ConversationUnarchive(conversation.id) }
} }
} }
} }
override fun onErrorConsumed() { fun onErrorConsumed() {
baseError.setValue { null } _baseError.setValue { null }
} }
override fun setScrollIndex(index: Int) { fun setScrollIndex(index: Int) {
screenState.setValue { old -> old.copy(scrollIndex = index) } _screenState.setValue { old -> old.copy(scrollIndex = index) }
} }
override fun setScrollOffset(offset: Int) { fun setScrollOffset(offset: Int) {
screenState.setValue { old -> old.copy(scrollOffset = offset) } _screenState.setValue { old -> old.copy(scrollOffset = offset) }
} }
override fun onCreateChatButtonClicked() { fun onCreateChatButtonClicked() {
navigation.setValue { ConversationNavigation.CreateChat } _navigation.setValue { ConversationNavigation.CreateChat }
} }
private fun collapseConversations() { private fun collapseConversations() {
@@ -289,7 +261,7 @@ class ConversationsViewModelImpl(
state.processState( state.processState(
error = { error -> error = { error ->
val newBaseError = VkUtils.parseError(error) val newBaseError = VkUtils.parseError(error)
baseError.update { newBaseError } _baseError.update { newBaseError }
}, },
success = { response -> success = { response ->
val conversations = response val conversations = response
@@ -304,7 +276,7 @@ class ConversationsViewModelImpl(
val paginationExhausted = !itemsCountSufficient && val paginationExhausted = !itemsCountSufficient &&
this.conversations.value.isNotEmpty() this.conversations.value.isNotEmpty()
screenState.updateValue { _screenState.updateValue {
copy(isPaginationExhausted = paginationExhausted) copy(isPaginationExhausted = paginationExhausted)
} }
@@ -321,13 +293,13 @@ class ConversationsViewModelImpl(
conversationsUseCase.storeConversations(response) conversationsUseCase.storeConversations(response)
this.conversations.emit(fullConversations) _conversations.emit(fullConversations)
syncUiConversation() syncUiConversation()
canPaginate.setValue { itemsCountSufficient } _canPaginate.setValue { itemsCountSufficient }
} }
) )
screenState.setValue { old -> _screenState.setValue { old ->
old.copy( old.copy(
isLoading = offset == 0 && state.isLoading(), isLoading = offset == 0 && state.isLoading(),
isPaginating = offset > 0 && state.isLoading() isPaginating = offset > 0 && state.isLoading()
@@ -347,11 +319,11 @@ class ConversationsViewModelImpl(
?: return@processState ?: return@processState
newConversations.removeAt(conversationIndex) newConversations.removeAt(conversationIndex)
conversations.update { newConversations.sorted() } _conversations.update { newConversations.sorted() }
syncUiConversation() syncUiConversation()
} }
) )
screenState.emit(screenState.value.copy(isLoading = state.isLoading())) _screenState.emit(screenState.value.copy(isLoading = state.isLoading()))
} }
} }
@@ -374,7 +346,7 @@ class ConversationsViewModelImpl(
} }
) )
screenState.setValue { old -> old.copy(isLoading = state.isLoading()) } _screenState.setValue { old -> old.copy(isLoading = state.isLoading()) }
} }
} }
@@ -420,7 +392,7 @@ class ConversationsViewModelImpl(
.copy(lastMessage = message) .copy(lastMessage = message)
newConversations.add(pinnedConversationsCount.value, conversation) newConversations.add(pinnedConversationsCount.value, conversation)
conversations.update { newConversations.sorted() } _conversations.update { newConversations.sorted() }
syncUiConversation() syncUiConversation()
} }
) )
@@ -461,7 +433,7 @@ class ConversationsViewModelImpl(
newConversations.add(toPosition, newConversation) newConversations.add(toPosition, newConversation)
} }
conversations.update { newConversations.sorted() } _conversations.update { newConversations.sorted() }
syncUiConversation() syncUiConversation()
} }
} }
@@ -480,7 +452,7 @@ class ConversationsViewModelImpl(
lastMessageId = message.id, lastMessageId = message.id,
lastCmId = message.cmId lastCmId = message.cmId
) )
conversations.update { newConversations } _conversations.update { newConversations }
syncUiConversation() syncUiConversation()
} }
} }
@@ -500,7 +472,7 @@ class ConversationsViewModelImpl(
unreadCount = event.unreadCount unreadCount = event.unreadCount
) )
conversations.update { newConversations } _conversations.update { newConversations }
syncUiConversation() syncUiConversation()
} }
} }
@@ -520,7 +492,7 @@ class ConversationsViewModelImpl(
unreadCount = event.unreadCount unreadCount = event.unreadCount
) )
conversations.update { newConversations } _conversations.update { newConversations }
syncUiConversation() syncUiConversation()
} }
} }
@@ -541,7 +513,7 @@ class ConversationsViewModelImpl(
interactionIds = userIds interactionIds = userIds
) )
conversations.update { newConversations } _conversations.update { newConversations }
syncUiConversation() syncUiConversation()
interactionsTimers[peerId]?.let { interactionJob -> interactionsTimers[peerId]?.let { interactionJob ->
@@ -583,7 +555,7 @@ class ConversationsViewModelImpl(
interactionIds = emptyList() interactionIds = emptyList()
) )
conversations.update { newConversations } _conversations.update { newConversations }
syncUiConversation() syncUiConversation()
interactionJob.timerJob.cancel() interactionJob.timerJob.cancel()
@@ -601,7 +573,7 @@ class ConversationsViewModelImpl(
newConversations[conversationIndex] = newConversations[conversationIndex] =
newConversations[conversationIndex].copy(majorId = event.majorId) newConversations[conversationIndex].copy(majorId = event.majorId)
conversations.setValue { newConversations.sorted() } _conversations.setValue { newConversations.sorted() }
syncUiConversation() syncUiConversation()
} }
} }
@@ -617,7 +589,7 @@ class ConversationsViewModelImpl(
newConversations[conversationIndex] = newConversations[conversationIndex] =
newConversations[conversationIndex].copy(minorId = event.minorId) newConversations[conversationIndex].copy(minorId = event.minorId)
conversations.setValue { newConversations.sorted() } _conversations.setValue { newConversations.sorted() }
syncUiConversation() syncUiConversation()
} }
} }
@@ -632,7 +604,7 @@ class ConversationsViewModelImpl(
} else { } else {
newConversations.removeAt(conversationIndex) newConversations.removeAt(conversationIndex)
conversations.setValue { newConversations.sorted() } _conversations.setValue { newConversations.sorted() }
syncUiConversation() syncUiConversation()
} }
} }
@@ -655,7 +627,7 @@ class ConversationsViewModelImpl(
newConversations.removeAt(index) newConversations.removeAt(index)
} }
conversations.update { newConversations } _conversations.update { newConversations }
syncUiConversation() syncUiConversation()
} }
@@ -669,7 +641,7 @@ class ConversationsViewModelImpl(
newConversations.add(pinnedConversationsCount.value, conversation) newConversations.add(pinnedConversationsCount.value, conversation)
} }
conversations.update { newConversations.sorted() } _conversations.update { newConversations.sorted() }
syncUiConversation() syncUiConversation()
} }
} }
@@ -691,7 +663,7 @@ class ConversationsViewModelImpl(
newConversations[conversationIndex] = newConversations[conversationIndex] =
newConversations[conversationIndex].copy(inRead = startMessageId) newConversations[conversationIndex].copy(inRead = startMessageId)
conversations.update { newConversations } _conversations.update { newConversations }
syncUiConversation() syncUiConversation()
} }
) )
@@ -763,7 +735,7 @@ class ConversationsViewModelImpl(
options = options.toImmutableList() options = options.toImmutableList()
) )
} }
uiConversations.setValue { newUiConversations } _uiConversations.setValue { newUiConversations }
return newUiConversations return newUiConversations
} }
@@ -1,6 +1,6 @@
package dev.meloda.fast.conversations.di package dev.meloda.fast.conversations.di
import dev.meloda.fast.conversations.ConversationsViewModelImpl import dev.meloda.fast.conversations.ConversationsViewModel
import dev.meloda.fast.domain.ConversationsUseCase import dev.meloda.fast.domain.ConversationsUseCase
import dev.meloda.fast.domain.ConversationsUseCaseImpl import dev.meloda.fast.domain.ConversationsUseCaseImpl
import dev.meloda.fast.model.ConversationsFilter import dev.meloda.fast.model.ConversationsFilter
@@ -22,8 +22,8 @@ val conversationsModule = module {
singleOf(::ConversationsUseCaseImpl) bind ConversationsUseCase::class singleOf(::ConversationsUseCaseImpl) bind ConversationsUseCase::class
} }
private fun Scope.createConversationsViewModel(filter: ConversationsFilter): ConversationsViewModelImpl { private fun Scope.createConversationsViewModel(filter: ConversationsFilter): ConversationsViewModel {
return ConversationsViewModelImpl( return ConversationsViewModel(
filter = filter, filter = filter,
updatesParser = get(), updatesParser = get(),
conversationsUseCase = get(), conversationsUseCase = get(),
@@ -1,19 +1,17 @@
package dev.meloda.fast.conversations.navigation package dev.meloda.fast.conversations.navigation
import androidx.activity.compose.LocalActivity
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.compose.ui.platform.LocalContext
import androidx.navigation.NavGraphBuilder import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.navigation import androidx.navigation.navigation
import dev.meloda.fast.conversations.ConversationsViewModelImpl import dev.meloda.fast.conversations.ConversationsViewModel
import dev.meloda.fast.conversations.presentation.ConversationsRoute import dev.meloda.fast.conversations.presentation.ConversationsRoute
import dev.meloda.fast.model.BaseError import dev.meloda.fast.model.BaseError
import dev.meloda.fast.model.ConversationsFilter import dev.meloda.fast.model.ConversationsFilter
import dev.meloda.fast.ui.extensions.getOrThrow
import dev.meloda.fast.ui.theme.LocalNavController import dev.meloda.fast.ui.theme.LocalNavController
import dev.meloda.fast.ui.theme.getOrThrow
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import org.koin.androidx.compose.koinViewModel import org.koin.androidx.viewmodel.ext.android.getViewModel
import org.koin.core.qualifier.named import org.koin.core.qualifier.named
@Serializable @Serializable
@@ -26,6 +24,7 @@ object Conversations
object Archive object Archive
fun NavGraphBuilder.conversationsGraph( fun NavGraphBuilder.conversationsGraph(
activity: AppCompatActivity,
onError: (BaseError) -> Unit, onError: (BaseError) -> Unit,
onNavigateToMessagesHistory: (id: Long) -> Unit, onNavigateToMessagesHistory: (id: Long) -> Unit,
onNavigateToCreateChat: () -> Unit, onNavigateToCreateChat: () -> Unit,
@@ -34,17 +33,14 @@ fun NavGraphBuilder.conversationsGraph(
navigation<ConversationsGraph>( navigation<ConversationsGraph>(
startDestination = Conversations startDestination = Conversations
) { ) {
val conversationsViewModel: ConversationsViewModel = with(activity) {
getViewModel(qualifier = named(ConversationsFilter.ALL))
}
composable<Conversations> { composable<Conversations> {
val context = LocalContext.current
val navController = LocalNavController.getOrThrow() val navController = LocalNavController.getOrThrow()
val viewModel: ConversationsViewModelImpl = koinViewModel(
qualifier = named(ConversationsFilter.ALL),
viewModelStoreOwner = context as AppCompatActivity
)
ConversationsRoute( ConversationsRoute(
viewModel = viewModel, viewModel = conversationsViewModel,
onError = onError, onError = onError,
onNavigateToMessagesHistory = onNavigateToMessagesHistory, onNavigateToMessagesHistory = onNavigateToMessagesHistory,
onNavigateToCreateChat = onNavigateToCreateChat, onNavigateToCreateChat = onNavigateToCreateChat,
@@ -53,16 +49,14 @@ fun NavGraphBuilder.conversationsGraph(
) )
} }
composable<Archive> { composable<Archive> {
val context = LocalContext.current
val navController = LocalNavController.getOrThrow() val navController = LocalNavController.getOrThrow()
val viewModel: ConversationsViewModelImpl = koinViewModel(
qualifier = named(ConversationsFilter.ARCHIVE),
viewModelStoreOwner = context as AppCompatActivity
)
ConversationsRoute( ConversationsRoute(
viewModel = viewModel, viewModel = with(activity) {
getViewModel<ConversationsViewModel>(
qualifier = named(ConversationsFilter.ARCHIVE)
)
},
onBack = navController::navigateUp, onBack = navController::navigateUp,
onError = onError, onError = onError,
onNavigateToMessagesHistory = onNavigateToMessagesHistory, onNavigateToMessagesHistory = onNavigateToMessagesHistory,
@@ -8,7 +8,7 @@ import dev.meloda.fast.conversations.model.ConversationDialog
import dev.meloda.fast.conversations.model.ConversationsScreenState import dev.meloda.fast.conversations.model.ConversationsScreenState
import dev.meloda.fast.ui.components.MaterialDialog import dev.meloda.fast.ui.components.MaterialDialog
import dev.meloda.fast.ui.R as UiR import dev.meloda.fast.ui.R
@Composable @Composable
fun HandleDialogs( fun HandleDialogs(
@@ -24,50 +24,50 @@ fun HandleDialogs(
is ConversationDialog.ConversationArchive -> { is ConversationDialog.ConversationArchive -> {
MaterialDialog( MaterialDialog(
onDismissRequest = { onDismissed(dialog) }, onDismissRequest = { onDismissed(dialog) },
title = stringResource(id = UiR.string.confirm_archive_conversation), title = stringResource(id = R.string.confirm_archive_conversation),
confirmAction = { onConfirmed(dialog, bundleOf()) }, confirmAction = { onConfirmed(dialog, bundleOf()) },
confirmText = stringResource(id = UiR.string.action_archive), confirmText = stringResource(id = R.string.action_archive),
cancelText = stringResource(id = UiR.string.cancel) cancelText = stringResource(id = R.string.cancel)
) )
} }
is ConversationDialog.ConversationUnarchive -> { is ConversationDialog.ConversationUnarchive -> {
MaterialDialog( MaterialDialog(
onDismissRequest = { onDismissed(dialog) }, onDismissRequest = { onDismissed(dialog) },
title = stringResource(id = UiR.string.confirm_unarchive_conversation), title = stringResource(id = R.string.confirm_unarchive_conversation),
confirmAction = { onConfirmed(dialog, bundleOf()) }, confirmAction = { onConfirmed(dialog, bundleOf()) },
confirmText = stringResource(id = UiR.string.action_unarchive), confirmText = stringResource(id = R.string.action_unarchive),
cancelText = stringResource(id = UiR.string.cancel) cancelText = stringResource(id = R.string.cancel)
) )
} }
is ConversationDialog.ConversationDelete -> { is ConversationDialog.ConversationDelete -> {
MaterialDialog( MaterialDialog(
onDismissRequest = { onDismissed(dialog) }, onDismissRequest = { onDismissed(dialog) },
title = stringResource(id = UiR.string.confirm_delete_conversation), title = stringResource(id = R.string.confirm_delete_conversation),
confirmAction = { onConfirmed(dialog, bundleOf()) }, confirmAction = { onConfirmed(dialog, bundleOf()) },
confirmText = stringResource(id = UiR.string.action_delete), confirmText = stringResource(id = R.string.action_delete),
cancelText = stringResource(id = UiR.string.cancel) cancelText = stringResource(id = R.string.cancel)
) )
} }
is ConversationDialog.ConversationPin -> { is ConversationDialog.ConversationPin -> {
MaterialDialog( MaterialDialog(
onDismissRequest = { onDismissed(dialog) }, onDismissRequest = { onDismissed(dialog) },
title = stringResource(id = UiR.string.confirm_pin_conversation), title = stringResource(id = R.string.confirm_pin_conversation),
confirmAction = { onConfirmed(dialog, bundleOf()) }, confirmAction = { onConfirmed(dialog, bundleOf()) },
confirmText = stringResource(id = UiR.string.action_pin), confirmText = stringResource(id = R.string.action_pin),
cancelText = stringResource(id = UiR.string.cancel) cancelText = stringResource(id = R.string.cancel)
) )
} }
is ConversationDialog.ConversationUnpin -> { is ConversationDialog.ConversationUnpin -> {
MaterialDialog( MaterialDialog(
onDismissRequest = { onDismissed(dialog) }, onDismissRequest = { onDismissed(dialog) },
title = stringResource(id = UiR.string.confirm_unpin_conversation), title = stringResource(id = R.string.confirm_unpin_conversation),
confirmAction = { onConfirmed(dialog, bundleOf()) }, confirmAction = { onConfirmed(dialog, bundleOf()) },
confirmText = stringResource(id = UiR.string.action_unpin), confirmText = stringResource(id = R.string.action_unpin),
cancelText = stringResource(id = UiR.string.cancel) cancelText = stringResource(id = R.string.cancel)
) )
} }
} }
@@ -53,7 +53,7 @@ import dev.meloda.fast.ui.model.api.UiConversation
import dev.meloda.fast.ui.util.getImage import dev.meloda.fast.ui.util.getImage
import dev.meloda.fast.ui.util.getResourcePainter import dev.meloda.fast.ui.util.getResourcePainter
import dev.meloda.fast.ui.util.getString import dev.meloda.fast.ui.util.getString
import dev.meloda.fast.ui.R as UiR import dev.meloda.fast.ui.R
val BirthdayColor = Color(0xffb00b69) val BirthdayColor = Color(0xffb00b69)
@@ -127,7 +127,7 @@ fun ConversationItem(
modifier = Modifier modifier = Modifier
.align(Alignment.Center) .align(Alignment.Center)
.size(32.dp), .size(32.dp),
painter = painterResource(id = UiR.drawable.ic_round_bookmark_border_24), painter = painterResource(id = R.drawable.ic_round_bookmark_border_24),
contentDescription = "Favorites icon", contentDescription = "Favorites icon",
tint = MaterialTheme.colorScheme.onPrimary tint = MaterialTheme.colorScheme.onPrimary
) )
@@ -150,7 +150,7 @@ fun ConversationItem(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.clip(CircleShape), .clip(CircleShape),
placeholder = painterResource(id = UiR.drawable.ic_account_circle_cut) placeholder = painterResource(id = R.drawable.ic_account_circle_cut)
) )
} }
} }
@@ -166,7 +166,7 @@ fun ConversationItem(
modifier = Modifier modifier = Modifier
.height(14.dp) .height(14.dp)
.align(Alignment.Center), .align(Alignment.Center),
painter = painterResource(id = UiR.drawable.ic_round_push_pin_24), painter = painterResource(id = R.drawable.ic_round_push_pin_24),
contentDescription = "Pin icon", contentDescription = "Pin icon",
tint = Color.White tint = Color.White
) )
@@ -222,7 +222,7 @@ fun ConversationItem(
modifier = Modifier modifier = Modifier
.align(Alignment.Center) .align(Alignment.Center)
.size(10.dp), .size(10.dp),
painter = painterResource(id = UiR.drawable.round_cake_24), painter = painterResource(id = R.drawable.round_cake_24),
contentDescription = "Birthday icon", contentDescription = "Birthday icon",
tint = Color.White tint = Color.White
) )

Some files were not shown because too many files have changed in this diff Show More