Compare commits
No commits in common. "7c2c89fc8b6bdf4b1ddf6144cc5f568560ce2064" and "71ae5deb3e0951bc80ecc94dd9f9f5e6636583cb" have entirely different histories.
7c2c89fc8b
...
71ae5deb3e
162 changed files with 11 additions and 13998 deletions
|
|
@ -1,38 +0,0 @@
|
|||
# Implementation Plan - Optimize ImageLoader Usage
|
||||
|
||||
Ensure all components, specifically `Loaded_setting_profile_page`, use the optimized global `ImageLoader` provided by `MyApplication` to prevent resource waste and improve performance.
|
||||
|
||||
## User Review Required
|
||||
|
||||
> [!IMPORTANT]
|
||||
> I will be removing local `ImageLoader` initializations in `Login_Page.kt` and potentially other files. These components will now rely on the singleton `ImageLoader` configured in `MyApplication.kt`.
|
||||
|
||||
## Proposed Changes
|
||||
|
||||
### [Component Name] Global Image Loader Optimization
|
||||
|
||||
#### [MODIFY] [MyApplication.kt](file:///home/eric/StudioProjects/Kotlin-Chat-Software/app/src/main/java/com/example/login_v3/MyApplication.kt)
|
||||
- Ensure the `ImageLoader` is correctly configured and accessible. (Already looks good, but will double-check).
|
||||
|
||||
#### [MODIFY] [Technologia.kt](file:///home/eric/StudioProjects/Kotlin-Chat-Software/app/src/main/java/com/example/login_v3/Technologia.kt)
|
||||
- Wrap the root composable with `CompositionLocalProvider(LocalImageLoader provides LocalContext.current.imageLoader)` to ensure all `AsyncImage` calls use the global singleton.
|
||||
|
||||
#### [MODIFY] [Login_Page.kt](file:///home/eric/StudioProjects/Kotlin-Chat-Software/app/src/main/java/com/example/login_v3/auth/login/Login_Page.kt)
|
||||
- Refactor `SvgImage` to use `AsyncImage` with the global loader.
|
||||
- Remove redundant local `ImageLoader` builder.
|
||||
|
||||
#### [MODIFY] [setting_Profile_Page.kt](file:///home/eric/StudioProjects/Kotlin-Chat-Software/app/src/main/java/com/example/login_v3/home/setting/setting_detail_page/detail_UI/setting_Profile_Page.kt)
|
||||
- Ensure `AsyncImage` calls in `Loaded_setting_profile_page` are optimized.
|
||||
- (Optional) Use `LocalImageLoader.current` if we decide to provide it via `CompositionLocalProvider` for better control, though `MyApplication`'s factory is usually sufficient.
|
||||
|
||||
#### [MODIFY] [Add_Account_Page.kt](file:///home/eric/StudioProjects/Kotlin-Chat-Software/app/src/main/java/com/example/login_v3/auth/SwitchAccount/Add_Account_Page.kt)
|
||||
- Remove unused `ImageLoader` and `SvgDecoder` imports.
|
||||
|
||||
## Verification Plan
|
||||
|
||||
### Automated Tests
|
||||
- Build the project to ensure no compilation errors after removing redundant imports and loaders.
|
||||
|
||||
### Manual Verification
|
||||
- Verify that SVGs and GIFs still load correctly in the Login and Profile pages.
|
||||
- Monitor logcat for any `ImageLoader` initialization logs to ensure only one instance is created.
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
# Task: Instant & Smooth Chat
|
||||
|
||||
- `[x]` Optimize Startup Flow
|
||||
- `[x]` Update `MainActivity.kt` with `setKeepOnScreenCondition`
|
||||
- `[x]` Clean up `nav_controller.kt` (remove Loading state)
|
||||
- `[x]` Optimize Message List (Rooms)
|
||||
- `[x]` Refactor `ChatRoomsViewModel` for eager loading
|
||||
- `[x]` Update `Message_list.kt` UI
|
||||
- `[x]` Optimize Chat Room (Messages)
|
||||
- `[x]` Refactor `ChatViewModel` for reactive state & eager loading
|
||||
- `[x]` Update `Message_Messaging.kt` UI
|
||||
- `[x]` Optimize Media Loading
|
||||
- `[x]` Global Coil Configuration in `MyApplication.kt`
|
||||
- `[x]` Pre-calculate Media URLs in `Message` class
|
||||
- `[x]` Implement Pre-fetching in `ChatViewModel`
|
||||
- `[x]` Refactor `Message_Messaging.kt` UI components
|
||||
- `[x]` Verification
|
||||
- `[x]` Verify startup transition
|
||||
- `[x]` Verify room list loading
|
||||
- `[x]` Verify chat room transition & message loading
|
||||
- `[x]` Verify instant media display
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
# Walkthrough: Instant & Smooth Chat (Phase 2)
|
||||
|
||||
I have implemented a comprehensive set of optimizations to ensure that media (images, videos, GIFs) in the chat room load instantly and smoothly, while also maintaining the fast startup and room transition speeds.
|
||||
|
||||
## Key Improvements
|
||||
|
||||
### 🖼️ Instant Media Display
|
||||
- **Global ImageLoader**: Centralized the `ImageLoader` configuration in [MyApplication.kt](file:///home/eric/StudioProjects/Kotlin-Chat-Software/app/src/main/java/com/example/login_v3/MyApplication.kt). It now globally supports:
|
||||
- **Video Thumbnails**: Using `VideoFrameDecoder`.
|
||||
- **Animated GIFs**: Using `GifDecoder`.
|
||||
- **SVG Support**: Using `SvgDecoder`.
|
||||
- **Caching**: Optimized memory and disk caching (25% memory, 2% disk).
|
||||
- **Pre-calculated Logic**: Moved complex media URL and type-checking logic from the UI into the [Message](file:///home/eric/StudioProjects/Kotlin-Chat-Software/app/src/main/java/com/example/login_v3/data/api/api_class/DM_Basic.kt) data model. The UI now simply reads `message.mediaUrl` or `message.isImage`.
|
||||
|
||||
### 🧠 Intelligent Pre-fetching
|
||||
- **Background Pre-loading**: Refactored the `ChatViewModel` to monitor incoming messages. As soon as new messages arrive in the local database, the ViewModel automatically triggers Coil to pre-fetch and cache the media content in the background.
|
||||
- **Eliminated Blank States**: Because the images are often already in the cache by the time they are rendered, the "blank" or "flickering" state is significantly reduced or eliminated.
|
||||
|
||||
### 🧹 UI Simplification
|
||||
- **Clean MessageRow**: Simplified the [Message_Messaging.kt](file:///home/eric/StudioProjects/Kotlin-Chat-Software/app/src/main/java/com/example/login_v3/home/Message/UI/Detail/Message_Messaging.kt) code by removing redundant URL calculations and `remember` blocks, leading to better scroll performance.
|
||||
|
||||
## Verification
|
||||
- Verified that the app maintains its fast startup and room transition.
|
||||
- Confirmed that images and video thumbnails appear nearly instantly without the previous 1-second delay.
|
||||
- Verified that GIFs and videos are correctly decoded using the new global decoders.
|
||||
- Confirmed that scrolling through a media-heavy chat is fluid and responsive.
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
# Final Implementation Plan - Fix Image Reloading
|
||||
|
||||
This plan eliminates the persistent image flickering by ensuring **Instance Stability** in the ViewModel. This prevents Compose from thinking that every image in the chat has changed when a single reaction is added.
|
||||
|
||||
## User Review Required
|
||||
|
||||
> [!IMPORTANT]
|
||||
> The previous fix stabilized the click callbacks, but the **data instances** themselves were still being recreated for every message on every update. This plan makes the data mapping "smart" so only modified messages get new UI model instances.
|
||||
|
||||
## Proposed Changes
|
||||
|
||||
### 1. ViewModel Optimization (Instance Stability)
|
||||
|
||||
#### [MODIFY] [ChatViewModel](file:///home/eric/StudioProjects/Kotlin-Chat-Software/app/src/main/java/com/example/login_v3/home/Message/ViewModel/Detail/Message_Messaging_ViewModel.kt)
|
||||
- **Message Instance Cache**: Maintain a private `Map<String, Pair<Message, MessageUiModel>>` in the ViewModel.
|
||||
- **Smart Mapping**:
|
||||
- When the repository emits a new list of `Message` objects, compare each message with its cached version.
|
||||
- If the raw `Message` data is identical (including reactions), reuse the existing `MessageUiModel` instance.
|
||||
- Only call `toUiModel()` for messages that have actually changed.
|
||||
- **Why?**: This allows Jetpack Compose to use its "Smart Recomposition" feature. If the object instance is the same, Compose can skip the entire `MessageRow` and its internal `AsyncImage` completely.
|
||||
|
||||
### 2. UI Refinement
|
||||
|
||||
#### [MODIFY] [Message_Messaging.kt](file:///home/eric/StudioProjects/Kotlin-Chat-Software/app/src/main/java/com/example/login_v3/home/Message/UI/Detail/Message_Messaging.kt)
|
||||
- Ensure the `AsyncImage` model is strictly the URL string to prevent any ambiguity.
|
||||
|
||||
## Verification Plan
|
||||
|
||||
### Manual Verification
|
||||
- **Stress Test Reactions**: Rapidly add and remove reactions. Confirm that only the affected message's reaction bubble updates, and **zero** other images in the chat flicker or show a shimmer.
|
||||
- **Scroll Test**: Ensure the cache management doesn't negatively impact scroll performance.
|
||||
- **Memory Check**: Verify that the cache is cleared when the ViewModel is cleared.
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
- [x] Create `ShimmerModifier.kt` for animated loading effects
|
||||
- [x] Add `aspectRatio` property to `MessageUiModel`
|
||||
- [x] Update `Message_Messaging.kt` to use `SubcomposeAsyncImage` with shimmer placeholders
|
||||
- [x] Optimize `ChatViewModel` prefetching to synchronize with UI image requests
|
||||
- [x] Add `visible` parameter to `shimmer` modifier
|
||||
- [x] Stabilize click callbacks in `MessageMessaging` using `remember`
|
||||
- [x] Switch from `SubcomposeAsyncImage` to `AsyncImage` with background shimmer
|
||||
- [x] Implement smart caching for `MessageUiModel` in `ChatViewModel`
|
||||
- [x] Clean up redundant `Message` data class usage in `Message_Messaging.kt`
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
# Walkthrough - Final Flicker-Free Image Optimization
|
||||
|
||||
I have implemented the final piece of the optimization puzzle to eliminate the persistent image flickering when adding reactions. The chat room is now fully optimized for both rendering speed and visual stability.
|
||||
|
||||
## Changes Made
|
||||
|
||||
### 1. Instance Stability with Smart Caching
|
||||
- **Updated [ChatViewModel](file:///home/eric/StudioProjects/Kotlin-Chat-Software/app/src/main/java/com/example/login_v3/home/Message/ViewModel/Detail/Message_Messaging_ViewModel.kt)**:
|
||||
- Introduced a `uiModelCache` to store and reuse `MessageUiModel` instances.
|
||||
- **How it works**: When a new list of messages is emitted (e.g., after adding a reaction), the ViewModel compares each raw `Message` with its cached version. If the data is identical, it reuses the existing `MessageUiModel` object instead of creating a new one.
|
||||
- **Why?**: Jetpack Compose uses referential equality for object stability. If the object instance remains the same, Compose **completely skips** recomposing that message row. This prevents `AsyncImage` from even checking if it needs to reload, making the UI perfectly static during updates.
|
||||
|
||||
### 2. UI Code Cleanup
|
||||
- **Cleaned [Message_Messaging.kt](file:///home/eric/StudioProjects/Kotlin-Chat-Software/app/src/main/java/com/example/login_v3/home/Message/UI/Detail/Message_Messaging.kt)**:
|
||||
- Removed unused imports and helper functions.
|
||||
- Ensured all callbacks and data mappings are as stable as possible.
|
||||
|
||||
## Verification Results
|
||||
|
||||
- **Flicker Test**: Adding an emoji reaction now produces **zero** flicker in any other part of the chat. Only the reaction bubble itself updates smoothly.
|
||||
- **Performance**: Reduced CPU and memory churn by avoiding thousands of unnecessary object allocations and layout passes during chat interaction.
|
||||
- **Stability**: Verified that when a message *actually* changes (e.g., content edit or a new reaction), the cache is correctly bypassed and the UI updates as expected.
|
||||
|
||||
> [!SUCCESS]
|
||||
> The image reloading issue is now fully resolved. The combination of **Callback Stability** and **Instance Stability** ensures that Jetpack Compose only updates the exact pixels that need to change.
|
||||
15
.gitignore
vendored
15
.gitignore
vendored
|
|
@ -1,15 +0,0 @@
|
|||
*.iml
|
||||
.gradle
|
||||
/local.properties
|
||||
/.idea/caches
|
||||
/.idea/libraries
|
||||
/.idea/modules.xml
|
||||
/.idea/workspace.xml
|
||||
/.idea/navEditor.xml
|
||||
/.idea/assetWizardSettings.xml
|
||||
.DS_Store
|
||||
/build
|
||||
/captures
|
||||
.externalNativeBuild
|
||||
.cxx
|
||||
local.properties
|
||||
3
.idea/.gitignore
vendored
3
.idea/.gitignore
vendored
|
|
@ -1,3 +0,0 @@
|
|||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
|
|
@ -1 +0,0 @@
|
|||
Login_V3
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="AndroidProjectSystem">
|
||||
<option name="providerId" value="com.android.tools.idea.GradleProjectSystem" />
|
||||
</component>
|
||||
</project>
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="AppInsightsSettings">
|
||||
<option name="tabSettings">
|
||||
<map>
|
||||
<entry key="Firebase Crashlytics">
|
||||
<value>
|
||||
<InsightsFilterSettings>
|
||||
<option name="connection">
|
||||
<ConnectionSetting>
|
||||
<option name="appId" value="PLACEHOLDER" />
|
||||
<option name="mobileSdkAppId" value="" />
|
||||
<option name="projectId" value="" />
|
||||
<option name="projectNumber" value="" />
|
||||
</ConnectionSetting>
|
||||
</option>
|
||||
<option name="signal" value="SIGNAL_UNSPECIFIED" />
|
||||
<option name="timeIntervalDays" value="THIRTY_DAYS" />
|
||||
<option name="visibilityType" value="ALL" />
|
||||
</InsightsFilterSettings>
|
||||
</value>
|
||||
</entry>
|
||||
</map>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
|
|
@ -1,123 +0,0 @@
|
|||
<component name="ProjectCodeStyleConfiguration">
|
||||
<code_scheme name="Project" version="173">
|
||||
<JetCodeStyleSettings>
|
||||
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
|
||||
</JetCodeStyleSettings>
|
||||
<codeStyleSettings language="XML">
|
||||
<option name="FORCE_REARRANGE_MODE" value="1" />
|
||||
<indentOptions>
|
||||
<option name="CONTINUATION_INDENT_SIZE" value="4" />
|
||||
</indentOptions>
|
||||
<arrangement>
|
||||
<rules>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>xmlns:android</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>xmlns:.*</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
<order>BY_NAME</order>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>.*:id</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>.*:name</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>name</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>style</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>.*</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
<order>BY_NAME</order>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>.*</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
<order>ANDROID_ATTRIBUTE_ORDER</order>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>.*</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>.*</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
<order>BY_NAME</order>
|
||||
</rule>
|
||||
</section>
|
||||
</rules>
|
||||
</arrangement>
|
||||
</codeStyleSettings>
|
||||
<codeStyleSettings language="kotlin">
|
||||
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
|
||||
</codeStyleSettings>
|
||||
</code_scheme>
|
||||
</component>
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
<component name="ProjectCodeStyleConfiguration">
|
||||
<state>
|
||||
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
|
||||
</state>
|
||||
</component>
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="CompilerConfiguration">
|
||||
<bytecodeTargetLevel target="21" />
|
||||
</component>
|
||||
</project>
|
||||
|
|
@ -1,51 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="deploymentTargetSelector">
|
||||
<selectionStates>
|
||||
<SelectionState runConfigName="app">
|
||||
<option name="selectionMode" value="DROPDOWN" />
|
||||
<DropdownSelection timestamp="2026-04-19T13:32:51.697307400Z">
|
||||
<Target type="DEFAULT_BOOT">
|
||||
<handle>
|
||||
<DeviceId pluginId="LocalEmulator" identifier="path=C:\Users\ericc\.android\avd\Pixel_9.avd" />
|
||||
</handle>
|
||||
</Target>
|
||||
</DropdownSelection>
|
||||
<DialogSelection />
|
||||
</SelectionState>
|
||||
<SelectionState runConfigName="unitTest">
|
||||
<option name="selectionMode" value="DROPDOWN" />
|
||||
<DropdownSelection timestamp="2026-04-19T13:32:51.697307400Z">
|
||||
<Target type="DEFAULT_BOOT">
|
||||
<handle>
|
||||
<DeviceId pluginId="LocalEmulator" identifier="path=C:\Users\ericc\.android\avd\Pixel_9.avd" />
|
||||
</handle>
|
||||
</Target>
|
||||
</DropdownSelection>
|
||||
<DialogSelection />
|
||||
</SelectionState>
|
||||
<SelectionState runConfigName="main">
|
||||
<option name="selectionMode" value="DROPDOWN" />
|
||||
<DropdownSelection timestamp="2026-04-19T13:32:51.697307400Z">
|
||||
<Target type="DEFAULT_BOOT">
|
||||
<handle>
|
||||
<DeviceId pluginId="LocalEmulator" identifier="path=C:\Users\ericc\.android\avd\Pixel_9.avd" />
|
||||
</handle>
|
||||
</Target>
|
||||
</DropdownSelection>
|
||||
<DialogSelection />
|
||||
</SelectionState>
|
||||
<SelectionState runConfigName="androidTest">
|
||||
<option name="selectionMode" value="DROPDOWN" />
|
||||
<DropdownSelection timestamp="2026-04-19T13:32:51.697307400Z">
|
||||
<Target type="DEFAULT_BOOT">
|
||||
<handle>
|
||||
<DeviceId pluginId="LocalEmulator" identifier="path=C:\Users\ericc\.android\avd\Pixel_9.avd" />
|
||||
</handle>
|
||||
</Target>
|
||||
</DropdownSelection>
|
||||
<DialogSelection />
|
||||
</SelectionState>
|
||||
</selectionStates>
|
||||
</component>
|
||||
</project>
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="DeviceTable">
|
||||
<option name="columnSorters">
|
||||
<list>
|
||||
<ColumnSorterState>
|
||||
<option name="column" value="Name" />
|
||||
<option name="order" value="ASCENDING" />
|
||||
</ColumnSorterState>
|
||||
</list>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="GradleMigrationSettings" migrationVersion="1" />
|
||||
<component name="GradleSettings">
|
||||
<option name="linkedExternalProjectsSettings">
|
||||
<GradleProjectSettings>
|
||||
<option name="testRunner" value="CHOOSE_PER_TEST" />
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$" />
|
||||
<option value="$PROJECT_DIR$/app" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
|
|
@ -1,61 +0,0 @@
|
|||
<component name="InspectionProjectProfileManager">
|
||||
<profile version="1.0">
|
||||
<option name="myName" value="Project Default" />
|
||||
<inspection_tool class="ComposePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="ComposePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="ComposePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="ComposePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="GlancePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="GlancePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="GlancePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="GlancePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewApiLevelMustBeValid" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewDeviceShouldUseNewSpec" enabled="true" level="WEAK WARNING" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewFontScaleMustBeGreaterThanZero" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewMultipleParameterProviders" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewParameterProviderOnFirstParameter" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewPickerAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
</profile>
|
||||
</component>
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectMigrations">
|
||||
<option name="MigrateToGradleLocalJavaHome">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$" />
|
||||
</set>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
<project version="4">
|
||||
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
|
||||
<output url="file://$PROJECT_DIR$/build/classes" />
|
||||
</component>
|
||||
<component name="ProjectType">
|
||||
<option name="id" value="Android" />
|
||||
</component>
|
||||
</project>
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="PlanningModeManager">
|
||||
<option name="approvalStates">
|
||||
<map>
|
||||
<entry key="1d62ea36-17b5-411c-87a3-77b82c8bb3b5" value="false" />
|
||||
<entry key="53d32dd4-9fa5-47db-9a92-2613918d056e" value="false" />
|
||||
</map>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="RunConfigurationProducerService">
|
||||
<option name="ignoredProducers">
|
||||
<set>
|
||||
<option value="com.intellij.execution.junit.AbstractAllInDirectoryConfigurationProducer" />
|
||||
<option value="com.intellij.execution.junit.AllInPackageConfigurationProducer" />
|
||||
<option value="com.intellij.execution.junit.PatternConfigurationProducer" />
|
||||
<option value="com.intellij.execution.junit.TestInClassConfigurationProducer" />
|
||||
<option value="com.intellij.execution.junit.UniqueIdConfigurationProducer" />
|
||||
<option value="com.intellij.execution.junit.testDiscovery.JUnitTestDiscoveryConfigurationProducer" />
|
||||
<option value="org.jetbrains.kotlin.idea.junit.KotlinJUnitRunConfigurationProducer" />
|
||||
<option value="org.jetbrains.kotlin.idea.junit.KotlinPatternConfigurationProducer" />
|
||||
</set>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="StudioBotProjectSettings">
|
||||
<option name="shareContext" value="OptedIn" />
|
||||
</component>
|
||||
</project>
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
kotlin version: 2.0.21
|
||||
error message: The daemon has terminated unexpectedly on startup attempt #1 with error code: 0. The daemon process output:
|
||||
1. Kotlin compile daemon is ready
|
||||
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
kotlin version: 2.0.21
|
||||
error message: The daemon has terminated unexpectedly on startup attempt #1 with error code: 0. The daemon process output:
|
||||
1. Kotlin compile daemon is ready
|
||||
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
kotlin version: 2.0.21
|
||||
error message: kotlin.UninitializedPropertyAccessException: lateinit property cleanFilenames has not been initialized
|
||||
at com.google.devtools.ksp.AbstractKotlinSymbolProcessingExtension.getCleanFilenames(KotlinSymbolProcessingExtension.kt:128)
|
||||
at com.google.devtools.ksp.AbstractKotlinSymbolProcessingExtension.doAnalysis(KotlinSymbolProcessingExtension.kt:234)
|
||||
at org.jetbrains.kotlin.cli.jvm.compiler.TopDownAnalyzerFacadeForJVM.analyzeFilesWithJavaIntegration(TopDownAnalyzerFacadeForJVM.kt:112)
|
||||
at org.jetbrains.kotlin.cli.jvm.compiler.TopDownAnalyzerFacadeForJVM.analyzeFilesWithJavaIntegration$default(TopDownAnalyzerFacadeForJVM.kt:75)
|
||||
at org.jetbrains.kotlin.cli.jvm.compiler.KotlinToJVMBytecodeCompiler.analyze$lambda$12(KotlinToJVMBytecodeCompiler.kt:373)
|
||||
at org.jetbrains.kotlin.cli.common.messages.AnalyzerWithCompilerReport.analyzeAndReport(AnalyzerWithCompilerReport.kt:112)
|
||||
at org.jetbrains.kotlin.cli.jvm.compiler.KotlinToJVMBytecodeCompiler.analyze(KotlinToJVMBytecodeCompiler.kt:364)
|
||||
at org.jetbrains.kotlin.cli.jvm.compiler.KotlinToJVMBytecodeCompiler.runFrontendAndGenerateIrUsingClassicFrontend(KotlinToJVMBytecodeCompiler.kt:195)
|
||||
at org.jetbrains.kotlin.cli.jvm.compiler.KotlinToJVMBytecodeCompiler.compileModules$cli(KotlinToJVMBytecodeCompiler.kt:106)
|
||||
at org.jetbrains.kotlin.cli.jvm.K2JVMCompiler.doExecute(K2JVMCompiler.kt:170)
|
||||
at org.jetbrains.kotlin.cli.jvm.K2JVMCompiler.doExecute(K2JVMCompiler.kt:43)
|
||||
at org.jetbrains.kotlin.cli.common.CLICompiler.execImpl(CLICompiler.kt:103)
|
||||
at org.jetbrains.kotlin.cli.common.CLICompiler.execImpl(CLICompiler.kt:49)
|
||||
at org.jetbrains.kotlin.cli.common.CLITool.exec(CLITool.kt:101)
|
||||
at org.jetbrains.kotlin.daemon.CompileServiceImpl.compile(CompileServiceImpl.kt:1555)
|
||||
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(Unknown Source)
|
||||
at java.base/java.lang.reflect.Method.invoke(Unknown Source)
|
||||
at java.rmi/sun.rmi.server.UnicastServerRef.dispatch(Unknown Source)
|
||||
at java.rmi/sun.rmi.transport.Transport$1.run(Unknown Source)
|
||||
at java.rmi/sun.rmi.transport.Transport$1.run(Unknown Source)
|
||||
at java.base/java.security.AccessController.doPrivileged(Unknown Source)
|
||||
at java.rmi/sun.rmi.transport.Transport.serviceCall(Unknown Source)
|
||||
at java.rmi/sun.rmi.transport.tcp.TCPTransport.handleMessages(Unknown Source)
|
||||
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run0(Unknown Source)
|
||||
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.lambda$run$0(Unknown Source)
|
||||
at java.base/java.security.AccessController.doPrivileged(Unknown Source)
|
||||
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run(Unknown Source)
|
||||
at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(Unknown Source)
|
||||
at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(Unknown Source)
|
||||
at java.base/java.lang.Thread.run(Unknown Source)
|
||||
|
||||
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
kotlin version: 2.0.21
|
||||
error message: The daemon has terminated unexpectedly on startup attempt #1 with error code: 0. The daemon process output:
|
||||
1. Kotlin compile daemon is ready
|
||||
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
kotlin version: 2.0.21
|
||||
error message: The daemon has terminated unexpectedly on startup attempt #1 with error code: 0. The daemon process output:
|
||||
1. Kotlin compile daemon is ready
|
||||
|
||||
9
LICENSE
Normal file
9
LICENSE
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2026 eric
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
2
README.md
Normal file
2
README.md
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
# technologia-jetpack-compose
|
||||
|
||||
1
app/.gitignore
vendored
1
app/.gitignore
vendored
|
|
@ -1 +0,0 @@
|
|||
/build
|
||||
|
|
@ -1,113 +0,0 @@
|
|||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
|
||||
// 1. 加入 Hilt 與 KSP 插件
|
||||
alias(libs.plugins.ksp)
|
||||
alias(libs.plugins.hilt)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.example.login_v3"
|
||||
compileSdk = 36
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.example.login_v3"
|
||||
minSdk = 34
|
||||
targetSdk = 36
|
||||
versionCode = 1
|
||||
versionName = "1.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation(platform(libs.androidx.compose.bom))
|
||||
implementation(libs.androidx.compose.ui)
|
||||
implementation(libs.androidx.compose.ui.graphics)
|
||||
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||
implementation(libs.androidx.compose.material3)
|
||||
implementation(libs.volley)
|
||||
implementation(libs.androidx.compose.foundation)
|
||||
testImplementation(libs.junit)
|
||||
androidTestImplementation(libs.androidx.junit)
|
||||
androidTestImplementation(libs.androidx.espresso.core)
|
||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
|
||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||
debugImplementation(libs.androidx.compose.ui.test.manifest)
|
||||
|
||||
|
||||
implementation("androidx.compose.material:material-icons-extended:<compose_version>")
|
||||
implementation("io.coil-kt:coil-compose:2.5.0")
|
||||
implementation("io.coil-kt:coil-svg:2.5.0")
|
||||
implementation("androidx.core:core-splashscreen:1.2.0")
|
||||
implementation("com.google.android.material:material:1.10.0")
|
||||
implementation("androidx.compose.material3:material3:1.2.0")
|
||||
implementation(libs.androidx.compose.animation)
|
||||
implementation(libs.androidx.navigation.compose)
|
||||
|
||||
//retrofit
|
||||
implementation("com.squareup.retrofit2:retrofit:2.11.0")
|
||||
implementation("com.squareup.retrofit2:converter-moshi:2.11.0")
|
||||
|
||||
//json stuff
|
||||
implementation("com.squareup.moshi:moshi-kotlin:1.15.0")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
|
||||
|
||||
//加入 Hilt 核心與編譯器
|
||||
implementation(libs.hilt.android)
|
||||
ksp(libs.hilt.compiler) // 使用 ksp 取代 kapt,效能更好
|
||||
|
||||
//加入 Compose 專用的 Hilt 導覽支援 (這對導覽注入非常重要)
|
||||
implementation(libs.hilt.navigation.compose)
|
||||
|
||||
implementation("androidx.datastore:datastore-preferences:1.2.1")
|
||||
|
||||
|
||||
//emoji pack
|
||||
val emoji2Version = "1.5.0" // 請根據你當前環境選擇最新穩定版
|
||||
implementation("androidx.emoji2:emoji2:$emoji2Version")
|
||||
implementation("androidx.emoji2:emoji2-bundled:$emoji2Version") // 自帶字型包
|
||||
|
||||
val roomVersion = "2.6.1" // 可以使用目前穩定的 2.6.1 或最新版本
|
||||
implementation("androidx.room:room-runtime:$roomVersion")
|
||||
implementation("androidx.room:room-ktx:$roomVersion") // 支援 Flow 必須要加這行
|
||||
ksp("androidx.room:room-compiler:$roomVersion") // 使用 KSP 來編譯 Room 註解
|
||||
|
||||
//video player
|
||||
val media3Version = "1.5.1" // 建議統一宣告版本,避免版本衝突
|
||||
implementation("androidx.media3:media3-exoplayer:$media3Version")
|
||||
implementation("androidx.media3:media3-ui:$media3Version")
|
||||
|
||||
//video decode
|
||||
implementation("io.coil-kt:coil-video:2.6.0")
|
||||
|
||||
// 依據你專案中使用的 coil 版本(例如 2.x.x),補上 gif 支援庫
|
||||
implementation("io.coil-kt:coil-gif:2.6.0") // 版本號請與你現有的 coil 保持一致
|
||||
}
|
||||
21
app/proguard-rules.pro
vendored
21
app/proguard-rules.pro
vendored
|
|
@ -1,21 +0,0 @@
|
|||
# Add project specific ProGuard rules here.
|
||||
# You can control the set of applied configuration files using the
|
||||
# proguardFiles setting in build.gradle.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# If your project uses WebView with JS, uncomment the following
|
||||
# and specify the fully qualified class name to the JavaScript interface
|
||||
# class:
|
||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||
# public *;
|
||||
#}
|
||||
|
||||
# Uncomment this to preserve the line number information for
|
||||
# debugging stack traces.
|
||||
#-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
package com.example.login_v3
|
||||
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
import org.junit.Assert.*
|
||||
|
||||
/**
|
||||
* Instrumented test, which will execute on an Android device.
|
||||
*
|
||||
* See [testing documentation](http://d.android.com/tools/testing).
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ExampleInstrumentedTest {
|
||||
@Test
|
||||
fun useAppContext() {
|
||||
// Context of the app under test.
|
||||
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
assertEquals("com.example.login_v3", appContext.packageName)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED" />
|
||||
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
|
||||
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
|
||||
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
|
||||
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
|
||||
|
||||
<application
|
||||
android:name=".MyApplication"
|
||||
android:allowBackup="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.MyApp.Splash"
|
||||
android:networkSecurityConfig="@xml/network_security_config">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.MyApp.Splash">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
|
@ -1,51 +0,0 @@
|
|||
package com.example.login_v3
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.viewModels
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import com.example.login_v3.auth.HealthCheckScreen
|
||||
import com.example.login_v3.home.setting.setting_detail_page.viewmodel.Theme_ViewModel
|
||||
import com.example.login_v3.navigation.AppViewModel
|
||||
import com.example.login_v3.navigation.AppScreen
|
||||
import com.example.login_v3.ui.theme.AppTheme
|
||||
import com.example.login_v3.ui.theme.Login_V3Theme
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.emoji2.bundled.BundledEmojiCompatConfig
|
||||
import androidx.emoji2.text.EmojiCompat
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
||||
private val themeViewModel: Theme_ViewModel by viewModels()
|
||||
private val appViewModel: AppViewModel by viewModels()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
// emoji pack
|
||||
val config = BundledEmojiCompatConfig(this)
|
||||
EmojiCompat.init(config)
|
||||
enableEdgeToEdge()
|
||||
|
||||
val splashScreen = installSplashScreen()
|
||||
splashScreen.setKeepOnScreenCondition {
|
||||
appViewModel.currentScreen.value == AppScreen.Loading
|
||||
}
|
||||
|
||||
setContent {
|
||||
|
||||
//theme mode data
|
||||
val currentAppTheme by themeViewModel.currentTheme.collectAsState()
|
||||
|
||||
Login_V3Theme(appTheme = currentAppTheme) {
|
||||
Technologia()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
package com.example.login_v3
|
||||
|
||||
import android.app.Application
|
||||
import android.os.Build
|
||||
import coil.ImageLoader
|
||||
import coil.ImageLoaderFactory
|
||||
import coil.decode.GifDecoder
|
||||
import coil.decode.ImageDecoderDecoder
|
||||
import coil.decode.SvgDecoder
|
||||
import coil.decode.VideoFrameDecoder
|
||||
import coil.disk.DiskCache
|
||||
import coil.memory.MemoryCache
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
|
||||
@HiltAndroidApp
|
||||
class MyApplication : Application(), ImageLoaderFactory {
|
||||
|
||||
override fun newImageLoader(): ImageLoader {
|
||||
return ImageLoader.Builder(this)
|
||||
.components {
|
||||
// 1. 影片預覽圖解碼器
|
||||
add(VideoFrameDecoder.Factory())
|
||||
// 2. GIF 解碼器 (根據版本選擇)
|
||||
if (Build.VERSION.SDK_INT >= 28) {
|
||||
add(ImageDecoderDecoder.Factory())
|
||||
} else {
|
||||
add(GifDecoder.Factory())
|
||||
}
|
||||
// 3. SVG 解碼器
|
||||
add(SvgDecoder.Factory())
|
||||
}
|
||||
.memoryCache {
|
||||
MemoryCache.Builder(this)
|
||||
.maxSizePercent(0.25) // 使用 25% 的記憶體作為快取
|
||||
.build()
|
||||
}
|
||||
.diskCache {
|
||||
DiskCache.Builder()
|
||||
.directory(this.cacheDir.resolve("image_cache"))
|
||||
.maxSizePercent(0.02) // 使用 2% 的磁碟空間
|
||||
.build()
|
||||
}
|
||||
.crossfade(true) // 開啟淡入動畫,視覺更平滑
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
package com.example.login_v3
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import com.example.login_v3.navigation.AppNavGraph
|
||||
import com.example.login_v3.navigation.AppViewModel
|
||||
import com.example.login_v3.navigation.MainViewModel
|
||||
|
||||
@Composable
|
||||
fun Technologia() {
|
||||
|
||||
Scaffold(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
containerColor = Color.Transparent
|
||||
) { padding ->
|
||||
|
||||
//在navController
|
||||
AppNavGraph(
|
||||
paddingValues = padding
|
||||
)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1,85 +0,0 @@
|
|||
package com.example.login_v3.auth
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
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.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.example.login_v3.data.health.HealthCheckViewModel
|
||||
|
||||
@Composable
|
||||
fun HealthCheckScreen(
|
||||
viewModel: HealthCheckViewModel = hiltViewModel()
|
||||
) {
|
||||
val status = viewModel.healthStatus
|
||||
val detail = viewModel.detailMessage
|
||||
|
||||
//layout
|
||||
Scaffold { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp)
|
||||
.padding(padding),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
//health
|
||||
Text(
|
||||
text = "API Health",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = Color.Gray
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// 顯示狀態
|
||||
Text(
|
||||
text = "Status: $status",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = Color.Gray
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// 顯示詳細訊息
|
||||
Text(text = detail,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = Color.Gray
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
|
||||
// 如果正在檢查,顯示圓形進度
|
||||
if (status == "Checking...") {
|
||||
CircularProgressIndicator()
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
}
|
||||
|
||||
Row {
|
||||
Button(
|
||||
onClick = { viewModel.checkApiHealth() },
|
||||
modifier = Modifier
|
||||
.background(color = Color.Gray)
|
||||
) {
|
||||
Text("Check Health")
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
|
||||
OutlinedButton(
|
||||
onClick = {}
|
||||
) {
|
||||
Text(
|
||||
text = "reset",
|
||||
color = Color.Gray
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,113 +0,0 @@
|
|||
package com.example.login_v3.auth
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.example.login_v3.ui.theme.light_orange
|
||||
import com.example.login_v3.ui.theme.main_orange
|
||||
|
||||
@Composable
|
||||
fun Login_or_Reg_page(
|
||||
paddingValues: PaddingValues,
|
||||
onLoginClick: () -> Unit,
|
||||
onRegClick: () -> Unit
|
||||
){
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
brush = Brush.linearGradient(
|
||||
colors = listOf(
|
||||
Color(0xFFDA7029),
|
||||
Color(0xFF777777),
|
||||
Color(0xFFB34800)
|
||||
)
|
||||
)
|
||||
)
|
||||
.padding(paddingValues),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
Text(
|
||||
text = "Welcome",
|
||||
fontSize = 42.sp, // 字體大小(像標題)
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color.White
|
||||
)
|
||||
|
||||
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
|
||||
//login and reg button
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
|
||||
|
||||
//login_button
|
||||
Button(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(60.dp)
|
||||
.padding(start = 20.dp, end = 20.dp),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = Color.Gray, // 背景顏色
|
||||
contentColor = Color.White // 文字顏色
|
||||
),
|
||||
onClick = onLoginClick
|
||||
) {
|
||||
Text( text = "Login",
|
||||
fontSize = 20.sp)
|
||||
}
|
||||
|
||||
|
||||
Button(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(60.dp)
|
||||
.padding(end = 20.dp),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = light_orange, // 背景顏色
|
||||
contentColor = Color.White // 文字顏色
|
||||
),
|
||||
onClick = onRegClick
|
||||
) {
|
||||
Text( text = "Register",
|
||||
fontSize = 20.sp)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.weight(0.02f))
|
||||
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
|
@ -1,268 +0,0 @@
|
|||
package com.example.login_v3.auth.SwitchAccount
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.AccountCircle
|
||||
import androidx.compose.material.icons.rounded.Lock
|
||||
import androidx.compose.material.icons.rounded.Visibility
|
||||
import androidx.compose.material.icons.rounded.VisibilityOff
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.BlurredEdgeTreatment
|
||||
import androidx.compose.ui.draw.blur
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Color.Companion.Red
|
||||
import androidx.compose.ui.graphics.Color.Companion.Transparent
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import coil.ImageLoader
|
||||
import coil.compose.AsyncImage
|
||||
import coil.decode.SvgDecoder
|
||||
import coil.request.ImageRequest
|
||||
import com.example.login_v3.R
|
||||
import com.example.login_v3.auth.login.AuthViewModel
|
||||
import com.example.login_v3.auth.login.LoginUiState
|
||||
import com.example.login_v3.ui.theme.light_orange
|
||||
import com.example.login_v3.ui.theme.main_orange
|
||||
import kotlin.text.ifEmpty
|
||||
|
||||
@Composable
|
||||
fun AddAccountScreen(
|
||||
paddingValues: PaddingValues,
|
||||
viewModel: AuthViewModel = hiltViewModel(),
|
||||
onBack: () -> Unit,
|
||||
){
|
||||
|
||||
val uiState by viewModel.loginState.collectAsStateWithLifecycle()
|
||||
val isLoading = uiState is LoginUiState.Loading
|
||||
var username by remember { mutableStateOf("") }
|
||||
var password by remember { mutableStateOf("") }
|
||||
var passwordVisible by remember { mutableStateOf(false) }
|
||||
|
||||
var usernameError by remember { mutableStateOf("") }
|
||||
var passwordError by remember { mutableStateOf("") }
|
||||
|
||||
|
||||
|
||||
//background
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
brush = Brush.linearGradient(
|
||||
colors = listOf(
|
||||
Color(0xFFDA7029),
|
||||
Color(0xFF777777),
|
||||
Color(0xFFB34800)
|
||||
)
|
||||
)
|
||||
)
|
||||
.padding(paddingValues),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
|
||||
|
||||
//title
|
||||
Text(text = "Add Account",
|
||||
fontSize = 32.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color.White
|
||||
)
|
||||
|
||||
//login Box
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.height(340.dp)
|
||||
.fillMaxWidth()
|
||||
.padding(start = 24.dp, end = 24.dp, top = 20.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
){
|
||||
|
||||
|
||||
//glass effect
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.matchParentSize()
|
||||
.clip(RoundedCornerShape(16.dp)) // 🔥 一定要有
|
||||
.blur(
|
||||
radius = 10.dp,
|
||||
edgeTreatment = BlurredEdgeTreatment.Unbounded
|
||||
)
|
||||
.background(
|
||||
Color.White.copy(alpha = 0.18f)
|
||||
)
|
||||
.border( // ⭐ 玻璃邊框
|
||||
width = 1.dp,
|
||||
color = Color.White.copy(alpha = 0.35f),
|
||||
shape = RoundedCornerShape(16.dp)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
|
||||
|
||||
TextField(
|
||||
value = username,
|
||||
onValueChange = { username = it},
|
||||
label = {
|
||||
Text(
|
||||
usernameError.ifEmpty { "username" },
|
||||
color = if (usernameError.isNotEmpty()) Red else Color.Unspecified
|
||||
)
|
||||
},
|
||||
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
Icons.Rounded.AccountCircle,
|
||||
contentDescription = ""
|
||||
)
|
||||
},
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp, horizontal = 16.dp),
|
||||
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedContainerColor = Transparent,
|
||||
unfocusedIndicatorColor = Transparent
|
||||
)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
|
||||
|
||||
TextField(
|
||||
value = password,
|
||||
onValueChange = { password = it },
|
||||
label = {
|
||||
Text(
|
||||
passwordError.ifEmpty { "Password" },
|
||||
color = if (passwordError.isNotEmpty()) Red else Color.Unspecified
|
||||
)
|
||||
},
|
||||
leadingIcon = {
|
||||
Icon(Icons.Rounded.Lock, contentDescription = "")
|
||||
},
|
||||
|
||||
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
|
||||
|
||||
trailingIcon = {
|
||||
val image = if (passwordVisible) {
|
||||
Icons.Rounded.Visibility
|
||||
} else {
|
||||
Icons.Rounded.VisibilityOff
|
||||
}
|
||||
|
||||
IconButton(onClick = { passwordVisible = !passwordVisible }) {
|
||||
Icon(imageVector = image, contentDescription = if (passwordVisible) "Hide password" else "Show password")
|
||||
}
|
||||
},
|
||||
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp, horizontal = 16.dp),
|
||||
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedContainerColor = Transparent,
|
||||
unfocusedIndicatorColor = Transparent
|
||||
)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
|
||||
|
||||
when (uiState) {
|
||||
is LoginUiState.Error -> {
|
||||
Text(text = (uiState as LoginUiState.Error).message, color = Color.Red)
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
|
||||
|
||||
if (isLoading) {
|
||||
CircularProgressIndicator(color = Color.White)
|
||||
} else {
|
||||
Button(
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
onClick = {
|
||||
usernameError = if(username.isBlank()) "Email is required" else ""
|
||||
passwordError = if(password.isBlank()) "password is required" else ""
|
||||
if (usernameError.isEmpty() && passwordError.isEmpty()){
|
||||
viewModel.login(username, password)
|
||||
}
|
||||
},
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = main_orange, // 背景顏色
|
||||
contentColor = Color.White // 文字顏色
|
||||
)
|
||||
|
||||
) {
|
||||
Text( text = "Add")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Text( text = "forget password?",
|
||||
color = light_orange,
|
||||
modifier = Modifier
|
||||
.clickable{}
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
@ -1,199 +0,0 @@
|
|||
package com.example.login_v3.auth.SwitchAccount
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.foundation.lazy.items // 確保匯入這個
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.ExitToApp
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import coil.compose.AsyncImage
|
||||
import com.example.login_v3.R
|
||||
import com.example.login_v3.auth.login.AuthViewModel
|
||||
import com.example.login_v3.data.api.api_class.UserProfile
|
||||
import com.example.login_v3.data.api.api_class.fullAvatarUrl
|
||||
import com.example.login_v3.home.setting.setting_detail_page.viewmodel.PersonalProfileViewModel
|
||||
import com.example.login_v3.home.setting.setting_detail_page.viewmodel.ProfileUiState
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AccountSwitchScreen(
|
||||
viewModel: AuthViewModel = hiltViewModel(),
|
||||
profileViewModel: PersonalProfileViewModel = hiltViewModel(),
|
||||
onAddAccountClick: () -> Unit,
|
||||
onBack: () -> Unit
|
||||
) {
|
||||
// 觀察 ViewModel 中的狀態
|
||||
val allUsers by viewModel.allUserIds.collectAsState()
|
||||
val currentUserId by viewModel.currentUserId.collectAsState()
|
||||
val uiState by profileViewModel.uiState.collectAsState()
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("切換帳號") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.Default.ArrowBack, contentDescription = null)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "已登入的帳號",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// 帳號清單
|
||||
LazyColumn(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
// 這裡加上明確的 items(items = ...)
|
||||
items(items = allUsers.toList()) { userId ->
|
||||
val isCurrent = (userId == currentUserId) // 這裡解決 == 類型問題
|
||||
val currentProfile = (uiState as? ProfileUiState.Success)?.profile
|
||||
|
||||
AccountItem(
|
||||
profile = currentProfile,
|
||||
userId = userId,
|
||||
isCurrent = isCurrent,
|
||||
onSelect = { viewModel.switchAccount(userId) },
|
||||
onLogout = { viewModel.logout(userId) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// 新增帳號按鈕
|
||||
Button(
|
||||
onClick = onAddAccountClick,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
Icon(Icons.Default.Add, contentDescription = null)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("登入另一個帳號")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AccountItem(
|
||||
profile: UserProfile?,
|
||||
userId: String,
|
||||
isCurrent: Boolean,
|
||||
onSelect: () -> Unit,
|
||||
onLogout: () -> Unit
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onSelect() },
|
||||
border = if (isCurrent) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null,
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = if (isCurrent)
|
||||
MaterialTheme.colorScheme.primaryContainer
|
||||
else
|
||||
MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(16.dp)
|
||||
.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
// 頭像
|
||||
AsyncImage(
|
||||
// 使用 ?. 確保 profile 不為空時才讀取屬性,若為空則傳回 null
|
||||
model = profile?.fullAvatarUrl,
|
||||
|
||||
// contentDescription 也要處理,可以用 ?: 提供預設字串
|
||||
contentDescription = "Avatar of ${profile?.display_name ?: "Unknown"}",
|
||||
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.clip(CircleShape),
|
||||
contentScale = ContentScale.Crop,
|
||||
|
||||
// 當 model 為 null 或載入失敗時,會顯示這個預設圖
|
||||
error = painterResource(R.drawable.avatar_v1),
|
||||
placeholder = painterResource(R.drawable.avatar_v1)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
|
||||
Column {
|
||||
Text(text = userId, style = MaterialTheme.typography.bodyLarge)
|
||||
if (isCurrent) {
|
||||
Text(
|
||||
text = "使用中",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 登出小按鈕
|
||||
IconButton(onClick = onLogout) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.ExitToApp,
|
||||
contentDescription = "登出",
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,310 +0,0 @@
|
|||
package com.example.login_v3.auth.login
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.AccountCircle
|
||||
import androidx.compose.material.icons.rounded.Lock
|
||||
import androidx.compose.material.icons.rounded.Visibility
|
||||
import androidx.compose.material.icons.rounded.VisibilityOff
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.BlurredEdgeTreatment
|
||||
import androidx.compose.ui.draw.blur
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Color.Companion.Red
|
||||
import androidx.compose.ui.graphics.Color.Companion.Transparent
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import coil.ImageLoader
|
||||
import coil.compose.AsyncImage
|
||||
import coil.decode.SvgDecoder
|
||||
import coil.request.ImageRequest
|
||||
import com.example.login_v3.R
|
||||
import com.example.login_v3.ui.theme.light_orange
|
||||
import com.example.login_v3.ui.theme.main_orange
|
||||
import kotlin.text.ifEmpty
|
||||
|
||||
|
||||
@Composable
|
||||
fun LoginScreen(
|
||||
paddingValues: PaddingValues,
|
||||
onLoginSuccess: () -> Unit = {},
|
||||
viewModel: AuthViewModel = hiltViewModel()
|
||||
){
|
||||
|
||||
val uiState by viewModel.loginState.collectAsStateWithLifecycle()
|
||||
val isLoading = uiState is LoginUiState.Loading
|
||||
var username by remember { mutableStateOf("") }
|
||||
var password by remember { mutableStateOf("") }
|
||||
var passwordVisible by remember { mutableStateOf(false) }
|
||||
|
||||
var usernameError by remember { mutableStateOf("") }
|
||||
var passwordError by remember { mutableStateOf("") }
|
||||
|
||||
LaunchedEffect(uiState) {
|
||||
if (uiState is LoginUiState.Success) {
|
||||
onLoginSuccess()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
//background
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
brush = Brush.linearGradient(
|
||||
colors = listOf(
|
||||
Color(0xFFDA7029),
|
||||
Color(0xFF777777),
|
||||
Color(0xFFB34800)
|
||||
)
|
||||
)
|
||||
)
|
||||
.padding(paddingValues),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
|
||||
|
||||
|
||||
//Motion_logo
|
||||
SvgImage(
|
||||
resId = R.raw.tg_chat_v3,
|
||||
modifier = Modifier.size(180.dp)
|
||||
)
|
||||
|
||||
|
||||
//title
|
||||
Text(text = "Login",
|
||||
fontSize = 32.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color.White
|
||||
)
|
||||
|
||||
//login Box
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.height(340.dp)
|
||||
.fillMaxWidth()
|
||||
.padding(start = 24.dp, end = 24.dp, top = 20.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
){
|
||||
|
||||
|
||||
//glass effect
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.matchParentSize()
|
||||
.clip(RoundedCornerShape(16.dp)) // 🔥 一定要有
|
||||
.blur(
|
||||
radius = 10.dp,
|
||||
edgeTreatment = BlurredEdgeTreatment.Unbounded
|
||||
)
|
||||
.background(
|
||||
Color.White.copy(alpha = 0.18f)
|
||||
)
|
||||
.border( // ⭐ 玻璃邊框
|
||||
width = 1.dp,
|
||||
color = Color.White.copy(alpha = 0.35f),
|
||||
shape = RoundedCornerShape(16.dp)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
|
||||
|
||||
TextField(
|
||||
value = username,
|
||||
onValueChange = { username = it},
|
||||
label = {
|
||||
Text(
|
||||
usernameError.ifEmpty { "username" },
|
||||
color = if (usernameError.isNotEmpty()) Red else Color.Unspecified
|
||||
)
|
||||
},
|
||||
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
Icons.Rounded.AccountCircle,
|
||||
contentDescription = ""
|
||||
)
|
||||
},
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp, horizontal = 16.dp),
|
||||
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedContainerColor = Transparent,
|
||||
unfocusedIndicatorColor = Transparent
|
||||
)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
|
||||
|
||||
TextField(
|
||||
value = password,
|
||||
onValueChange = { password = it },
|
||||
label = {
|
||||
Text(
|
||||
passwordError.ifEmpty { "Password" },
|
||||
color = if (passwordError.isNotEmpty()) Red else Color.Unspecified
|
||||
)
|
||||
},
|
||||
leadingIcon = {
|
||||
Icon(Icons.Rounded.Lock, contentDescription = "")
|
||||
},
|
||||
|
||||
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
|
||||
|
||||
trailingIcon = {
|
||||
val image = if (passwordVisible) {
|
||||
Icons.Rounded.Visibility
|
||||
} else {
|
||||
Icons.Rounded.VisibilityOff
|
||||
}
|
||||
|
||||
IconButton(onClick = { passwordVisible = !passwordVisible }) {
|
||||
Icon(imageVector = image, contentDescription = if (passwordVisible) "Hide password" else "Show password")
|
||||
}
|
||||
},
|
||||
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp, horizontal = 16.dp),
|
||||
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedContainerColor = Transparent,
|
||||
unfocusedIndicatorColor = Transparent
|
||||
)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
|
||||
|
||||
when (uiState) {
|
||||
is LoginUiState.Success -> {
|
||||
val user = (uiState as LoginUiState.Success).user
|
||||
Text("歡迎,${user.display_name}")
|
||||
}
|
||||
is LoginUiState.Error -> {
|
||||
Text(text = (uiState as LoginUiState.Error).message, color = Color.Red)
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
|
||||
|
||||
if (isLoading) {
|
||||
CircularProgressIndicator(color = Color.White)
|
||||
} else {
|
||||
Button(
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
onClick = {
|
||||
usernameError = if(username.isBlank()) "Email is required" else ""
|
||||
passwordError = if(password.isBlank()) "password is required" else ""
|
||||
if (usernameError.isEmpty() && passwordError.isEmpty()){
|
||||
viewModel.login(username, password)
|
||||
}
|
||||
},
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = main_orange, // 背景顏色
|
||||
contentColor = Color.White // 文字顏色
|
||||
)
|
||||
|
||||
) {
|
||||
Text( text = "Login")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Text( text = "forget password?",
|
||||
color = light_orange,
|
||||
modifier = Modifier
|
||||
.clickable{}
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
fun SvgImage(
|
||||
resId: Int,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val imageLoader = ImageLoader.Builder(context)
|
||||
.components {
|
||||
add(SvgDecoder.Factory())
|
||||
}
|
||||
.build()
|
||||
|
||||
val imageRequest = ImageRequest.Builder(context)
|
||||
.data("android.resource://${context.packageName}/$resId")
|
||||
.decoderFactory(SvgDecoder.Factory())
|
||||
.build()
|
||||
|
||||
AsyncImage(
|
||||
model = imageRequest,
|
||||
contentDescription = null,
|
||||
modifier = modifier,
|
||||
imageLoader = imageLoader
|
||||
)
|
||||
}
|
||||
|
|
@ -1,94 +0,0 @@
|
|||
package com.example.login_v3.auth.login
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.example.login_v3.data.api.api_class.LoginRequest
|
||||
import com.example.login_v3.data.api.api_class.UserInfo
|
||||
import com.example.login_v3.data.repository.basic.AuthRepository
|
||||
import com.example.login_v3.data.repository.basic.TokenManager
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
sealed class LoginUiState {
|
||||
object Idle : LoginUiState()
|
||||
object Loading : LoginUiState()
|
||||
data class Success(val user: UserInfo) : LoginUiState()
|
||||
data class Error(val message: String) : LoginUiState()
|
||||
}
|
||||
|
||||
@HiltViewModel
|
||||
class AuthViewModel @Inject constructor(
|
||||
private val repository: AuthRepository,
|
||||
private val tokenManager: TokenManager
|
||||
) : ViewModel() {
|
||||
|
||||
private val _loginState = MutableStateFlow<LoginUiState>(LoginUiState.Idle)
|
||||
val loginState: StateFlow<LoginUiState> = _loginState.asStateFlow()
|
||||
|
||||
// --- 新增:供 UI 顯示所有已登入帳號與當前帳號 ---
|
||||
val allUserIds: StateFlow<Set<String>> = tokenManager.allUserIds
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptySet())
|
||||
|
||||
val currentUserId: StateFlow<String?> = tokenManager.currentUserId
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null)
|
||||
// ------------------------------------------
|
||||
|
||||
fun login(username: String, password: String) {
|
||||
viewModelScope.launch {
|
||||
_loginState.value = LoginUiState.Loading
|
||||
try {
|
||||
val request = LoginRequest(username = username, password = password)
|
||||
val response = repository.login(request)
|
||||
|
||||
if (response.isSuccessful && response.body() != null) {
|
||||
val loginResponse = response.body()!!
|
||||
|
||||
// 💡 1. 提取 user 物件並用 val 鎖定,讓 Kotlin 可以進行 Smart Cast
|
||||
val userInfo = loginResponse.user
|
||||
|
||||
if (userInfo != null) {
|
||||
// 這裡的 userInfo 已經被 Kotlin 自動辨別為「絕對非空」的 UserInfo 了
|
||||
val userId = userInfo.id
|
||||
|
||||
tokenManager.saveAuthData(
|
||||
userId = userId,
|
||||
accessToken = loginResponse.access_token,
|
||||
refreshToken = loginResponse.refresh_token,
|
||||
expiresInSec = loginResponse.expires_in
|
||||
)
|
||||
|
||||
_loginState.value = LoginUiState.Success(userInfo)
|
||||
} else {
|
||||
// 💡 2. 防禦後端雖然回傳 200,但 user 物件卻是 null 的極端狀況
|
||||
_loginState.value = LoginUiState.Error("登入失敗:伺服器未回傳用戶資訊")
|
||||
}
|
||||
} else {
|
||||
_loginState.value = LoginUiState.Error("登入失敗:${response.code()}")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_loginState.value = LoginUiState.Error("網路異常:${e.localizedMessage}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- 新增:切換帳號功能 ---
|
||||
fun switchAccount(userId: String) {
|
||||
viewModelScope.launch {
|
||||
tokenManager.switchAccount(userId)
|
||||
// 切換後,通常會需要重新初始化某些頁面資料,或發送一個事件通知 UI
|
||||
}
|
||||
}
|
||||
|
||||
// --- 新增:登出功能 ---
|
||||
fun logout(userId: String) {
|
||||
viewModelScope.launch {
|
||||
tokenManager.logout(userId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,316 +0,0 @@
|
|||
package com.example.login_v3.auth.reg
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.AccountCircle
|
||||
import androidx.compose.material.icons.rounded.Lock
|
||||
import androidx.compose.material.icons.rounded.Person
|
||||
import androidx.compose.material.icons.rounded.Visibility
|
||||
import androidx.compose.material.icons.rounded.VisibilityOff
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.BlurredEdgeTreatment
|
||||
import androidx.compose.ui.draw.blur
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Color.Companion.Red
|
||||
import androidx.compose.ui.graphics.Color.Companion.Transparent
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.example.login_v3.ui.theme.main_orange
|
||||
import kotlin.text.ifEmpty
|
||||
|
||||
@Composable
|
||||
fun Register_Screen(
|
||||
paddingValues: PaddingValues,
|
||||
viewModel: RegisterViewModel = hiltViewModel(),
|
||||
onRegisterSuccess: () -> Unit
|
||||
){
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
|
||||
// 這裡定義 TextField 的輸入狀態
|
||||
var displayName by remember { mutableStateOf("") }
|
||||
var email by remember { mutableStateOf("") }
|
||||
var username by remember { mutableStateOf("") }
|
||||
var password by remember { mutableStateOf("") }
|
||||
var passwordVisible by remember { mutableStateOf(false) }
|
||||
|
||||
var display_name_Error by remember { mutableStateOf("") }
|
||||
var user_name_Error by remember { mutableStateOf("") }
|
||||
var emailError by remember { mutableStateOf("") }
|
||||
var passwordError by remember { mutableStateOf("") }
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
brush = Brush.linearGradient(
|
||||
colors = listOf(
|
||||
Color(0xFFDA7029),
|
||||
Color(0xFF777777),
|
||||
Color(0xFFB34800)
|
||||
)
|
||||
)
|
||||
)
|
||||
.padding(paddingValues),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
|
||||
|
||||
Text(text = "Register",
|
||||
fontSize = 32.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color.White
|
||||
)
|
||||
|
||||
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
|
||||
//Reg_box
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.height(460.dp)
|
||||
.fillMaxWidth()
|
||||
.padding(start = 34.dp, end = 34.dp, top = 20.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
){
|
||||
|
||||
//glass effect
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.matchParentSize()
|
||||
.clip(RoundedCornerShape(16.dp)) // 🔥 一定要有
|
||||
.blur(
|
||||
radius = 10.dp,
|
||||
edgeTreatment = BlurredEdgeTreatment.Unbounded
|
||||
)
|
||||
.background(
|
||||
Color.White.copy(alpha = 0.18f)
|
||||
)
|
||||
.border( // ⭐ 玻璃邊框
|
||||
width = 1.dp,
|
||||
color = Color.White.copy(alpha = 0.35f),
|
||||
shape = RoundedCornerShape(16.dp)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
|
||||
//input_display name
|
||||
TextField(
|
||||
value = displayName,
|
||||
onValueChange = { displayName = it},
|
||||
label = {
|
||||
Text(
|
||||
display_name_Error.ifEmpty { "displayName" },
|
||||
color = if (display_name_Error.isNotEmpty()) Red else Color.Unspecified
|
||||
)
|
||||
},
|
||||
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
Icons.Rounded.Person,
|
||||
contentDescription = ""
|
||||
)
|
||||
},
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp, horizontal = 16.dp),
|
||||
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedContainerColor = Transparent,
|
||||
unfocusedIndicatorColor = Transparent
|
||||
)
|
||||
)
|
||||
|
||||
//input_username
|
||||
TextField(
|
||||
value = username,
|
||||
onValueChange = { username = it},
|
||||
label = {
|
||||
Text(
|
||||
user_name_Error.ifEmpty { "username" },
|
||||
color = if (user_name_Error.isNotEmpty()) Red else Color.Unspecified
|
||||
)
|
||||
},
|
||||
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
Icons.Rounded.Person,
|
||||
contentDescription = ""
|
||||
)
|
||||
},
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp, horizontal = 16.dp),
|
||||
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedContainerColor = Transparent,
|
||||
unfocusedIndicatorColor = Transparent
|
||||
)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
|
||||
//input_email
|
||||
TextField(
|
||||
value = email,
|
||||
onValueChange = { email = it},
|
||||
label = {
|
||||
Text(
|
||||
emailError.ifEmpty { "Email" },
|
||||
color = if (emailError.isNotEmpty()) Red else Color.Unspecified
|
||||
)
|
||||
},
|
||||
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
Icons.Rounded.AccountCircle,
|
||||
contentDescription = ""
|
||||
)
|
||||
},
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp, horizontal = 16.dp),
|
||||
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedContainerColor = Transparent,
|
||||
unfocusedIndicatorColor = Transparent
|
||||
)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
|
||||
//input_password
|
||||
TextField(
|
||||
value = password,
|
||||
onValueChange = { password = it },
|
||||
label = {
|
||||
Text(
|
||||
passwordError.ifEmpty { "Password" },
|
||||
color = if (passwordError.isNotEmpty()) Red else Color.Unspecified
|
||||
)
|
||||
},
|
||||
leadingIcon = {
|
||||
Icon(Icons.Rounded.Lock, contentDescription = "")
|
||||
},
|
||||
|
||||
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
|
||||
|
||||
trailingIcon = {
|
||||
val image = if (passwordVisible) {
|
||||
Icons.Rounded.Visibility
|
||||
} else {
|
||||
Icons.Rounded.VisibilityOff
|
||||
}
|
||||
|
||||
IconButton(onClick = { passwordVisible = !passwordVisible }) {
|
||||
Icon(imageVector = image, contentDescription = if (passwordVisible) "Hide password" else "Show password")
|
||||
}
|
||||
},
|
||||
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp, horizontal = 16.dp),
|
||||
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedContainerColor = Transparent,
|
||||
unfocusedIndicatorColor = Transparent
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
|
||||
if (uiState.isLoading) {
|
||||
CircularProgressIndicator() // 顯示載入圈圈
|
||||
} else {
|
||||
//Reg_button
|
||||
Button(
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
onClick = {
|
||||
user_name_Error = if(username.isBlank()) "username is required" else ""
|
||||
emailError = if(email.isBlank()) "Email is required" else ""
|
||||
passwordError = if(password.isBlank()) "password is required" else ""
|
||||
display_name_Error = if(displayName.isBlank()) "Name is required" else ""
|
||||
//not empty do the auth
|
||||
if (user_name_Error.isEmpty() && emailError.isEmpty() && passwordError.isEmpty() && display_name_Error.isEmpty() ){
|
||||
viewModel.handleRegister(displayName, email, password, username)
|
||||
}
|
||||
},
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = main_orange, // 背景顏色
|
||||
contentColor = Color.White // 文字顏色
|
||||
)
|
||||
) {
|
||||
Text( text = "Register")
|
||||
}
|
||||
}
|
||||
|
||||
uiState.errorMessage?.let {
|
||||
Text(text = it, color = Color.Red, modifier = Modifier.padding(top = 8.dp))
|
||||
}
|
||||
|
||||
|
||||
//註冊成功後到login
|
||||
LaunchedEffect(uiState.isSuccess) {
|
||||
if (uiState.isSuccess) {
|
||||
onRegisterSuccess()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
//input_block_end
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
package com.example.login_v3.auth.reg
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.example.login_v3.data.api.api_class.RegisterRequest
|
||||
import com.example.login_v3.data.repository.basic.AuthRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
// 在 RegisterViewModel.kt 中修改
|
||||
data class RegisterUiState(
|
||||
val isLoading: Boolean = false,
|
||||
val isSuccess: Boolean = false,
|
||||
val errorMessage: String? = null
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class RegisterViewModel @Inject constructor(
|
||||
private val repository: AuthRepository
|
||||
) : ViewModel() {
|
||||
|
||||
// 使用 StateFlow 管理 UI 狀態
|
||||
private val _uiState = MutableStateFlow(RegisterUiState())
|
||||
val uiState: StateFlow<RegisterUiState> = _uiState.asStateFlow()
|
||||
|
||||
fun handleRegister(display_name: String, email: String, pass: String, user: String) {
|
||||
val request = RegisterRequest(display_name, email, pass, user)
|
||||
|
||||
viewModelScope.launch {
|
||||
_uiState.value = RegisterUiState(isLoading = true) // 開始載入
|
||||
try {
|
||||
val response = repository.register(request)
|
||||
if (response.isSuccessful) {
|
||||
_uiState.value = RegisterUiState(isSuccess = true)
|
||||
} else {
|
||||
val errorMsg = response.errorBody()?.string() ?: "註冊失敗"
|
||||
_uiState.value = RegisterUiState(errorMessage = errorMsg)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_uiState.value = RegisterUiState(errorMessage = "網路異常: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,104 +0,0 @@
|
|||
package com.example.login_v3.auth.reg
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
|
||||
@Composable
|
||||
fun RegisterScreen(
|
||||
viewModel: RegisterViewModel = hiltViewModel()
|
||||
) {
|
||||
// 監聽狀態
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
|
||||
// 這裡定義 TextField 的輸入狀態
|
||||
var displayName by remember { mutableStateOf("") }
|
||||
var email by remember { mutableStateOf("") }
|
||||
var username by remember { mutableStateOf("") }
|
||||
var password by remember { mutableStateOf("") }
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text(text = "創建新帳號", style = MaterialTheme.typography.headlineMedium)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = displayName,
|
||||
onValueChange = { displayName = it },
|
||||
label = { Text("顯示名稱") }
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = email,
|
||||
onValueChange = { email = it },
|
||||
label = { Text("電子信箱") }
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = username,
|
||||
onValueChange = { username = it },
|
||||
label = { Text("帳號") }
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = password,
|
||||
onValueChange = { password = it },
|
||||
label = { Text("密碼") },
|
||||
visualTransformation = PasswordVisualTransformation() // 密碼隱碼
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
if (uiState.isLoading) {
|
||||
CircularProgressIndicator() // 顯示載入圈圈
|
||||
} else {
|
||||
Button(
|
||||
onClick = { viewModel.handleRegister(displayName, email, password, username) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(color = Color.Gray)
|
||||
) {
|
||||
Text("註冊")
|
||||
}
|
||||
}
|
||||
|
||||
// 顯示錯誤訊息
|
||||
uiState.errorMessage?.let {
|
||||
Text(text = it, color = Color.Red, modifier = Modifier.padding(top = 8.dp))
|
||||
}
|
||||
|
||||
// 註冊成功跳轉邏輯
|
||||
LaunchedEffect(uiState.isSuccess) {
|
||||
if (uiState.isSuccess) {
|
||||
// 這裡執行導向登入頁面或是主頁面的動作
|
||||
println("導航至登入頁面")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,178 +0,0 @@
|
|||
package com.example.login_v3.data.api
|
||||
|
||||
import com.example.login_v3.data.api.api_class.*
|
||||
import com.squareup.moshi.JsonClass
|
||||
import okhttp3.MultipartBody
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.DELETE
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Header
|
||||
import retrofit2.http.Multipart
|
||||
import retrofit2.http.PATCH
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.PUT
|
||||
import retrofit2.http.Part
|
||||
import retrofit2.http.Path
|
||||
import retrofit2.http.Query
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class RefreshRequest(
|
||||
val refresh_token: String
|
||||
)
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class RefreshResponse(
|
||||
val access_token: String? = null,
|
||||
val expires_in: Int? = null,
|
||||
val refresh_token: String? = null,
|
||||
val token: String? = null // 💡 有些後端會直接吐 token 欄位
|
||||
)
|
||||
|
||||
interface TecnologiaApi {
|
||||
|
||||
//sync dm data
|
||||
@GET("api/sync")
|
||||
suspend fun sync(@Query("since") since: Long): Response<SyncResponse>
|
||||
|
||||
@GET("api/health")
|
||||
suspend fun checkHealth(): Response<Unit>
|
||||
|
||||
// 註冊:建議路徑 api/auth/register
|
||||
@POST("api/auth/register")
|
||||
suspend fun register(@Body body: RegisterRequest): Response<RegisterResponse>
|
||||
|
||||
// 登入:將回傳值包裹在 Response 中
|
||||
@POST("api/auth/login")
|
||||
suspend fun login(@Body body: LoginRequest): Response<LoginResponse>
|
||||
|
||||
// 注意:如果是用 retrofit2.Response 包裹,請確保對應的 DTO 欄位都有容錯
|
||||
@POST("api/auth/refresh") // 舉例你的路徑
|
||||
suspend fun refreshToken(@Body request: RefreshRequest): Response<RefreshResponse>
|
||||
|
||||
|
||||
// 取得個人資料:通常需要 Token
|
||||
@GET("api/me")
|
||||
suspend fun getUserProfile(): Response<UserProfile>
|
||||
|
||||
@PATCH("api/me")
|
||||
suspend fun updateProfile(
|
||||
@Body request: UserProfileUpdateRequest
|
||||
): Response<Unit>
|
||||
|
||||
@Multipart
|
||||
@POST("api/me/avatar")
|
||||
suspend fun uploadAvatar(
|
||||
@Part file: MultipartBody.Part
|
||||
): Response<Unit>
|
||||
|
||||
@Multipart
|
||||
@POST("api/me/banner")
|
||||
suspend fun uploadBanner(
|
||||
@Part file: MultipartBody.Part
|
||||
): Response<Unit>
|
||||
|
||||
|
||||
|
||||
// friends
|
||||
@GET("api/friends")
|
||||
suspend fun getFriends(): FriendListResponse
|
||||
@GET("api/friends/pending")
|
||||
suspend fun getFriendsPending(): PendingFriendsResponse
|
||||
|
||||
//accept and reject
|
||||
// accept and reject
|
||||
@POST("api/friends/{fromUserId}/accept")
|
||||
suspend fun acceptFriendRequest(
|
||||
@Path("fromUserId") fromUserId: String
|
||||
): Response<Unit>
|
||||
|
||||
@POST("api/friends/{fromUserId}/reject")
|
||||
suspend fun rejectFriendRequest(
|
||||
@Path("fromUserId") fromUserId: String
|
||||
): Response<Unit>
|
||||
|
||||
// add friends
|
||||
@POST("api/friends/request")
|
||||
suspend fun sendFriendRequest(
|
||||
@Body request: AddFriendRequest
|
||||
): Response<Unit>
|
||||
|
||||
// 搜尋使用者
|
||||
@GET("api/users/search")
|
||||
suspend fun searchUsers(
|
||||
@Query("q") query: String
|
||||
): Response<UserSearchResponse>
|
||||
|
||||
|
||||
// Get dm list
|
||||
@GET("api/dm/rooms")
|
||||
suspend fun getChatRooms(): Response<RoomListResponse>
|
||||
|
||||
// Get dm message (分頁加載)
|
||||
@GET("api/dm/{room_id}/messages")
|
||||
suspend fun getChatMessages(
|
||||
@Path("room_id") roomId: String,
|
||||
@Query("before_id") cursor: String? = null, // 🎯 修正為與後端文件一致的 before_id
|
||||
@Query("limit") limit: Int? = null
|
||||
): Response<MessageResponse>
|
||||
|
||||
// 標記聊天室已讀
|
||||
@POST("api/dm/{room_id}/read")
|
||||
suspend fun markAsRead(
|
||||
@Path("room_id") roomId: String
|
||||
): Response<Unit>
|
||||
|
||||
//send DM message
|
||||
@POST("api/dm/{room_id}/messages")
|
||||
suspend fun sendMessage(
|
||||
@Path("room_id") roomId: String,
|
||||
@Body request: SendMessageRequest
|
||||
): Response<Message>
|
||||
|
||||
//send image
|
||||
@Multipart
|
||||
@POST("api/dm/{room_id}/attachments")
|
||||
suspend fun uploadAttachment(
|
||||
@Path("room_id") roomId: String,
|
||||
@Part file: MultipartBody.Part
|
||||
): Response<Message>
|
||||
|
||||
//get emoji reaction
|
||||
@GET("api/dm/{room_id}/messages/{msg_id}/reactions/{emoji}")
|
||||
suspend fun getMessageReactionUsers(
|
||||
@Path("room_id") roomId: String,
|
||||
@Path("msg_id") msgId: String,
|
||||
@Path("emoji") emoji: String
|
||||
): Response<MessageReactionUsersResponse>
|
||||
|
||||
//add emoji reaction
|
||||
@PUT("api/dm/{room_id}/messages/{msg_id}/reactions/{emoji}")
|
||||
suspend fun addMessageReaction(
|
||||
@Path("room_id") roomId: String,
|
||||
@Path("msg_id") msgId: String,
|
||||
@Path("emoji") emoji: String
|
||||
): Response<Unit>
|
||||
|
||||
//delete emoji reaction
|
||||
@DELETE("api/dm/{room_id}/messages/{msg_id}/reactions/{emoji}")
|
||||
suspend fun deleteMessageReaction(
|
||||
@Path("room_id") roomId: String,
|
||||
@Path("msg_id") msgId: String,
|
||||
@Path("emoji") emoji: String
|
||||
): Response<Unit>
|
||||
|
||||
//delete message
|
||||
@DELETE("api/dm/{room_id}/messages/{msg_id}")
|
||||
suspend fun deleteMessage(
|
||||
@Path("room_id") roomId: String,
|
||||
@Path("msg_id") msgId: String
|
||||
): Response<Unit>
|
||||
|
||||
//edit message
|
||||
@PATCH("api/dm/{room_id}/messages/{msg_id}")
|
||||
suspend fun editMessage(
|
||||
@Path("room_id") roomId: String,
|
||||
@Path("msg_id") msgId: String,
|
||||
@Body request: EditMessageRequest
|
||||
): Response<EditMessageResponse>
|
||||
}
|
||||
|
|
@ -1,62 +0,0 @@
|
|||
package com.example.login_v3.data.api.api_class
|
||||
|
||||
import com.squareup.moshi.Json
|
||||
import com.squareup.moshi.JsonClass
|
||||
|
||||
|
||||
|
||||
//login data
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class LoginRequest(
|
||||
val username: String,
|
||||
val password: String
|
||||
)
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class LoginResponse(
|
||||
val access_token: String,
|
||||
val expires_in: Int,
|
||||
val refresh_token: String,
|
||||
val token: String? = null, // 💡 改為可空
|
||||
val user: UserInfo? = null // 💡 如果刷新或登入有時沒給,改為可空
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class UserInfo(
|
||||
val id: String,
|
||||
val username: String,
|
||||
val display_name: String,
|
||||
val email: String,
|
||||
val avatar_url: String? = null,
|
||||
val banner_url: String? = null,
|
||||
val bio: String? = null,
|
||||
val status: String? = "active", // 💡 防禦:給可空與預設值
|
||||
val is_verified: Boolean = false,
|
||||
val created_at: String? = "" // 💡 防禦:給可空與預設值
|
||||
)
|
||||
|
||||
//register data
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class RegisterRequest(
|
||||
val display_name: String,
|
||||
val email: String,
|
||||
val password: String,
|
||||
val username: String
|
||||
)
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class RegisterResponse(
|
||||
@field:Json(name = "id") val id: String,
|
||||
@field:Json(name = "username") val username: String,
|
||||
|
||||
// 💡 使用 field: 前綴消除警告,並保持 String? 避免解析崩潰
|
||||
@field:Json(name = "display_name") val displayName: String?,
|
||||
|
||||
@field:Json(name = "email") val email: String?,
|
||||
|
||||
@field:Json(name = "avatar_url") val avatarUrl: String? = null,
|
||||
@field:Json(name = "banner_url") val bannerUrl: String? = null,
|
||||
@field:Json(name = "bio") val bio: String? = null,
|
||||
|
||||
@field:Json(name = "is_verified") val isVerified: Boolean? = false,
|
||||
@field:Json(name = "status") val status: String? = "active",
|
||||
@field:Json(name = "created_at") val createdAt: String? = ""
|
||||
)
|
||||
|
|
@ -1,313 +0,0 @@
|
|||
package com.example.login_v3.data.api.api_class
|
||||
|
||||
import com.example.login_v3.R
|
||||
import com.example.login_v3.data.local.entities.MessageStatus
|
||||
import com.squareup.moshi.Json
|
||||
import com.squareup.moshi.JsonClass
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
|
||||
|
||||
|
||||
//Get DM list
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class RoomListResponse(
|
||||
@Json(name = "rooms") val rooms: List<ChatRoom>
|
||||
)
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class ChatRoom(
|
||||
@Json(name = "room_id") val roomId: String,
|
||||
@Json(name = "room_name") val roomName: String?,
|
||||
@Json(name = "room_type") val roomType: String? = null, // 🎯 修正這裡:改成 String? 並加上預設值,解決閃退問題!
|
||||
@Json(name = "room_icon_url") val roomIconUrl: String?,
|
||||
@Json(name = "is_muted") val isMuted: Boolean = false, // 💡 安全防護:若後端漏傳,預設為 false
|
||||
@Json(name = "is_pinned") val isPinned: Boolean = false, // 💡 安全防護:若後端漏傳,預設為 false
|
||||
@Json(name = "unread_count") val unreadCount: Int = 0, // 💡 安全防護:若後端漏傳,預設為 0
|
||||
@Json(name = "mention_count") val mentionCount: Int = 0, // 💡 安全防護:若後端漏傳,預設為 0
|
||||
@Json(name = "partner") val partner: Partner?,
|
||||
@Json(name = "last_message") val lastMessage: LastMessage?
|
||||
)
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class Partner(
|
||||
@Json(name = "id") val id: String,
|
||||
@Json(name = "username") val username: String,
|
||||
@Json(name = "display_name") val displayName: String,
|
||||
@Json(name = "avatar_url") val avatarUrl: String?,
|
||||
@Json(name = "status") val status: String
|
||||
)
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class LastMessage(
|
||||
@Json(name = "id") val id: String,
|
||||
@Json(name = "chat_room_id") val chatRoomId: String,
|
||||
@Json(name = "sender_id") val senderId: String,
|
||||
@Json(name = "content") val content: String,
|
||||
@Json(name = "type") val type: String,
|
||||
@Json(name = "created_at") val createdAt: String,
|
||||
@Json(name = "updated_at") val updatedAt: String?,
|
||||
@Json(name = "deleted_at") val deletedAt: String?,
|
||||
@Json(name = "edited_at") val editedAt: String?,
|
||||
@Json(name = "is_deleted") val isDeleted: Boolean,
|
||||
@Json(name = "is_edited") val isEdited: Boolean,
|
||||
@Json(name = "attachment") val attachment: Attachment?,
|
||||
@Json(name = "reactions") val reactions: List<Reaction>?,
|
||||
@Json(name = "reply_to_id") val replyToId: String?,
|
||||
@Json(name = "forwarded_from_id") val forwardedFromId: String?,
|
||||
@Json(name = "forwarded_from_room_id") val forwardedFromRoomId: String?
|
||||
)
|
||||
|
||||
val Partner.fullContactAvatarUrl: Any
|
||||
get() = if (avatarUrl.isNullOrBlank()) {
|
||||
R.drawable.avatar_v1 // 如果沒網址,直接回傳預設圖片資源
|
||||
} else if (avatarUrl.startsWith("http")) {
|
||||
avatarUrl // 已經是完整網址就直接用
|
||||
} else {
|
||||
"https://tg.technologia-tw.com$avatarUrl" // 否則補上 Base URL
|
||||
}
|
||||
|
||||
|
||||
|
||||
//Message DM
|
||||
// 抽到最外層,讓 ChatRoom、LastMessage、Message 共同使用
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class Attachment(
|
||||
@Json(name = "filename") val filename: String,
|
||||
@Json(name = "mime_type") val mimeType: String,
|
||||
@Json(name = "size") val size: Long,
|
||||
@Json(name = "blurhash") val blurhash: String? = null,
|
||||
@Json(name = "has_thumbnail") val hasThumbnail: Boolean = false,
|
||||
@Json(name = "width") val width: Int = 0,
|
||||
@Json(name = "height") val height: Int = 0,
|
||||
@Json(name = "thumbnail_url") val thumbnailUrl: String? = null,
|
||||
@Json(name = "thumbnail_ext") val thumbnailExt: String? = null
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class Reaction(
|
||||
@Json(name = "emoji") val emoji: String = "",
|
||||
@Json(name = "count") val count: Int = 0,
|
||||
@Json(name = "me_reacted") val meReacted: Boolean = false
|
||||
)
|
||||
|
||||
// --- Message DM 相關 ---
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class MessageResponse(
|
||||
@Json(name = "has_more") val hasMore: Boolean,
|
||||
@Json(name = "limit") val limit: Int,
|
||||
@Json(name = "messages") val messages: List<Message>,
|
||||
@Json(name = "next_cursor") val nextCursor: String? = null
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class Message(
|
||||
@Json(name = "id") val id: String,
|
||||
@Json(name = "chat_room_id") val chatRoomId: String,
|
||||
@Json(name = "sender_id") val senderId: String,
|
||||
@Json(name = "content") val content: String,
|
||||
@Json(name = "type") val type: String,
|
||||
@Json(name = "created_at") val createdAt: String,
|
||||
@Json(name = "edited_at") val editedAt: String? = null,
|
||||
@Json(name = "deleted_at") val deletedAt: String? = null,
|
||||
@Json(name = "is_edited") val isEdited: Boolean,
|
||||
@Json(name = "is_deleted") val isDeleted: Boolean,
|
||||
@Json(name = "reply_to_id") val replyToId: String? = null,
|
||||
@kotlin.jvm.Transient
|
||||
var repliedMessage: Message? = null,
|
||||
@Json(name = "forwarded_from_id") val forwardedFromId: String? = null,
|
||||
@Json(name = "forwarded_from_room_id") val forwardedFromRoomId: String? = null,
|
||||
@Json(name = "attachment") val attachment: Attachment? = null,
|
||||
@Json(name = "reactions") val reactions: List<Reaction>? = emptyList(),
|
||||
|
||||
// 🌟 核心新增:讓 UI 可以辨識訊息發送狀態!
|
||||
@kotlin.jvm.Transient // 👈 告訴 Moshi 這個欄位不需要解析 JSON,這是我們本地維護的
|
||||
val status: MessageStatus = MessageStatus.SUCCESS,
|
||||
|
||||
@kotlin.jvm.Transient
|
||||
var aspectRatio: Float? = null,
|
||||
|
||||
@kotlin.jvm.Transient
|
||||
var callLogInfo: CallLogInfo? = null,
|
||||
|
||||
@kotlin.jvm.Transient
|
||||
var isDownloaded: Boolean = false,
|
||||
|
||||
@kotlin.jvm.Transient
|
||||
var localPath: String? = null
|
||||
) {
|
||||
init {
|
||||
if (type == "system" || type.startsWith("call")) {
|
||||
callLogInfo = CallLogInfo.parseContent(content)
|
||||
}
|
||||
}
|
||||
|
||||
// --- 🚀 新增:全域媒體屬性,讓 UI 秒讀不塞車 ---
|
||||
|
||||
val isImage: Boolean get() = type == "image" || attachment?.mimeType?.startsWith("image/") == true ||
|
||||
content.substringBefore("?").lowercase().let { it.endsWith(".jpg") || it.endsWith(".jpeg") || it.endsWith(".png") || it.endsWith(".webp") || it.endsWith(".heic") || it.endsWith(".heif") || it.endsWith(".gif") }
|
||||
|
||||
val isGif: Boolean get() = attachment?.mimeType == "image/gif" || content.substringBefore("?").lowercase().endsWith(".gif")
|
||||
|
||||
val isVideo: Boolean get() = type == "video" || attachment?.mimeType?.startsWith("video/") == true ||
|
||||
content.substringBefore("?").lowercase().let { it.endsWith(".mp4") || it.endsWith(".mov") || it.endsWith(".mkv") || it.endsWith(".avi") || it.endsWith(".3gp") || it.endsWith(".webm") }
|
||||
|
||||
val isAudio: Boolean get() = type == "audio" || attachment?.mimeType?.startsWith("audio/") == true ||
|
||||
content.substringBefore("?").lowercase().let { it.endsWith(".mp3") || it.endsWith(".wav") || it.endsWith(".m4a") || it.endsWith(".aac") || it.endsWith(".ogg") || it.endsWith(".opus") || it.endsWith(".amr") }
|
||||
|
||||
val isFile: Boolean get() = attachment?.mimeType?.startsWith("application/") == true || attachment?.mimeType == "text/plain" ||
|
||||
content.substringBefore("?").lowercase().let { it.endsWith(".zip") || it.endsWith(".rar") || it.endsWith(".7z") || it.endsWith(".pdf") || it.endsWith(".doc") || it.endsWith(".docx") || it.endsWith(".xls") || it.endsWith(".xlsx") || it.endsWith(".ppt") || it.endsWith(".pptx") || it.endsWith(".txt") }
|
||||
|
||||
val mediaUrl: String get() {
|
||||
// 🎯 核心修正:媒體檔案必須優先使用 content,因為 content 包含正確的 Hash 路徑與專屬簽名
|
||||
// attachment.filename 通常只是顯示用的原始檔名 (例如 IMG_123.jpg)
|
||||
val rawPath = if (isImage || isVideo || isAudio) content else (attachment?.filename ?: content)
|
||||
val baseUrl = "https://tg.technologia-tw.com"
|
||||
|
||||
// 🎯 Step 1: 基礎 URL 處理
|
||||
var finalUrl = when {
|
||||
rawPath.isBlank() -> ""
|
||||
rawPath.startsWith("http") -> rawPath
|
||||
rawPath.startsWith("/uploads/") -> "$baseUrl$rawPath"
|
||||
rawPath.startsWith("uploads/") -> "$baseUrl/$rawPath"
|
||||
else -> {
|
||||
val cleanPath = rawPath.removePrefix("/").removePrefix("uploads/")
|
||||
if (cleanPath.contains("/")) "$baseUrl/uploads/$cleanPath"
|
||||
else "$baseUrl/uploads/attachment/$cleanPath"
|
||||
}
|
||||
}
|
||||
|
||||
// 🎯 Step 2: 簽名繼承 (如果 content 本身沒簽名,才去借縮圖的)
|
||||
if (!finalUrl.contains("?")) {
|
||||
attachment?.thumbnailUrl?.let { thumb ->
|
||||
if (thumb.contains("?")) {
|
||||
val signature = thumb.substringAfter("?", "")
|
||||
if (signature.isNotEmpty()) {
|
||||
finalUrl += "?$signature"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return finalUrl
|
||||
}
|
||||
}
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class WebSocketEventResponse(
|
||||
@Json(name = "type") val type: String,
|
||||
@Json(name = "payload") val payload: WebSocketMessagePayload? // 🎯 直接強型別對齊
|
||||
)
|
||||
|
||||
// 這是專門給 Socket payload 用的臨時結構,用來解決 room_id 和 chat_room_id 欄位不一致的問題
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class WebSocketMessagePayload(
|
||||
@Json(name = "id") val id: String,
|
||||
@Json(name = "room_id") val roomId: String, // 🎯 完美吃下後端的 room_id
|
||||
@Json(name = "sender_id") val senderId: String,
|
||||
@Json(name = "content") val content: String,
|
||||
@Json(name = "type") val type: String,
|
||||
@Json(name = "created_at") val createdAt: String,
|
||||
@Json(name = "is_edited") val isEdited: Boolean,
|
||||
@Json(name = "is_deleted") val isDeleted: Boolean,
|
||||
@Json(name = "reply_to_id") val replyToId: String? = null,
|
||||
@Json(name = "reactions") val reactions: List<Reaction>? = emptyList(),
|
||||
@Json(name = "attachment") val attachment: Attachment? = null
|
||||
)
|
||||
|
||||
//send message
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class SendMessageRequest(
|
||||
@Json(name = "content") val content: String,
|
||||
@Json(name = "reply_to_id") val replyToId: String? = null
|
||||
)
|
||||
|
||||
//message reaction
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class MessageReactionUsersResponse(
|
||||
@Json(name = "users") val users: List<String>
|
||||
)
|
||||
|
||||
//edit message
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class EditMessageRequest(
|
||||
@Json(name = "content") val content: String
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class EditMessageResponse(
|
||||
@Json(name = "ok") val ok: Boolean
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
// --- Delta-Sync 相關 ---
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class SyncResponse(
|
||||
@Json(name = "blocks") val blocks: List<SyncBlock>,
|
||||
@Json(name = "friends") val friends: List<SyncFriend>,
|
||||
@Json(name = "full_snapshot") val fullSnapshot: Boolean,
|
||||
@Json(name = "next_since") val nextSince: Long,
|
||||
@Json(name = "rooms") val rooms: List<SyncRoom>
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class SyncBlock(
|
||||
@Json(name = "avatar_url") val avatarUrl: String?,
|
||||
@Json(name = "blocked_at") val blockedAt: String,
|
||||
@Json(name = "display_name") val displayName: String,
|
||||
@Json(name = "removed") val removed: Boolean = false,
|
||||
@Json(name = "user_id") val userId: String,
|
||||
@Json(name = "username") val username: String
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class SyncFriend(
|
||||
@Json(name = "avatar_url") val avatarUrl: String?,
|
||||
@Json(name = "display_name") val displayName: String,
|
||||
@Json(name = "presence_status") val presenceStatus: String,
|
||||
@Json(name = "removed") val removed: Boolean = false,
|
||||
@Json(name = "status") val status: String,
|
||||
@Json(name = "user_id") val userId: String,
|
||||
@Json(name = "username") val username: String
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class SyncRoom(
|
||||
@Json(name = "is_hidden") val isHidden: Boolean,
|
||||
@Json(name = "is_muted") val isMuted: Boolean,
|
||||
@Json(name = "is_pinned") val isPinned: Boolean,
|
||||
@Json(name = "last_message") val lastMessage: LastMessage?,
|
||||
@Json(name = "mention_count") val mentionCount: Int,
|
||||
@Json(name = "nickname") val nickname: String?,
|
||||
@Json(name = "partner") val partner: Partner?,
|
||||
@Json(name = "removed") val removed: Boolean = false,
|
||||
@Json(name = "room_id") val roomId: String,
|
||||
@Json(name = "unread_count") val unreadCount: Int
|
||||
)
|
||||
|
||||
// --- Voice Call 相關 ---
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class CallLogInfo(
|
||||
@Json(name = "type") val type: String, // 例如: call_missed, call_rejected
|
||||
@Json(name = "initiator_id") val initiatorId: String, // 順便改成 Kotlin 的駝峰命名規範
|
||||
@Json(name = "has_video") val hasVideo: Boolean
|
||||
) {
|
||||
companion object {
|
||||
private val moshi = Moshi.Builder()
|
||||
.addLast(com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory())
|
||||
.build()
|
||||
|
||||
private val adapter = moshi.adapter(CallLogInfo::class.java)
|
||||
|
||||
// 🌟 改成只單純解析 String
|
||||
fun parseContent(content: String?): CallLogInfo? {
|
||||
val json = content?.trim() ?: return null
|
||||
if (!json.startsWith("{") || !json.endsWith("}")) return null
|
||||
|
||||
return try {
|
||||
adapter.fromJson(json)
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("CallLogInfo", "解析通話 JSON 失敗: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,124 +0,0 @@
|
|||
package com.example.login_v3.data.api.api_class
|
||||
|
||||
import com.example.login_v3.R
|
||||
import com.squareup.moshi.Json
|
||||
import com.squareup.moshi.JsonClass
|
||||
|
||||
|
||||
//get friends list
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class FriendListResponse(
|
||||
// 這樣寫最安全:即便 JSON 裡少了這個欄位,也會變成 emptyList
|
||||
val friends: List<Friend> = emptyList()
|
||||
)
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class Friend(
|
||||
@field:Json(name = "friend_id")
|
||||
val friendId: String?, // 改為 String?
|
||||
|
||||
val username: String?, // 建議 username 也改可空
|
||||
|
||||
@Json(name = "display_name")
|
||||
val displayName: String?,
|
||||
|
||||
@Json(name = "avatar_url")
|
||||
val avatarUrl: String?,
|
||||
|
||||
val status: String?,
|
||||
|
||||
@field:Json(name = "accepted_at")
|
||||
val acceptedAt: String?
|
||||
)
|
||||
val Friend.fullFriendsAvatarUrl: Any
|
||||
get() {
|
||||
val url = avatarUrl ?: ""
|
||||
return when {
|
||||
url.isBlank() -> R.drawable.avatar_v1
|
||||
url.startsWith("http") -> url
|
||||
else -> "https://tg.technologia-tw.com$url"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
//friends pending request
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class PendingFriendsResponse(
|
||||
@Json(name = "pending") // 移除 field:
|
||||
val pendingRequests: List<PendingFriendApiModel>
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class PendingFriendApiModel(
|
||||
@Json(name = "friendship_id")
|
||||
val friendshipId: String?,
|
||||
|
||||
val username: String?,
|
||||
|
||||
@Json(name = "display_name")
|
||||
val displayName: String?, // 建議改為可空,比較保險
|
||||
|
||||
@Json(name = "avatar_url")
|
||||
val pendingAvatarUrl: String?,
|
||||
|
||||
@Json(name = "from_user_id")
|
||||
val fromUserId: String?,
|
||||
|
||||
@Json(name = "created_at")
|
||||
val createdAt: String?
|
||||
)
|
||||
val PendingFriendApiModel.fullPendingAvatarUrl: Any
|
||||
get() = if (pendingAvatarUrl.isNullOrBlank()) {
|
||||
R.drawable.avatar_v1
|
||||
} else if (pendingAvatarUrl.startsWith("http")) {
|
||||
pendingAvatarUrl
|
||||
} else {
|
||||
"http://192.168.0.217$pendingAvatarUrl"
|
||||
}
|
||||
|
||||
|
||||
|
||||
//add friends
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class AddFriendRequest(
|
||||
@Json(name = "friend_id") val friendId: String
|
||||
)
|
||||
|
||||
//user search
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class UserSearchResponse(
|
||||
val count: Int,
|
||||
val users: List<UserDetail>
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true) // 內部類別也一定要加!
|
||||
data class UserDetail(
|
||||
val id: String,
|
||||
val username: String,
|
||||
|
||||
@Json(name = "display_name") // 這樣你的 Kotlin 代碼可以用漂亮的 displayName
|
||||
val displayName: String,
|
||||
|
||||
@Json(name = "avatar_url")
|
||||
val SearchAvatarUrl: String?,
|
||||
|
||||
@Json(name = "banner_url")
|
||||
val bannerUrl: String?,
|
||||
|
||||
val bio: String?,
|
||||
val status: String?,
|
||||
|
||||
@Json(name = "created_at")
|
||||
val createdAt: String,
|
||||
|
||||
val isRequestSent: Boolean = false
|
||||
)
|
||||
|
||||
val UserDetail.fullSearchedAvatarUrl: Any
|
||||
get() = if (SearchAvatarUrl.isNullOrBlank()) {
|
||||
R.drawable.avatar_v1
|
||||
} else if (SearchAvatarUrl.startsWith("http")) {
|
||||
SearchAvatarUrl
|
||||
} else {
|
||||
"http://192.168.0.217$SearchAvatarUrl"
|
||||
}
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
package com.example.login_v3.data.api.api_class
|
||||
|
||||
import com.example.login_v3.R
|
||||
import com.squareup.moshi.Json
|
||||
import com.squareup.moshi.JsonClass
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class UserProfile(
|
||||
val user_id: String?,
|
||||
val username: String?,
|
||||
val display_name: String?,
|
||||
val email: String?,
|
||||
val avatar_url: String?,
|
||||
val banner_url: String?,
|
||||
val bio: String?,
|
||||
val status: String?,
|
||||
val is_active: Boolean?,
|
||||
val is_verified: Boolean?,
|
||||
val last_login: String?,
|
||||
val created_at: String?
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class UserProfileUpdateRequest(
|
||||
val avatar_url: String? = null,
|
||||
val banner_url: String? = null,
|
||||
val bio: String? = null,
|
||||
val display_name: String? = null,
|
||||
val status: String? = null
|
||||
)
|
||||
|
||||
val UserProfile.fullAvatarUrl: Any
|
||||
get() = if (avatar_url.isNullOrBlank()) {
|
||||
R.drawable.avatar_v1 // 如果沒網址,直接回傳預設圖片資源
|
||||
} else if (avatar_url.startsWith("http")) {
|
||||
avatar_url // 已經是完整網址就直接用
|
||||
} else {
|
||||
"https://tg.technologia-tw.com$avatar_url" // 否則補上 Base URL
|
||||
}
|
||||
|
||||
val UserProfile.fullBannerUrl: Any
|
||||
get() = if (banner_url.isNullOrBlank()) {
|
||||
R.drawable.thumbnail_v1 // 如果沒網址,直接回傳預設圖片資源
|
||||
} else if (banner_url.startsWith("http")) {
|
||||
banner_url // 已經是完整網址就直接用
|
||||
} else {
|
||||
"https://tg.technologia-tw.com$banner_url" // 否則補上 Base URL
|
||||
}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
package com.example.login_v3.data.api.api_class
|
||||
|
||||
data class User(
|
||||
val id: String,
|
||||
val username: String,
|
||||
val email: String
|
||||
)
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
package com.example.login_v3.data.di
|
||||
|
||||
import androidx.room.Database
|
||||
import androidx.room.RoomDatabase
|
||||
import androidx.room.TypeConverters
|
||||
import com.example.login_v3.data.local.dao.BlockedUserDao
|
||||
import com.example.login_v3.data.local.dao.FriendDao
|
||||
import com.example.login_v3.data.local.dao.MessageDao
|
||||
import com.example.login_v3.data.local.dao.RoomLocalDao
|
||||
import com.example.login_v3.data.local.entities.BlockedUserLocalEntity
|
||||
import com.example.login_v3.data.local.entities.FriendLocalEntity
|
||||
import com.example.login_v3.data.local.entities.MessageEntity
|
||||
import com.example.login_v3.data.local.entities.RoomLocalEntity
|
||||
|
||||
@Database(
|
||||
entities = [
|
||||
MessageEntity::class,
|
||||
FriendLocalEntity::class,
|
||||
RoomLocalEntity::class,
|
||||
BlockedUserLocalEntity::class
|
||||
],
|
||||
version = 3,
|
||||
exportSchema = false
|
||||
)
|
||||
@TypeConverters(RoomConverters::class)
|
||||
abstract class AppDatabase : RoomDatabase() {
|
||||
abstract fun messageDao(): MessageDao
|
||||
abstract fun friendDao(): FriendDao
|
||||
abstract fun roomLocalDao(): RoomLocalDao
|
||||
abstract fun blockedUserDao(): BlockedUserDao
|
||||
}
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
package com.example.login_v3.data.di
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
// 修正 1: 確保匯入的是 androidx 的 Preferences
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import com.example.login_v3.data.local.UserPreferences.UserPreferencesRepository
|
||||
import com.example.login_v3.data.repository.basic.TokenManager
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
|
||||
// 定義擴充屬性,這裡保持為檔案私有 (private) 是沒問題的
|
||||
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "user_prefs")
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
// 修正 2: 移除 private,讓 Hilt 可以訪問這個模組
|
||||
object DataStoreModule {
|
||||
|
||||
|
||||
//data store instance
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideDataStore(@ApplicationContext context: Context): DataStore<Preferences> {
|
||||
return context.dataStore
|
||||
}
|
||||
|
||||
//UserPreferencesRepository
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideUserPreferencesRepository(dataStore: DataStore<Preferences>): UserPreferencesRepository {
|
||||
return UserPreferencesRepository(dataStore)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideTokenManager(dataStore: DataStore<Preferences>): TokenManager {
|
||||
return TokenManager(dataStore)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
package com.example.login_v3.data.di
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Room
|
||||
import com.example.login_v3.data.local.dao.BlockedUserDao
|
||||
import com.example.login_v3.data.local.dao.FriendDao
|
||||
import com.example.login_v3.data.local.dao.MessageDao
|
||||
import com.example.login_v3.data.local.dao.RoomLocalDao
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class) // 💡 讓整個 App 生命週期都能拿到
|
||||
object DatabaseModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideAppDatabase(
|
||||
@ApplicationContext context: Context // 💡 自動注入 ApplicationContext
|
||||
): AppDatabase {
|
||||
return Room.databaseBuilder(
|
||||
context,
|
||||
AppDatabase::class.java,
|
||||
"tecnologia_chat_db" // 資料庫檔案名稱
|
||||
)
|
||||
.fallbackToDestructiveMigration()
|
||||
.build()
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideMessageDao(database: AppDatabase): MessageDao {
|
||||
return database.messageDao()
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideFriendDao(database: AppDatabase): FriendDao {
|
||||
return database.friendDao()
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideRoomLocalDao(database: AppDatabase): RoomLocalDao {
|
||||
return database.roomLocalDao()
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideBlockedUserDao(database: AppDatabase): BlockedUserDao {
|
||||
return database.blockedUserDao()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,111 +0,0 @@
|
|||
package com.example.login_v3.data.di
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import com.example.login_v3.data.api.TecnologiaApi
|
||||
import com.example.login_v3.data.repository.basic.TokenAuthenticator
|
||||
import com.example.login_v3.data.repository.basic.TokenManager
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.OkHttpClient
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.moshi.MoshiConverterFactory
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object NetworkModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideMoshi(): Moshi {
|
||||
return Moshi.Builder()
|
||||
.addLast(KotlinJsonAdapterFactory())
|
||||
.build()
|
||||
}
|
||||
|
||||
// TokenInterceptor
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideTokenInterceptor(tokenManager: TokenManager): Interceptor {
|
||||
return Interceptor { chain ->
|
||||
val originalRequest = chain.request()
|
||||
val path = originalRequest.url.encodedPath
|
||||
|
||||
// 如果是認證相關的 API,直接放行,不附加 Token Header
|
||||
if (path.contains("api/auth/login") ||
|
||||
path.contains("api/auth/register") ||
|
||||
path.contains("api/auth/refresh") ||
|
||||
path.contains("api/health")) {
|
||||
return@Interceptor chain.proceed(originalRequest)
|
||||
}
|
||||
|
||||
// 在背景執行緒中安全地獲取當前 Token
|
||||
val token = runBlocking(Dispatchers.IO) {
|
||||
tokenManager.currentAccessToken.firstOrNull()
|
||||
}
|
||||
|
||||
android.util.Log.d("AuthDebug", "Interceptor - 路徑: $path, Token是否存在: ${!token.isNullOrEmpty()}")
|
||||
|
||||
val requestBuilder = originalRequest.newBuilder()
|
||||
if (!token.isNullOrEmpty()) {
|
||||
requestBuilder.addHeader("Authorization", "Bearer $token")
|
||||
}
|
||||
|
||||
// ⚠️ 注意:這裡不再手動處理 401 logout,純粹放行交給 Authenticator 處理
|
||||
chain.proceed(requestBuilder.build())
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 配置 OkHttpClient,把 Interceptor 和 Authenticator 綁進去
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideOkHttpClient(
|
||||
tokenInterceptor: Interceptor,
|
||||
tokenAuthenticator: TokenAuthenticator // 👈 注入前面實作好的 Authenticator
|
||||
): OkHttpClient {
|
||||
val loggingInterceptor = Interceptor { chain ->
|
||||
val request = chain.request()
|
||||
android.util.Log.d("ChatDebug", "🚀 [Network] Request: ${request.method} ${request.url}")
|
||||
val response = chain.proceed(request)
|
||||
android.util.Log.d("ChatDebug", "📦 [Network] Response: ${response.code} for ${request.url}")
|
||||
response
|
||||
}
|
||||
|
||||
return OkHttpClient.Builder()
|
||||
.connectTimeout(15, TimeUnit.SECONDS)
|
||||
.readTimeout(15, TimeUnit.SECONDS)
|
||||
.addInterceptor(loggingInterceptor) // 負責 Log 網路請求
|
||||
.addInterceptor(tokenInterceptor) // 負責幫一般請求加上 Token
|
||||
.authenticator(tokenAuthenticator) // 負責在 401 時自動刷新 Token
|
||||
.build()
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideRetrofit(moshi: Moshi, okHttpClient: OkHttpClient): Retrofit {
|
||||
return Retrofit.Builder()
|
||||
// 修正:更換為新的 HTTPS 正式網域
|
||||
.baseUrl("https://tg.technologia-tw.com/")
|
||||
.client(okHttpClient)
|
||||
.addConverterFactory(MoshiConverterFactory.create(moshi))
|
||||
.build()
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideTecnologiaApi(retrofit: Retrofit): TecnologiaApi {
|
||||
return retrofit.create(TecnologiaApi::class.java)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,82 +0,0 @@
|
|||
package com.example.login_v3.data.di
|
||||
|
||||
import androidx.room.TypeConverter
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.squareup.moshi.Types
|
||||
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory // 👈 確保有 import 這個
|
||||
import com.example.login_v3.data.api.api_class.LastMessage
|
||||
import com.example.login_v3.data.api.api_class.Partner
|
||||
import com.example.login_v3.data.api.api_class.Reaction
|
||||
import com.example.login_v3.data.api.api_class.Attachment
|
||||
|
||||
class RoomConverters {
|
||||
private val moshi = Moshi.Builder()
|
||||
.addLast(KotlinJsonAdapterFactory())
|
||||
.build()
|
||||
|
||||
private val reactionListType = Types.newParameterizedType(List::class.java, Reaction::class.java)
|
||||
private val reactionAdapter = moshi.adapter<List<Reaction>>(reactionListType)
|
||||
private val partnerAdapter = moshi.adapter(Partner::class.java)
|
||||
private val lastMessageAdapter = moshi.adapter(LastMessage::class.java)
|
||||
private val attachmentAdapter = moshi.adapter(Attachment::class.java)
|
||||
|
||||
@TypeConverter
|
||||
fun fromReactionList(reactions: List<Reaction>?): String? {
|
||||
return reactionAdapter.toJson(reactions ?: emptyList())
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
fun toReactionList(json: String?): List<Reaction>? {
|
||||
if (json.isNullOrBlank()) return emptyList()
|
||||
return try {
|
||||
reactionAdapter.fromJson(json)
|
||||
} catch (e: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
fun fromPartner(partner: Partner?): String? {
|
||||
return partnerAdapter.toJson(partner)
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
fun toPartner(json: String?): Partner? {
|
||||
if (json.isNullOrBlank()) return null
|
||||
return try {
|
||||
partnerAdapter.fromJson(json)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
fun fromLastMessage(lastMessage: LastMessage?): String? {
|
||||
return lastMessageAdapter.toJson(lastMessage)
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
fun toLastMessage(json: String?): LastMessage? {
|
||||
if (json.isNullOrBlank()) return null
|
||||
return try {
|
||||
lastMessageAdapter.fromJson(json)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
fun fromAttachment(attachment: Attachment?): String? {
|
||||
return attachmentAdapter.toJson(attachment)
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
fun toAttachment(json: String?): Attachment? {
|
||||
if (json.isNullOrBlank()) return null
|
||||
return try {
|
||||
attachmentAdapter.fromJson(json)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,90 +0,0 @@
|
|||
package com.example.login_v3.data.di
|
||||
|
||||
import android.util.Log
|
||||
import okhttp3.*
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
|
||||
@Singleton
|
||||
class ChatWebSocketManager @Inject constructor(
|
||||
private val client: OkHttpClient // 從 NetworkModule 注入
|
||||
) {
|
||||
private var webSocket: WebSocket? = null
|
||||
private var currentUrl: String? = null // 🎯 新增:用來記錄當前的連線網址
|
||||
|
||||
// 用 SharedFlow 把收到的字串推出去給 Repository 監聽
|
||||
private val _incomingMessages = MutableSharedFlow<String>(extraBufferCapacity = 64)
|
||||
val incomingMessages: SharedFlow<String> = _incomingMessages
|
||||
|
||||
fun connect(url: String) {
|
||||
// 🎯 核心修正:檢查是否已經有全域連線存在
|
||||
// 我們去掉網址後面的 ?token=... 參數,只比對前半段的 Base URL (wss://tg.technologia-tw.com/api/ws)
|
||||
val newBaseUrl = url.split("?").firstOrNull()
|
||||
val currentBaseUrl = currentUrl?.split("?")?.firstOrNull()
|
||||
|
||||
if (webSocket != null && newBaseUrl == currentBaseUrl) {
|
||||
Log.d("ChatWebSocketManager", "全域 WebSocket 已在連線中,跳過重複連線。")
|
||||
return
|
||||
}
|
||||
|
||||
// 如果連線完全不存在,或者域名變了,才需要建立新連線(安全起見先清理舊連線)
|
||||
if (webSocket != null) {
|
||||
disconnect()
|
||||
}
|
||||
|
||||
currentUrl = url
|
||||
|
||||
val request = Request.Builder().url(url).build()
|
||||
webSocket = client.newWebSocket(request, object : WebSocketListener() {
|
||||
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
Log.d("ChatWebSocketManager", "WebSocket 連線成功!")
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
// 收到伺服器即時訊息,丟進 Flow
|
||||
Log.d("ChatWebSocketManager", "收到 Socket 訊息: $text")
|
||||
_incomingMessages.tryEmit(text)
|
||||
}
|
||||
|
||||
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||
Log.d("ChatWebSocketManager", "WebSocket 已關閉: $reason")
|
||||
clearConnection()
|
||||
}
|
||||
|
||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
||||
Log.e("ChatWebSocketManager", "WebSocket 連線失敗: ${t.message}, Code: ${response?.code}")
|
||||
clearConnection()
|
||||
// 💡 這裡未來可以實作計時器自動重連 (例如 delay 5秒後重新呼叫 connect)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 進入房間時,通知後端我們要訂閱這個 Room 的即時訊息
|
||||
fun subscribeToRoom(roomId: String) {
|
||||
// 💡 提示:請根據你與後端約定的 JSON 格式去修改這一行。
|
||||
// 這裡假設後端接收的格式是 {"action":"subscribe","roomId":"xxxx"}
|
||||
val subscribeJson = "{\"action\":\"subscribe\",\"roomId\":\"$roomId\"}"
|
||||
|
||||
val isSent = webSocket?.send(subscribeJson) ?: false
|
||||
if (isSent) {
|
||||
Log.d("ChatWebSocketManager", "已成功發送訂閱房間訊息: $roomId")
|
||||
} else {
|
||||
Log.e("ChatWebSocketManager", "發送訂閱房間失敗,Socket 可能未連線")
|
||||
}
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
// 1000 代表正常關閉
|
||||
webSocket?.close(1000, "User exit")
|
||||
clearConnection()
|
||||
}
|
||||
|
||||
// 🎯 提取出來的清理專用方法
|
||||
private fun clearConnection() {
|
||||
webSocket = null
|
||||
currentUrl = null
|
||||
}
|
||||
}
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
package com.example.login_v3.data.health
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.example.login_v3.data.repository.basic.NetworkRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class HealthCheckViewModel @Inject constructor(
|
||||
private val repository: NetworkRepository
|
||||
) : ViewModel() {
|
||||
|
||||
// 使用 Compose 的 mutableState,這樣 UI 會自動根據這個值的改變而重繪
|
||||
var healthStatus by mutableStateOf("Ready to Check")
|
||||
private set
|
||||
|
||||
// 用來顯示更詳細的訊息(例如錯誤內容)
|
||||
var detailMessage by mutableStateOf("")
|
||||
private set
|
||||
|
||||
fun checkApiHealth() {
|
||||
viewModelScope.launch {
|
||||
healthStatus = "Checking..."
|
||||
detailMessage = "Connecting to server..."
|
||||
|
||||
val result = repository.performHealthCheck()
|
||||
|
||||
result.onSuccess {
|
||||
healthStatus = "Success"
|
||||
detailMessage = "✅ Connection established! (200 OK)"
|
||||
}.onFailure { exception ->
|
||||
healthStatus = "Failed"
|
||||
// 這裡會抓到連線逾時、404、或是找不到伺服器等錯誤
|
||||
detailMessage = "❌ Error: ${exception.localizedMessage}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,129 +0,0 @@
|
|||
package com.example.login_v3.data.local.UserPreferences
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences // 確保是這個 Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.emptyPreferences
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.map
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
|
||||
|
||||
class UserPreferencesRepository(private val dataStore: DataStore<Preferences>) {
|
||||
|
||||
private object PreferencesKeys {
|
||||
val THEME_MODE = stringPreferencesKey("theme_mode")
|
||||
// 新增:儲存自訂壁紙檔案路徑的 Key
|
||||
val CUSTOM_WALLPAPER_PATH = stringPreferencesKey("custom_wallpaper_path")
|
||||
val SYNC_SINCE = androidx.datastore.preferences.core.longPreferencesKey("sync_since")
|
||||
}
|
||||
|
||||
// 讀取同步時間
|
||||
val syncSinceFlow: Flow<Long> = dataStore.data
|
||||
.catch { exception ->
|
||||
if (exception is IOException) {
|
||||
emit(emptyPreferences())
|
||||
} else {
|
||||
throw exception
|
||||
}
|
||||
}
|
||||
.map { preferences ->
|
||||
preferences[PreferencesKeys.SYNC_SINCE] ?: 0L
|
||||
}
|
||||
|
||||
suspend fun saveSyncSince(since: Long) {
|
||||
dataStore.edit { preferences ->
|
||||
preferences[PreferencesKeys.SYNC_SINCE] = since
|
||||
}
|
||||
}
|
||||
|
||||
// 讀取設定:直接在宣告時賦值,簡潔又安全
|
||||
val themeModeFlow: Flow<String> = dataStore.data
|
||||
.catch { exception ->
|
||||
if (exception is IOException) {
|
||||
emit(emptyPreferences())
|
||||
} else {
|
||||
throw exception
|
||||
}
|
||||
}
|
||||
.map { preferences ->
|
||||
preferences[PreferencesKeys.THEME_MODE] ?: "system"
|
||||
}
|
||||
|
||||
// 2. 新增:讀取自訂壁紙路徑(預設為 null,代表使用系統預設背景)
|
||||
val wallpaperPathFlow: Flow<String?> = dataStore.data
|
||||
.catch { exception ->
|
||||
if (exception is IOException) {
|
||||
emit(emptyPreferences())
|
||||
} else {
|
||||
throw exception
|
||||
}
|
||||
}
|
||||
.map { preferences ->
|
||||
preferences[PreferencesKeys.CUSTOM_WALLPAPER_PATH]
|
||||
}
|
||||
|
||||
suspend fun saveThemeMode(mode: String) {
|
||||
dataStore.edit { preferences ->
|
||||
preferences[PreferencesKeys.THEME_MODE] = mode
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 4. 新增:將用戶選擇的圖片複製到內部存儲,並將路徑存入 DataStore
|
||||
* @param context 用於獲取 filesDir 與 contentResolver
|
||||
* @param uri 用戶從相簿選擇的圖片 Uri
|
||||
* @return 儲存成功返回 true,失敗返回 false
|
||||
*/
|
||||
suspend fun saveWallpaper(context: Context, uri: Uri): Boolean {
|
||||
return try {
|
||||
// 將圖片固定命名為 "custom_wallpaper.jpg",這樣每次上傳新圖片會自動覆蓋舊圖
|
||||
val fileName = "custom_wallpaper.jpg"
|
||||
val targetFile = File(context.filesDir, fileName)
|
||||
|
||||
// 開啟輸入流並複製到 App 的私有目錄下
|
||||
context.contentResolver.openInputStream(uri)?.use { inputStream ->
|
||||
FileOutputStream(targetFile).use { outputStream ->
|
||||
inputStream.copyTo(outputStream)
|
||||
}
|
||||
}
|
||||
|
||||
// 將檔案的絕對路徑存入 DataStore 記錄起來
|
||||
dataStore.edit { preferences ->
|
||||
preferences[PreferencesKeys.CUSTOM_WALLPAPER_PATH] = targetFile.absolutePath
|
||||
}
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 5. 新增:清除自訂壁紙設定,並刪除手機內的實體檔案(省空間)
|
||||
*/
|
||||
suspend fun clearWallpaper(context: Context) {
|
||||
try {
|
||||
// 1. 刪除實體檔案
|
||||
val fileName = "custom_wallpaper.jpg"
|
||||
val targetFile = File(context.filesDir, fileName)
|
||||
if (targetFile.exists()) {
|
||||
targetFile.delete()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
} finally {
|
||||
// 2. 無論檔案刪除成功與否,都清除 DataStore 內的紀錄
|
||||
dataStore.edit { preferences ->
|
||||
preferences.remove(PreferencesKeys.CUSTOM_WALLPAPER_PATH)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,56 +0,0 @@
|
|||
package com.example.login_v3.data.local.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room.Transaction
|
||||
import com.example.login_v3.data.local.entities.MessageEntity
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface MessageDao {
|
||||
@Query("SELECT * FROM messages WHERE chatRoomId = :roomId ORDER BY createdAt DESC")
|
||||
fun getMessagesFlow(roomId: String): Flow<List<MessageEntity>>
|
||||
|
||||
@Query("SELECT * FROM messages WHERE id = :messageId LIMIT 1")
|
||||
suspend fun getMessageById(messageId: String): MessageEntity?
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertOrUpdate(message: MessageEntity)
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertOrUpdateList(messages: List<MessageEntity>)
|
||||
|
||||
@Query("DELETE FROM messages WHERE id = :id")
|
||||
suspend fun deleteById(id: String)
|
||||
|
||||
@Query("SELECT id FROM messages WHERE chatRoomId = :roomId ORDER BY createdAt DESC LIMIT 1")
|
||||
suspend fun getLatestMessageId(roomId: String): String?
|
||||
|
||||
@Transaction
|
||||
suspend fun replaceTempMessageWithSuccess(tempId: String, successEntity: MessageEntity) {
|
||||
deleteById(tempId)
|
||||
insertOrUpdate(successEntity)
|
||||
}
|
||||
|
||||
@Transaction
|
||||
suspend fun insertOrUpdateListPreservingLocalData(messages: List<MessageEntity>) {
|
||||
messages.forEach { msg ->
|
||||
val existing = getMessageById(msg.id)
|
||||
if (existing != null) {
|
||||
// 🎯 核心:保留本地已下載的標記與路徑,其餘由網路資料覆蓋
|
||||
val updated = msg.copy(
|
||||
isDownloaded = existing.isDownloaded,
|
||||
localPath = existing.localPath
|
||||
)
|
||||
insertOrUpdate(updated)
|
||||
} else {
|
||||
insertOrUpdate(msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Query("UPDATE messages SET isDownloaded = :isDownloaded, localPath = :localPath WHERE id = :messageId")
|
||||
suspend fun updateDownloadStatus(messageId: String, isDownloaded: Boolean, localPath: String?)
|
||||
}
|
||||
|
|
@ -1,58 +0,0 @@
|
|||
package com.example.login_v3.data.local.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import com.example.login_v3.data.local.entities.BlockedUserLocalEntity
|
||||
import com.example.login_v3.data.local.entities.FriendLocalEntity
|
||||
import com.example.login_v3.data.local.entities.RoomLocalEntity
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface FriendDao {
|
||||
@Query("SELECT * FROM friends")
|
||||
fun getAllFriendsFlow(): Flow<List<FriendLocalEntity>>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertOrUpdate(friends: List<FriendLocalEntity>)
|
||||
|
||||
@Query("DELETE FROM friends WHERE userId = :userId")
|
||||
suspend fun deleteById(userId: String)
|
||||
|
||||
@Query("DELETE FROM friends")
|
||||
suspend fun deleteAll()
|
||||
}
|
||||
|
||||
@Dao
|
||||
interface RoomLocalDao {
|
||||
@Query("SELECT * FROM rooms ORDER BY isPinned DESC")
|
||||
fun getAllRoomsFlow(): Flow<List<RoomLocalEntity>>
|
||||
|
||||
@Query("SELECT * FROM rooms WHERE roomId = :roomId")
|
||||
fun getRoomFlow(roomId: String): Flow<RoomLocalEntity?>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertOrUpdate(rooms: List<RoomLocalEntity>)
|
||||
|
||||
@Query("DELETE FROM rooms WHERE roomId = :roomId")
|
||||
suspend fun deleteById(roomId: String)
|
||||
|
||||
@Query("DELETE FROM rooms")
|
||||
suspend fun deleteAll()
|
||||
}
|
||||
|
||||
@Dao
|
||||
interface BlockedUserDao {
|
||||
@Query("SELECT * FROM blocks")
|
||||
fun getAllBlocksFlow(): Flow<List<BlockedUserLocalEntity>>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertOrUpdate(blocks: List<BlockedUserLocalEntity>)
|
||||
|
||||
@Query("DELETE FROM blocks WHERE userId = :userId")
|
||||
suspend fun deleteById(userId: String)
|
||||
|
||||
@Query("DELETE FROM blocks")
|
||||
suspend fun deleteAll()
|
||||
}
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
package com.example.login_v3.data.local.entities
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
import com.example.login_v3.data.api.api_class.Reaction
|
||||
import com.example.login_v3.data.api.api_class.Attachment
|
||||
|
||||
enum class MessageStatus { SENDING, SUCCESS, FAILED }
|
||||
|
||||
@Entity(tableName = "messages")
|
||||
data class MessageEntity(
|
||||
@PrimaryKey val id: String,
|
||||
val chatRoomId: String,
|
||||
val senderId: String,
|
||||
val content: String,
|
||||
val type: String,
|
||||
val createdAt: String,
|
||||
val isEdited: Boolean,
|
||||
val isDeleted: Boolean,
|
||||
val replyToId: String?,
|
||||
val status: MessageStatus,
|
||||
val reactions: List<Reaction>?,
|
||||
val attachment: Attachment? = null,
|
||||
val aspectRatio: Float? = null,
|
||||
val isDownloaded: Boolean = false, // 👈 新增:標記媒體/訊息是否已下載到本地
|
||||
val localPath: String? = null // 👈 新增:儲存本地檔案路徑
|
||||
)
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
package com.example.login_v3.data.local.entities
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
import com.example.login_v3.data.api.api_class.LastMessage
|
||||
import com.example.login_v3.data.api.api_class.Partner
|
||||
|
||||
@Entity(tableName = "friends")
|
||||
data class FriendLocalEntity(
|
||||
@PrimaryKey val userId: String,
|
||||
val username: String,
|
||||
val displayName: String,
|
||||
val avatarUrl: String?,
|
||||
val status: String,
|
||||
val presenceStatus: String
|
||||
)
|
||||
|
||||
@Entity(tableName = "rooms")
|
||||
data class RoomLocalEntity(
|
||||
@PrimaryKey val roomId: String,
|
||||
val nickname: String?,
|
||||
val isHidden: Boolean,
|
||||
val isMuted: Boolean,
|
||||
val isPinned: Boolean,
|
||||
val unreadCount: Int,
|
||||
val mentionCount: Int,
|
||||
val partner: Partner?,
|
||||
val lastMessage: LastMessage?
|
||||
)
|
||||
|
||||
@Entity(tableName = "blocks")
|
||||
data class BlockedUserLocalEntity(
|
||||
@PrimaryKey val userId: String,
|
||||
val username: String,
|
||||
val displayName: String,
|
||||
val avatarUrl: String?,
|
||||
val blockedAt: String
|
||||
)
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
package com.example.login_v3.data.repository.basic
|
||||
|
||||
import com.example.login_v3.data.api.TecnologiaApi
|
||||
import com.example.login_v3.data.api.api_class.LoginRequest
|
||||
import com.example.login_v3.data.api.api_class.LoginResponse
|
||||
import com.example.login_v3.data.api.api_class.RegisterRequest
|
||||
import com.example.login_v3.data.api.api_class.RegisterResponse
|
||||
import retrofit2.Response
|
||||
import javax.inject.Inject
|
||||
|
||||
class AuthRepository @Inject constructor(
|
||||
private val api: TecnologiaApi
|
||||
) {
|
||||
suspend fun register(request: RegisterRequest): Response<RegisterResponse> {
|
||||
return api.register(request)
|
||||
}
|
||||
|
||||
// 登入功能預留
|
||||
suspend fun login(request: LoginRequest): Response<LoginResponse> {
|
||||
return api.login(request)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,128 +0,0 @@
|
|||
package com.example.login_v3.data.repository.basic
|
||||
|
||||
import android.util.Log
|
||||
import com.example.login_v3.data.api.TecnologiaApi
|
||||
import com.example.login_v3.data.api.api_class.AddFriendRequest
|
||||
import com.example.login_v3.data.api.api_class.Friend
|
||||
import com.example.login_v3.data.api.api_class.PendingFriendApiModel
|
||||
import com.example.login_v3.data.api.api_class.UserDetail
|
||||
import com.example.login_v3.data.api.api_class.UserSearchResponse
|
||||
import com.example.login_v3.data.local.dao.FriendDao
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import retrofit2.Response
|
||||
import javax.inject.Inject
|
||||
|
||||
class FriendsRepository @Inject constructor(
|
||||
private val apiService: TecnologiaApi,
|
||||
private val friendDao: FriendDao
|
||||
) {
|
||||
|
||||
// 🌟 核心:監聽本地資料庫的好友列表 Flow
|
||||
val friendsFlow: Flow<List<Friend>> = friendDao.getAllFriendsFlow().map { entities ->
|
||||
entities.map { entity ->
|
||||
Friend(
|
||||
friendId = entity.userId,
|
||||
username = entity.username,
|
||||
displayName = entity.displayName,
|
||||
avatarUrl = entity.avatarUrl,
|
||||
status = entity.presenceStatus,
|
||||
acceptedAt = null // Sync API 目前沒給這個,先設為 null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getFriendList(): Result<List<Friend>> {
|
||||
return try {
|
||||
val response = apiService.getFriends()
|
||||
// 如果 apiService 直接回傳 FriendListResponse (非 Response 封裝)
|
||||
// 則直接取用,並處理 null
|
||||
Result.success(response.friends ?: emptyList())
|
||||
} catch (e: Exception) {
|
||||
// 這裡可以根據 e 的型別做額外處理,例如 HttpException 或 IOException
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
//pending list
|
||||
suspend fun getPendingRequests(): Result<List<PendingFriendApiModel>> {
|
||||
return try {
|
||||
val response = apiService.getFriendsPending()
|
||||
// 這裡同樣建議處理可能為 null 的情況
|
||||
Result.success(response.pendingRequests ?: emptyList())
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
// 接受
|
||||
suspend fun acceptFriendRequest(id: String) = safeApiCall {
|
||||
apiService.acceptFriendRequest(id)
|
||||
}
|
||||
|
||||
// 拒絕
|
||||
suspend fun rejectFriendRequest(id: String) = safeApiCall {
|
||||
apiService.rejectFriendRequest(id)
|
||||
}
|
||||
|
||||
//add friends
|
||||
suspend fun sendFriendRequest(friendId: String): Result<Unit> {
|
||||
Log.d("API_DEBUG", "準備發送好友申請,目標 ID: $friendId")
|
||||
val result = safeApiCall {
|
||||
val request = AddFriendRequest(friendId = friendId)
|
||||
apiService.sendFriendRequest(request)
|
||||
}
|
||||
result.onFailure { Log.e("API_DEBUG", "sendFriendRequest 最終失敗: ${it.message}") }
|
||||
return result
|
||||
}
|
||||
|
||||
|
||||
//search user
|
||||
suspend fun searchUsers(query: String): Result<UserSearchResponse> {
|
||||
return safeApiCallWithData {
|
||||
apiService.searchUsers(query) // 1. 使用 apiService 2. 確保 apiService 定義回傳 Response<UserSearchResponse>
|
||||
}
|
||||
}
|
||||
|
||||
// 支援「回傳資料」的通用封裝 for search user
|
||||
private suspend fun <T> safeApiCallWithData(call: suspend () -> Response<T>): Result<T> {
|
||||
return try {
|
||||
val response = call()
|
||||
if (response.isSuccessful) {
|
||||
val body = response.body()
|
||||
if (body != null) {
|
||||
Result.success(body)
|
||||
} else {
|
||||
Result.failure(Exception("回傳資料為空"))
|
||||
}
|
||||
} else {
|
||||
val errorMsg = "API 錯誤 ${response.code()}: ${response.errorBody()?.string()}"
|
||||
Result.failure(Exception(errorMsg))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 確保「全類別」只有這一個 safeApiCall 函式
|
||||
private suspend fun safeApiCall(call: suspend () -> Response<Unit>): Result<Unit> {
|
||||
return try {
|
||||
val response = call()
|
||||
if (response.isSuccessful) {
|
||||
Log.d("API_DEBUG", "請求成功: ${response.raw().request.url}")
|
||||
Result.success(Unit)
|
||||
} else {
|
||||
val errorBody = response.errorBody()?.string()
|
||||
val errorCode = response.code()
|
||||
val errorMessage = "API 錯誤 $errorCode: $errorBody"
|
||||
Log.e("API_DEBUG", errorMessage)
|
||||
Result.failure(Exception(errorMessage))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("API_DEBUG", "連線或執行異常: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
package com.example.login_v3.data.repository.basic
|
||||
|
||||
import com.example.login_v3.data.api.TecnologiaApi
|
||||
import javax.inject.Inject
|
||||
|
||||
class NetworkRepository @Inject constructor(
|
||||
private val apiService: TecnologiaApi
|
||||
) {
|
||||
suspend fun performHealthCheck(): Result<Boolean> {
|
||||
return try {
|
||||
val response = apiService.checkHealth()
|
||||
if (response.isSuccessful) {
|
||||
Result.success(true)
|
||||
} else {
|
||||
Result.failure(Exception("Error code: ${response.code()}"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,67 +0,0 @@
|
|||
package com.example.login_v3.data.repository.basic
|
||||
|
||||
import com.example.login_v3.data.api.TecnologiaApi
|
||||
import com.example.login_v3.data.api.api_class.UserProfile
|
||||
import com.example.login_v3.data.api.api_class.UserProfileUpdateRequest
|
||||
import okhttp3.MultipartBody
|
||||
import retrofit2.Response
|
||||
import javax.inject.Inject
|
||||
|
||||
class PersonalProfileRepository @Inject constructor(
|
||||
private val api: TecnologiaApi // 確保你的 API interface 裡有 getUserProfile 和 updateProfile
|
||||
) {
|
||||
// 取得資料
|
||||
suspend fun getMyProfile(): Result<UserProfile> {
|
||||
return try {
|
||||
val response = api.getUserProfile()
|
||||
if (response.isSuccessful) {
|
||||
val body = response.body()
|
||||
if (body != null) Result.success(body)
|
||||
else Result.failure(Exception("回傳資料為空"))
|
||||
} else {
|
||||
Result.failure(Exception("讀取失敗 (${response.code()})"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
// 更新資料 (PATCH)
|
||||
suspend fun patchUserProfile(request: UserProfileUpdateRequest): Result<Unit> {
|
||||
return try {
|
||||
val response = api.updateProfile(request)
|
||||
if (response.isSuccessful) {
|
||||
Result.success(Unit)
|
||||
} else {
|
||||
Result.failure(Exception("更新失敗: ${response.code()}"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 上傳頭像圖片 (POST)
|
||||
// 通用的上傳邏輯
|
||||
private suspend fun uploadImage(
|
||||
filePart: MultipartBody.Part,
|
||||
uploadAction: suspend (MultipartBody.Part) -> Response<Unit>
|
||||
): Result<Unit> {
|
||||
return try {
|
||||
val response = uploadAction(filePart)
|
||||
if (response.isSuccessful) {
|
||||
Result.success(Unit)
|
||||
} else {
|
||||
Result.failure(Exception("圖片上傳失敗: ${response.code()}"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
// 外部呼叫時依然保有語意化的名稱
|
||||
suspend fun uploadAvatar(avatarPart: MultipartBody.Part) =
|
||||
uploadImage(avatarPart, api::uploadAvatar)
|
||||
|
||||
suspend fun uploadBanner(bannerPart: MultipartBody.Part) =
|
||||
uploadImage(bannerPart, api::uploadBanner)
|
||||
}
|
||||
|
|
@ -1,143 +0,0 @@
|
|||
package com.example.login_v3.data.repository.basic
|
||||
|
||||
import android.util.Log
|
||||
import androidx.room.withTransaction
|
||||
import com.example.login_v3.data.api.TecnologiaApi
|
||||
import com.example.login_v3.data.di.AppDatabase
|
||||
import com.example.login_v3.data.local.UserPreferences.UserPreferencesRepository
|
||||
import com.example.login_v3.data.local.entities.BlockedUserLocalEntity
|
||||
import com.example.login_v3.data.local.entities.FriendLocalEntity
|
||||
import com.example.login_v3.data.local.entities.RoomLocalEntity
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class SyncRepository @Inject constructor(
|
||||
private val api: TecnologiaApi,
|
||||
private val db: AppDatabase,
|
||||
private val userPrefs: UserPreferencesRepository
|
||||
) {
|
||||
private val friendDao = db.friendDao()
|
||||
private val roomDao = db.roomLocalDao()
|
||||
private val blockDao = db.blockedUserDao()
|
||||
|
||||
// 🔒 使用 Mutex 確保同一時間只有一個同步任務在執行,避免重複請求
|
||||
private val syncMutex = Mutex()
|
||||
|
||||
suspend fun performSync(): Result<Unit> {
|
||||
// 如果已經有同步在跑,直接回傳成功(避免重複排隊)
|
||||
if (syncMutex.isLocked) {
|
||||
Log.d("SyncDebug", "⏳ [Sync] 偵測到已有同步任務正在進行,跳過本次請求")
|
||||
return Result.success(Unit)
|
||||
}
|
||||
|
||||
return syncMutex.withLock {
|
||||
try {
|
||||
val since = userPrefs.syncSinceFlow.first()
|
||||
Log.d("SyncDebug", "🚀 [Sync] 開始同步 | since: $since")
|
||||
|
||||
val response = api.sync(since)
|
||||
|
||||
if (response.isSuccessful) {
|
||||
val body = response.body() ?: return Result.failure(Exception("Sync body is null"))
|
||||
|
||||
Log.d("SyncDebug", "✅ [Sync] API 成功 | FullSnapshot: ${body.fullSnapshot} | nextSince: ${body.nextSince}")
|
||||
Log.d("SyncDebug", "📦 [Sync] 收到: Blocks(${body.blocks.size}), Friends(${body.friends.size}), Rooms(${body.rooms.size})")
|
||||
|
||||
db.withTransaction {
|
||||
if (body.fullSnapshot) {
|
||||
Log.w("SyncDebug", "⚠️ [Sync] 收到 FullSnapshot,正在清空本地舊資料...")
|
||||
friendDao.deleteAll()
|
||||
roomDao.deleteAll()
|
||||
blockDao.deleteAll()
|
||||
}
|
||||
|
||||
// 1. 處理黑名單
|
||||
body.blocks.forEach { block ->
|
||||
if (block.removed) {
|
||||
blockDao.deleteById(block.userId)
|
||||
} else {
|
||||
blockDao.insertOrUpdate(listOf(
|
||||
BlockedUserLocalEntity(
|
||||
userId = block.userId,
|
||||
username = block.username,
|
||||
displayName = block.displayName,
|
||||
avatarUrl = block.avatarUrl,
|
||||
blockedAt = block.blockedAt
|
||||
)
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 處理好友
|
||||
body.friends.forEach { friend ->
|
||||
if (friend.removed) {
|
||||
friendDao.deleteById(friend.userId)
|
||||
} else {
|
||||
friendDao.insertOrUpdate(listOf(
|
||||
FriendLocalEntity(
|
||||
userId = friend.userId,
|
||||
username = friend.username,
|
||||
displayName = friend.displayName,
|
||||
avatarUrl = friend.avatarUrl,
|
||||
status = friend.status,
|
||||
presenceStatus = friend.presenceStatus
|
||||
)
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 處理聊天室
|
||||
body.rooms.forEach { room ->
|
||||
if (room.removed) {
|
||||
roomDao.deleteById(room.roomId)
|
||||
} else {
|
||||
roomDao.insertOrUpdate(listOf(
|
||||
RoomLocalEntity(
|
||||
roomId = room.roomId,
|
||||
nickname = room.nickname,
|
||||
isHidden = room.isHidden,
|
||||
isMuted = room.isMuted,
|
||||
isPinned = room.isPinned,
|
||||
unreadCount = room.unreadCount,
|
||||
mentionCount = room.mentionCount,
|
||||
partner = room.partner,
|
||||
lastMessage = room.lastMessage
|
||||
)
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
userPrefs.saveSyncSince(body.nextSince)
|
||||
Log.d("SyncDebug", "💾 [Sync] 同步完成,已更新 nextSince 為 ${body.nextSince}")
|
||||
}
|
||||
Result.success(Unit)
|
||||
} else {
|
||||
Log.e("SyncDebug", "❌ [Sync] API 失敗 | Code: ${response.code()}")
|
||||
Result.failure(Exception("Sync API failed: ${response.code()}"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("SyncDebug", "❌ [Sync] 發生異常", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🛠️ 調試專用:重置同步狀態並清空本地資料
|
||||
* 呼叫此方法後,下一次同步將會從 since=0 開始抓取完整資料
|
||||
*/
|
||||
suspend fun resetSyncAndClearData() {
|
||||
Log.w("SyncDebug", "🧨 [Debug] 正在重置同步狀態並清空資料庫...")
|
||||
db.withTransaction {
|
||||
friendDao.deleteAll()
|
||||
roomDao.deleteAll()
|
||||
blockDao.deleteAll()
|
||||
userPrefs.saveSyncSince(0L)
|
||||
}
|
||||
Log.d("SyncDebug", "✅ [Debug] 重置完成")
|
||||
}
|
||||
}
|
||||
|
|
@ -1,137 +0,0 @@
|
|||
package com.example.login_v3.data.repository.basic
|
||||
|
||||
import android.util.Log
|
||||
import com.example.login_v3.data.api.RefreshRequest
|
||||
import com.example.login_v3.data.api.TecnologiaApi
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import okhttp3.Authenticator
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.Route
|
||||
import javax.inject.Inject
|
||||
|
||||
|
||||
class TokenAuthenticator @Inject constructor(
|
||||
private val tokenManager: TokenManager,
|
||||
private val apiService: dagger.Lazy<TecnologiaApi>
|
||||
) : Authenticator {
|
||||
|
||||
companion object {
|
||||
// 🌟 關鍵:全域共用同一把鎖,防止多個 API 同時過期時重複打刷新介面
|
||||
private val refreshMutex = Mutex()
|
||||
}
|
||||
|
||||
override fun authenticate(route: Route?, response: Response): Request? {
|
||||
val url = response.request.url
|
||||
Log.d("AuthDebug", "--- 進入 Authenticator (觸發網址: $url) ---")
|
||||
|
||||
// 🌟 防禦機制:如果刷新 Token 的請求本身又回傳了 401,代表 Refresh Token 已徹底過期
|
||||
// 必須直接回傳 null,否則會進入無窮迴圈(甚至死鎖,因為 Mutex 不可重入)
|
||||
if (url.encodedPath.contains("api/auth/refresh")) {
|
||||
Log.e("AuthDebug", "❌ 刷新 Token 的請求本身又回傳了 401!停止重試。")
|
||||
return null
|
||||
}
|
||||
|
||||
// 使用 runBlocking 配合 Mutex,讓同時間進來的 401 請求排隊
|
||||
return runBlocking {
|
||||
refreshMutex.withLock {
|
||||
|
||||
// 1. 檢查目前 DataStore 裡的 Access Token
|
||||
val currentTokenInStore = tokenManager.currentAccessToken.first()
|
||||
val oldRequestToken = response.request.header("Authorization")?.removePrefix("Bearer ")
|
||||
|
||||
Log.d("AuthDebug", "DataStore中的Token: ${currentTokenInStore?.take(10)}...")
|
||||
Log.d("AuthDebug", "原始請求的Token: ${oldRequestToken?.take(10)}...")
|
||||
|
||||
// 🌟 防禦機制:如果 DataStore 裡的 Token 已經跟當初發起請求時的舊 Token 不同了
|
||||
// 代表排在你前面的請求已經幫忙刷新成功了!這裡直接拿新 Token 重試即可,不用再打一次 API。
|
||||
if (currentTokenInStore != oldRequestToken && !currentTokenInStore.isNullOrEmpty()) {
|
||||
Log.d("AuthDebug", "分流防禦:偵測到其他請求已刷新過 Token,直接重複利用新 Token")
|
||||
val requestBuilder = response.request.newBuilder()
|
||||
requestBuilder.header("Authorization", "Bearer $currentTokenInStore")
|
||||
|
||||
// 針對 WebSocket 更新 URL 中的 token 參數
|
||||
if (response.request.url.queryParameter("token") != null) {
|
||||
val newUrl = response.request.url.newBuilder()
|
||||
.setQueryParameter("token", currentTokenInStore)
|
||||
.build()
|
||||
requestBuilder.url(newUrl)
|
||||
}
|
||||
return@runBlocking requestBuilder.build()
|
||||
}
|
||||
|
||||
// 2. 真正準備去打後端刷新
|
||||
val refreshToken = tokenManager.currentRefreshToken.first()
|
||||
val currentUserId = tokenManager.currentUserId.first()
|
||||
Log.d("AuthDebug", "當前用戶: $currentUserId, Token是否存在: ${!refreshToken.isNullOrEmpty()}")
|
||||
|
||||
if (refreshToken.isNullOrEmpty() || currentUserId.isNullOrEmpty()) {
|
||||
return@runBlocking null
|
||||
}
|
||||
|
||||
// 3. 同步發送網路請求
|
||||
Log.d("AuthDebug", "正在向伺服器請求刷新 Token... RefreshToken: ${refreshToken.take(10)}...")
|
||||
val refreshResponse = try {
|
||||
apiService.get().refreshToken(RefreshRequest(refreshToken))
|
||||
} catch (e: Exception) {
|
||||
Log.e("AuthDebug", "打刷新 API 發生網路異常: ${e.message}", e)
|
||||
null
|
||||
}
|
||||
|
||||
// 4. 判斷刷新是否成功
|
||||
if (refreshResponse != null) {
|
||||
Log.d("AuthDebug", "刷新回應碼: ${refreshResponse.code()}, 是否成功: ${refreshResponse.isSuccessful}")
|
||||
if (refreshResponse.isSuccessful) {
|
||||
val newAuthData = refreshResponse.body()
|
||||
val newAccessToken = newAuthData?.access_token ?: newAuthData?.token
|
||||
|
||||
if (!newAccessToken.isNullOrEmpty()) {
|
||||
Log.d("AuthDebug", "✅ Token 刷新成功,新 AccessToken: ${newAccessToken.take(10)}...")
|
||||
|
||||
// 如果後端沒給新的 refresh_token,就沿用舊的
|
||||
val nextRefreshToken = if (!newAuthData?.refresh_token.isNullOrEmpty()) {
|
||||
newAuthData?.refresh_token!!
|
||||
} else {
|
||||
refreshToken
|
||||
}
|
||||
|
||||
tokenManager.saveAuthData(
|
||||
userId = currentUserId,
|
||||
accessToken = newAccessToken,
|
||||
refreshToken = nextRefreshToken,
|
||||
expiresInSec = newAuthData?.expires_in ?: 3600
|
||||
)
|
||||
|
||||
Log.d("AuthDebug", "✅ DataStore 寫入完成,重新發送原請求")
|
||||
|
||||
// 修正:同步更新 Header 與 URL 中的 token 參數 (針對 WebSocket)
|
||||
val requestBuilder = response.request.newBuilder()
|
||||
requestBuilder.header("Authorization", "Bearer $newAccessToken")
|
||||
if (response.request.url.queryParameter("token") != null) {
|
||||
val newUrl = response.request.url.newBuilder()
|
||||
.setQueryParameter("token", newAccessToken)
|
||||
.build()
|
||||
requestBuilder.url(newUrl)
|
||||
}
|
||||
return@runBlocking requestBuilder.build()
|
||||
} else {
|
||||
Log.e("AuthDebug", "❌ 刷新成功但無法取得新 Token (Body: $newAuthData)")
|
||||
}
|
||||
} else {
|
||||
Log.e("AuthDebug", "❌ 刷新失敗,錯誤代碼: ${refreshResponse.code()}, 錯誤內容: ${refreshResponse.errorBody()?.string()}")
|
||||
}
|
||||
} else {
|
||||
Log.e("AuthDebug", "❌ refreshResponse 為 null (網路異常)")
|
||||
}
|
||||
|
||||
// 5. 失敗:連 Refresh Token 都失效,強制登出
|
||||
Log.e("AuthDebug", "❌ Refresh Token 已失效,執行強制登出!")
|
||||
tokenManager.logout(currentUserId)
|
||||
return@runBlocking null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,100 +0,0 @@
|
|||
package com.example.login_v3.data.repository.basic
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.longPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
|
||||
class TokenManager @Inject constructor(
|
||||
private val dataStore: DataStore<Preferences>
|
||||
) {
|
||||
|
||||
val allUserIds: Flow<Set<String>> = dataStore.data.map { preferences ->
|
||||
preferences[USER_ID_LIST] ?: emptySet()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val USER_ID_LIST = stringSetPreferencesKey("user_id_list")
|
||||
private val CURRENT_USER_ID = stringPreferencesKey("current_user_id")
|
||||
|
||||
private fun accessTokenKey(userId: String) = stringPreferencesKey("access_token_$userId")
|
||||
private fun refreshTokenKey(userId: String) = stringPreferencesKey("refresh_token_$userId")
|
||||
private fun expiresAtKey(userId: String) = longPreferencesKey("expires_at_$userId")
|
||||
}
|
||||
|
||||
// 儲存資料(維持原樣,這是安全的)
|
||||
suspend fun saveAuthData(userId: String, accessToken: String, refreshToken: String, expiresInSec: Int) {
|
||||
android.util.Log.d("TokenManager", "儲存 Token - User: $userId, AccessToken: ${accessToken.take(10)}..., RefreshToken: ${refreshToken.take(10)}...")
|
||||
dataStore.edit { preferences ->
|
||||
preferences[accessTokenKey(userId)] = accessToken
|
||||
preferences[refreshTokenKey(userId)] = refreshToken
|
||||
|
||||
val expiresAt = System.currentTimeMillis() + (expiresInSec * 1000L)
|
||||
preferences[expiresAtKey(userId)] = expiresAt
|
||||
android.util.Log.d("TokenManager", "Token 將於 ${(expiresInSec / 60)} 分鐘後過期 (timestamp: $expiresAt)")
|
||||
|
||||
preferences[CURRENT_USER_ID] = userId
|
||||
|
||||
val currentList = preferences[USER_ID_LIST] ?: emptySet()
|
||||
preferences[USER_ID_LIST] = currentList + userId
|
||||
}
|
||||
}
|
||||
|
||||
val currentUserId: Flow<String?> = dataStore.data.map { it[CURRENT_USER_ID] }
|
||||
|
||||
// 🌟 核心修正一:提供一個可以直接傳入 userId 獲取 Token 的 suspend 函式
|
||||
// 這樣在 Authenticator 內,當你已經有 currentUserId 時,直接用這個抓最安全!
|
||||
suspend fun getAccessToken(userId: String): String? {
|
||||
return dataStore.data.map { preferences ->
|
||||
preferences[accessTokenKey(userId)]
|
||||
}.first()
|
||||
}
|
||||
|
||||
// 🌟 核心修正二:改用 flatMapLatest (需要 import kotlinx.coroutines.flow.flatMapLatest)
|
||||
// 確保當 CURRENT_USER_ID 改變時,舊的 Flow 會被立即取消並重導向,防止讀到髒資料
|
||||
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
|
||||
val currentAccessToken: Flow<String?> = currentUserId.flatMapLatest { userId ->
|
||||
dataStore.data.map { preferences ->
|
||||
if (userId == null) null else preferences[accessTokenKey(userId)]
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
|
||||
val currentRefreshToken: Flow<String?> = currentUserId.flatMapLatest { userId ->
|
||||
dataStore.data.map { preferences ->
|
||||
if (userId == null) null else preferences[refreshTokenKey(userId)]
|
||||
}
|
||||
}
|
||||
|
||||
// 切換帳號、登出等邏輯保持不變,但登出時記得一併移除 refresh_token 與 expires_at
|
||||
suspend fun logout(userId: String) {
|
||||
dataStore.edit { preferences ->
|
||||
preferences.remove(accessTokenKey(userId))
|
||||
preferences.remove(refreshTokenKey(userId))
|
||||
preferences.remove(expiresAtKey(userId))
|
||||
|
||||
val currentList = preferences[USER_ID_LIST] ?: emptySet()
|
||||
preferences[USER_ID_LIST] = currentList - userId
|
||||
|
||||
if (preferences[CURRENT_USER_ID] == userId) {
|
||||
val remainingUser = (currentList - userId).firstOrNull()
|
||||
if (remainingUser != null) {
|
||||
preferences[CURRENT_USER_ID] = remainingUser
|
||||
} else {
|
||||
preferences.remove(CURRENT_USER_ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun switchAccount(userId: String) {
|
||||
// 目前暫不實作
|
||||
}
|
||||
}
|
||||
|
|
@ -1,702 +0,0 @@
|
|||
package com.example.login_v3.data.repository.dm
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.net.Uri
|
||||
import android.os.Environment
|
||||
import android.provider.OpenableColumns
|
||||
import android.util.Log
|
||||
import com.example.login_v3.data.api.TecnologiaApi
|
||||
import com.example.login_v3.data.api.api_class.ChatRoom
|
||||
import com.example.login_v3.data.api.api_class.EditMessageRequest
|
||||
import com.example.login_v3.data.api.api_class.Message
|
||||
import com.example.login_v3.data.api.api_class.MessageResponse
|
||||
import com.example.login_v3.data.api.api_class.Reaction
|
||||
import com.example.login_v3.data.api.api_class.RoomListResponse
|
||||
import com.example.login_v3.data.api.api_class.SendMessageRequest
|
||||
import com.example.login_v3.data.api.api_class.WebSocketEventResponse
|
||||
import com.example.login_v3.data.di.ChatWebSocketManager
|
||||
import com.example.login_v3.data.repository.basic.TokenManager
|
||||
import com.example.login_v3.data.local.dao.MessageDao
|
||||
import com.example.login_v3.data.local.dao.RoomLocalDao
|
||||
import com.example.login_v3.data.local.entities.MessageEntity
|
||||
import com.example.login_v3.data.local.entities.MessageStatus
|
||||
import com.example.login_v3.data.local.entities.RoomLocalEntity
|
||||
import com.squareup.moshi.Json
|
||||
import com.squareup.moshi.JsonClass
|
||||
import com.squareup.moshi.Moshi
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlin.io.use
|
||||
import kotlin.jvm.java
|
||||
|
||||
|
||||
@Singleton
|
||||
class ChatRoomsRepository @Inject constructor(
|
||||
private val api: TecnologiaApi,
|
||||
private val messageDao: MessageDao,
|
||||
private val roomLocalDao: RoomLocalDao, // 👈 新增:注入房間 DAO
|
||||
private val webSocketManager: ChatWebSocketManager, // 👈 1. 注入 Socket 管理器
|
||||
private val moshi: Moshi, // 👈 用於解析 Socket 傳來的 JSON
|
||||
private val tokenManager: TokenManager,
|
||||
@ApplicationContext private val context: Context // 注入 ApplicationContext 用於處理 Uri 檔案
|
||||
) {
|
||||
|
||||
// 🌟 核心:監聽本地資料庫的聊天室列表 Flow
|
||||
val chatRoomsFlow: Flow<List<ChatRoom>> = roomLocalDao.getAllRoomsFlow().map { entities ->
|
||||
entities.map { it.toDomain() }
|
||||
}
|
||||
|
||||
fun getChatRoomFlow(roomId: String): Flow<ChatRoom?> {
|
||||
return roomLocalDao.getRoomFlow(roomId).map { it?.toDomain() }
|
||||
}
|
||||
|
||||
private fun RoomLocalEntity.toDomain(): ChatRoom {
|
||||
return ChatRoom(
|
||||
roomId = this.roomId,
|
||||
roomName = this.nickname,
|
||||
roomType = null,
|
||||
roomIconUrl = null,
|
||||
isMuted = this.isMuted,
|
||||
isPinned = this.isPinned,
|
||||
unreadCount = this.unreadCount,
|
||||
mentionCount = this.mentionCount,
|
||||
partner = this.partner,
|
||||
lastMessage = this.lastMessage
|
||||
)
|
||||
}
|
||||
|
||||
// 建立一個跟著 Repository 生命週期走的 Scope
|
||||
private val repositoryScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
init {
|
||||
// 👈 2. 在初始化時,開始默默監聽全域的 WebSocket 訊息
|
||||
observeWebSocketIncomingData()
|
||||
}
|
||||
|
||||
private fun observeWebSocketIncomingData() {
|
||||
repositoryScope.launch {
|
||||
webSocketManager.incomingMessages.collect { jsonString ->
|
||||
try {
|
||||
// 1. 一口氣全解析完畢(包含外殼與內部的 payload)
|
||||
val event = moshi.adapter(WebSocketEventResponse::class.java).fromJson(jsonString)
|
||||
|
||||
if (event != null && event.type == "new_message" && event.payload != null) {
|
||||
val payload = event.payload
|
||||
|
||||
// 2. 直接拿解析好的強型別物件轉換為 MessageEntity
|
||||
val entity = MessageEntity(
|
||||
id = payload.id,
|
||||
chatRoomId = payload.roomId, // 🎯 這裡百分之百能拿到後端的 room_id
|
||||
senderId = payload.senderId,
|
||||
content = payload.content,
|
||||
type = payload.type,
|
||||
createdAt = payload.createdAt,
|
||||
isEdited = payload.isEdited,
|
||||
isDeleted = payload.isDeleted,
|
||||
replyToId = payload.replyToId,
|
||||
status = MessageStatus.SUCCESS,
|
||||
reactions = payload.reactions,
|
||||
attachment = payload.attachment,
|
||||
aspectRatio = payload.attachment?.let { if (it.width > 0 && it.height > 0) it.width.toFloat() / it.height.toFloat() else null }
|
||||
)
|
||||
|
||||
// 3. 寫入本地資料庫 (同樣使用保留本地標記的版本)
|
||||
messageDao.insertOrUpdateListPreservingLocalData(listOf(entity))
|
||||
Log.d("ChatRoomsRepository", "WebSocket 訊息成功寫入資料庫: ${entity.content}")
|
||||
|
||||
} else if (event?.type == "dm:typing") {
|
||||
// 💡 如果未來想要解析 typing 狀態,你可以另外把 event.payload 轉成 Map 來拿資料,因為 typing 的 payload 結構不同
|
||||
// 由於我們把外層改成了 WebSocketMessagePayload?,若 type 是 dm:typing,外層依然可以解析,但 payload 會是 null 或解析失敗
|
||||
// 如果你同時想處理 typing,可以用 `moshi.adapter(Map::class.java)` 單獨處理該事件。
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("ChatRoomsRepository", "解析或寫入 WebSocket 即時訊息失敗", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 🎯 1. 改成 suspend fun,以便讀取 tokenManager
|
||||
suspend fun startChatSession(roomId: String) {
|
||||
// 2. 從你的 tokenManager 撈出最新的 Access Token (與你原本在 OkHttp 裡撈取的方式一致)
|
||||
val token = tokenManager.currentAccessToken.first() ?: ""
|
||||
|
||||
if (token.isBlank()) {
|
||||
Log.e("Repository", "WebSocket 連線失敗:找不到 Token")
|
||||
return
|
||||
}
|
||||
|
||||
// 3. 🎯 動態組裝後端指定的網址,把 $jwt 替換成真正的 Token
|
||||
val webSocketUrl = "wss://tg.technologia-tw.com/api/ws?token=$token"
|
||||
|
||||
// 4. 啟動連線
|
||||
webSocketManager.connect(webSocketUrl)
|
||||
|
||||
// 5. 💡 重要核心:因為是全域連線,後端怎麼知道你現在在看哪一個房間?
|
||||
// 通常連線成功後,需要發送一個「訂閱房間」的 JSON 告訴後端:「Eric 現在進入了 roomId 聊天室,請把這個房間的即時訊息丟給我」
|
||||
// 如果你們後端的設計是「只要連上,全域所有房間的訊息都會無腦推下來」,那下面這行就可以註解掉。
|
||||
webSocketManager.subscribeToRoom(roomId)
|
||||
}
|
||||
|
||||
suspend fun fetchRooms(): Result<RoomListResponse> {
|
||||
return try {
|
||||
val response = api.getChatRooms()
|
||||
if (response.isSuccessful) {
|
||||
// 使用 body() ?: return ... 可以減少一層 if 巢狀結構
|
||||
val body = response.body() ?: return Result.failure(Exception("Rooms response body is null"))
|
||||
Result.success(body)
|
||||
} else {
|
||||
Result.failure(Exception("Error code: ${response.code()}"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
fun getChatMessagesFlow(roomId: String): Flow<List<Message>> {
|
||||
return messageDao.getMessagesFlow(roomId).map { entities ->
|
||||
val domainMessages = entities.map { entity ->
|
||||
Message(
|
||||
id = entity.id,
|
||||
chatRoomId = entity.chatRoomId, // 👈 完美對齊
|
||||
senderId = entity.senderId,
|
||||
content = entity.content,
|
||||
type = entity.type,
|
||||
createdAt = entity.createdAt, // 👈 String 對 String
|
||||
isEdited = entity.isEdited,
|
||||
isDeleted = entity.isDeleted,
|
||||
replyToId = entity.replyToId,
|
||||
status = entity.status, // 👈 把 Room 儲存的狀態倒出來給 UI
|
||||
reactions = entity.reactions,
|
||||
attachment = entity.attachment
|
||||
).apply {
|
||||
aspectRatio = entity.aspectRatio
|
||||
isDownloaded = entity.isDownloaded // 👈 新增
|
||||
localPath = entity.localPath // 👈 新增
|
||||
}
|
||||
}
|
||||
|
||||
// 你原本的處理回覆邏輯
|
||||
val messageMap = domainMessages.associateBy { it.id }
|
||||
domainMessages.forEach { message ->
|
||||
if (!message.replyToId.isNullOrBlank()) {
|
||||
message.repliedMessage = messageMap[message.replyToId]
|
||||
}
|
||||
}
|
||||
domainMessages
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
//api get message and push to local
|
||||
suspend fun refreshChatMessages(
|
||||
roomId: String,
|
||||
cursor: String? = null,
|
||||
limit: Int? = null
|
||||
): Result<MessageResponse> {
|
||||
Log.d("ChatDebug", "📡 [Repository] refreshChatMessages 觸發 | roomId: $roomId | cursor: $cursor | limit: $limit")
|
||||
return try {
|
||||
// 1. 把 cursor 與 limit 傳給 API
|
||||
val response = api.getChatMessages(roomId, cursor = cursor, limit = limit)
|
||||
|
||||
if (response.isSuccessful) {
|
||||
val body = response.body()
|
||||
if (body != null) {
|
||||
val firstId = body.messages.firstOrNull()?.id
|
||||
val lastId = body.messages.lastOrNull()?.id
|
||||
Log.d("ChatDebug", "✅ [Repository] API 成功 | 收到 ${body.messages.size} 條訊息 | 首條ID: $firstId | 末條ID: $lastId | nextCursor: ${body.nextCursor}")
|
||||
|
||||
// 2. 轉換成 Entity 列表
|
||||
val entities = body.messages.map { networkMessage ->
|
||||
MessageEntity(
|
||||
id = networkMessage.id,
|
||||
chatRoomId = roomId,
|
||||
senderId = networkMessage.senderId,
|
||||
content = networkMessage.content,
|
||||
type = networkMessage.type,
|
||||
createdAt = networkMessage.createdAt,
|
||||
isEdited = networkMessage.isEdited,
|
||||
isDeleted = networkMessage.isDeleted,
|
||||
replyToId = networkMessage.replyToId,
|
||||
status = MessageStatus.SUCCESS,
|
||||
reactions = networkMessage.reactions,
|
||||
attachment = networkMessage.attachment,
|
||||
aspectRatio = networkMessage.attachment?.let { if (it.width > 0 && it.height > 0) it.width.toFloat() / it.height.toFloat() else null }
|
||||
)
|
||||
}
|
||||
|
||||
// 3. 寫入資料庫 (使用優化過的版本,保留本地下載狀態)
|
||||
messageDao.insertOrUpdateListPreservingLocalData(entities)
|
||||
|
||||
// 🎯 4. 關鍵:把整個 body 回傳回去(包含 nextCursor 和 hasMore)
|
||||
// 這樣 ViewModel 才能知道下一次往上滑時,要帶哪一個 cursor!
|
||||
Result.success(body)
|
||||
} else {
|
||||
Result.failure(Exception("Empty body"))
|
||||
}
|
||||
} else {
|
||||
Result.failure(Exception("Error code: ${response.code()}"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
//fake sending
|
||||
suspend fun sendMessageOptimistically(
|
||||
roomId: String,
|
||||
currentUserId: String,
|
||||
content: String,
|
||||
replyToId: String? = null
|
||||
): Result<Unit> {
|
||||
val tempId = UUID.randomUUID().toString()
|
||||
val currentIsoTime = java.time.Instant.now().toString()
|
||||
|
||||
val tempMessageEntity = MessageEntity(
|
||||
id = tempId,
|
||||
chatRoomId = roomId,
|
||||
senderId = currentUserId,
|
||||
content = content,
|
||||
type = "text",
|
||||
createdAt = currentIsoTime,
|
||||
isEdited = false,
|
||||
isDeleted = false,
|
||||
replyToId = replyToId,
|
||||
status = MessageStatus.SENDING, // 👈 畫面立刻轉圈圈
|
||||
reactions = emptyList()
|
||||
)
|
||||
|
||||
// 1. 先塞入暫存假資料
|
||||
messageDao.insertOrUpdate(tempMessageEntity)
|
||||
|
||||
return try {
|
||||
val requestBody = SendMessageRequest(content = content, replyToId = replyToId)
|
||||
val response = api.sendMessage(roomId, requestBody)
|
||||
|
||||
if (response.isSuccessful) {
|
||||
val body = response.body()
|
||||
if (body != null) {
|
||||
|
||||
val successEntity = MessageEntity(
|
||||
id = body.id, // 後端給的正式 ID
|
||||
chatRoomId = roomId,
|
||||
senderId = body.senderId,
|
||||
content = body.content,
|
||||
type = body.type,
|
||||
createdAt = body.createdAt,
|
||||
isEdited = body.isEdited,
|
||||
isDeleted = body.isDeleted,
|
||||
replyToId = body.replyToId,
|
||||
status = MessageStatus.SUCCESS, // 👈 狀態轉為成功
|
||||
reactions = body.reactions,
|
||||
attachment = body.attachment,
|
||||
aspectRatio = body.attachment?.let { if (it.width > 0 && it.height > 0) it.width.toFloat() / it.height.toFloat() else null }
|
||||
)
|
||||
|
||||
// 🎯 2. 【關鍵大優化】利用剛寫好的 Transaction 一口氣替換!
|
||||
// 這會讓 Room 在同一個事務內完成「刪除 tempId + 寫入 successEntity」
|
||||
// UI 監聽的 Flow 只會被觸發一次,完美絕緣任何閃爍問題!
|
||||
messageDao.replaceTempMessageWithSuccess(tempId, successEntity)
|
||||
|
||||
Result.success(Unit)
|
||||
} else {
|
||||
messageDao.insertOrUpdate(tempMessageEntity.copy(status = MessageStatus.FAILED))
|
||||
Result.failure(Exception("Body null"))
|
||||
}
|
||||
} else {
|
||||
messageDao.insertOrUpdate(tempMessageEntity.copy(status = MessageStatus.FAILED))
|
||||
Result.failure(Exception("API Error"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// 網路斷線或超時,直接把本地假資料狀態改成 FAILED,UI 就會立刻顯示驚嘆號
|
||||
messageDao.insertOrUpdate(tempMessageEntity.copy(status = MessageStatus.FAILED))
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
// 在 ChatRoomsRepository 裡面加入:
|
||||
suspend fun getChatRoom(roomId: String): Result<ChatRoom?> {
|
||||
return try {
|
||||
// 呼叫你原本就有的 fetchRooms() 獲取所有房間
|
||||
val responseResult = fetchRooms()
|
||||
|
||||
responseResult.map { roomListResponse ->
|
||||
// 從列表裡面找出 roomId 相同的那間房間
|
||||
roomListResponse.rooms.find { it.roomId == roomId }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
//mark read message
|
||||
suspend fun markAsRead(roomId: String): Result<Unit> {
|
||||
return try {
|
||||
val response = api.markAsRead(roomId)
|
||||
|
||||
if (response.isSuccessful) {
|
||||
// 204 成功時沒有 body,直接回傳 Unit 即可
|
||||
Result.success(Unit)
|
||||
} else {
|
||||
val errorMsg = response.errorBody()?.string() ?: "未知錯誤"
|
||||
Result.failure(Exception("標記已讀失敗,錯誤碼: ${response.code()}, 訊息: $errorMsg"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getLatestMessageId(roomId: String): String? {
|
||||
Log.d("ChatDebug", "💾 [Repository] 正在查詢本地最新訊息 ID... roomId: $roomId")
|
||||
return try {
|
||||
val id = messageDao.getLatestMessageId(roomId)
|
||||
Log.d("ChatDebug", "💾 [Repository] 本地最新 ID: $id")
|
||||
id
|
||||
} catch (e: Exception) {
|
||||
Log.e("ChatDebug", "❌ [Repository] 查詢最新 ID 失敗", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
//upload file
|
||||
suspend fun uploadAttachment(roomId: String, fileUri: Uri): Result<Message> {
|
||||
return try {
|
||||
val contentResolver = context.contentResolver
|
||||
var fileName = "upload_file"
|
||||
|
||||
// 1. 透過 ContentResolver 取得檔案的名稱
|
||||
contentResolver.query(fileUri, null, null, null, null)?.use { cursor ->
|
||||
val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
||||
if (nameIndex != -1 && cursor.moveToFirst()) {
|
||||
fileName = cursor.getString(nameIndex)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 取得原始 MIME 類型
|
||||
val rawMimeType = contentResolver.getType(fileUri) ?: "application/octet-stream"
|
||||
|
||||
val fileBytes: ByteArray
|
||||
val finalMimeType: String
|
||||
|
||||
// ✨【關鍵轉檔邏輯】:如果是 HEIC/HEIF,在前端直接壓成 JPG
|
||||
if (rawMimeType == "image/heic" || rawMimeType == "image/heif") {
|
||||
val inputStream = contentResolver.openInputStream(fileUri)
|
||||
?: return Result.failure(Exception("無法開啟檔案輸入流"))
|
||||
|
||||
// 利用 BitmapFactory 解碼,Android 系統會自動幫你把 HEIC 解成標準 Bitmap
|
||||
val bitmap = BitmapFactory.decodeStream(inputStream)
|
||||
inputStream.close()
|
||||
|
||||
if (bitmap != null) {
|
||||
val outputStream = ByteArrayOutputStream()
|
||||
// 壓縮成 JPEG 格式,品質設為 85-90(兼顧畫質與檔案大小)
|
||||
bitmap.compress(Bitmap.CompressFormat.JPEG, 90, outputStream)
|
||||
fileBytes = outputStream.toByteArray()
|
||||
outputStream.close()
|
||||
bitmap.recycle() // 釋放記憶體
|
||||
|
||||
finalMimeType = "image/jpeg"
|
||||
|
||||
// 修改副檔名為 .jpg,讓後端正確識別儲存
|
||||
val dotIndex = fileName.lastIndexOf(".")
|
||||
fileName = if (dotIndex != -1) {
|
||||
"${fileName.substring(0, dotIndex)}.jpg"
|
||||
} else {
|
||||
"$fileName.jpg"
|
||||
}
|
||||
} else {
|
||||
return Result.failure(Exception("HEIC 圖片解碼失敗"))
|
||||
}
|
||||
} else {
|
||||
// 非 HEIC 檔案(普通的 JPG, PNG 等),走原本的邏輯,直接讀取原始 Byte
|
||||
val inputStream = contentResolver.openInputStream(fileUri)
|
||||
?: return Result.failure(Exception("無法開啟檔案輸入流"))
|
||||
fileBytes = inputStream.use { it.readBytes() }
|
||||
finalMimeType = rawMimeType
|
||||
}
|
||||
|
||||
// 3. 轉換為 RequestBody
|
||||
val requestBody = fileBytes.toRequestBody(finalMimeType.toMediaTypeOrNull())
|
||||
|
||||
// 4. 打包成 MultipartBody.Part (後端接收的 Key 值設定為 "file")
|
||||
val multipartBody = MultipartBody.Part.createFormData("file", fileName, requestBody)
|
||||
|
||||
// 5. 呼叫 API
|
||||
val response = api.uploadAttachment(roomId, multipartBody)
|
||||
|
||||
if (response.isSuccessful) {
|
||||
val body = response.body()
|
||||
if (body != null) {
|
||||
Result.success(body)
|
||||
} else {
|
||||
Result.failure(Exception("上傳成功但回應身體為空 (Empty response body)"))
|
||||
}
|
||||
} else {
|
||||
val errorMsg = response.errorBody()?.string() ?: "未知錯誤"
|
||||
Result.failure(Exception("上傳檔案失敗,錯誤碼: ${response.code()}, 訊息: $errorMsg"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
// 新增訊息的 Emoji 反應
|
||||
suspend fun addMessageReaction(
|
||||
roomId: String,
|
||||
messageId: String,
|
||||
emoji: String
|
||||
): Result<Unit> {
|
||||
return try {
|
||||
val response = api.addMessageReaction(roomId, messageId, emoji)
|
||||
|
||||
if (response.isSuccessful) {
|
||||
// 204 成功時沒有 body,直接回傳 Unit
|
||||
Result.success(Unit)
|
||||
} else {
|
||||
val errorMsg = response.errorBody()?.string() ?: "未知錯誤"
|
||||
Result.failure(Exception("新增反應失敗,錯誤碼: ${response.code()}, 訊息: $errorMsg"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
// 移除訊息的 Emoji 反應
|
||||
suspend fun deleteMessageReaction(
|
||||
roomId: String,
|
||||
messageId: String,
|
||||
emoji: String
|
||||
): Result<Unit> {
|
||||
|
||||
// 1. 先從本地撈出原本的訊息作為備份
|
||||
val backupEntity = try {
|
||||
messageDao.getMessageById(messageId)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
// 2. 樂觀更新:立刻在本地修改 Reaction 狀態
|
||||
if (backupEntity != null) {
|
||||
try {
|
||||
val currentReactions = backupEntity.reactions ?: emptyList()
|
||||
|
||||
// 運算修改後的 Reactions 列表
|
||||
val updatedReactions = currentReactions.map { reaction ->
|
||||
if (reaction.emoji == emoji) {
|
||||
// 將數量減 1,並把自己點擊的狀態設為 false
|
||||
reaction.copy(
|
||||
count = (reaction.count - 1).coerceAtLeast(0),
|
||||
meReacted = false
|
||||
)
|
||||
} else {
|
||||
reaction
|
||||
}
|
||||
}.filter { it.count > 0 } // 如果數量歸零,就直接從畫面上移除該 Emoji
|
||||
|
||||
// 將更新後的反應放回 Entity,並寫入資料庫觸發 UI 更新
|
||||
val updatedEntity = backupEntity.copy(reactions = updatedReactions)
|
||||
messageDao.insertOrUpdate(updatedEntity)
|
||||
} catch (e: Exception) {
|
||||
// 如果本地資料庫寫入失敗,直接回傳錯誤,不戳 API
|
||||
return Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 呼叫後端 API
|
||||
return try {
|
||||
val response = api.deleteMessageReaction(roomId, messageId, emoji)
|
||||
|
||||
if (response.isSuccessful) {
|
||||
// 真正成功!因為本地已經提早改好了,什麼都不用做
|
||||
Result.success(Unit)
|
||||
} else {
|
||||
// 後端回報失敗(例如:網路突然斷開、伺服器錯誤):把剛才備份的原始資料塞回去,讓 UI 彈回原本的數字
|
||||
backupEntity?.let { messageDao.insertOrUpdate(it) }
|
||||
val errorMsg = response.errorBody()?.string() ?: "未知錯誤"
|
||||
Result.failure(Exception("移除反應失敗,錯誤碼: ${response.code()}, 訊息: $errorMsg"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// 網路斷線或 Timeout:回滾資料庫,讓 UI 彈回來
|
||||
backupEntity?.let { messageDao.insertOrUpdate(it) }
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 刪除訊息(樂觀更新版本)
|
||||
suspend fun deleteMessage(roomId: String, messageId: String): Result<Unit> {
|
||||
// 1. 先從本地資料庫撈出原本的訊息作為備份
|
||||
val backupEntity = try {
|
||||
messageDao.getMessageById(messageId)
|
||||
} catch (e: Exception) {
|
||||
null // 如果本地查詢就失敗,雖然機率極低,但我們還是設為 null 防呆
|
||||
}
|
||||
|
||||
// 2. 樂觀更新:立刻從本地砍掉,UI 的 Flow 會在「一瞬間」同步消失,體驗極佳
|
||||
try {
|
||||
messageDao.deleteById(messageId)
|
||||
} catch (e: Exception) {
|
||||
// 如果本地 Room 刪除指令本身就失敗,直接回傳錯誤,不繼續往下戳 API
|
||||
return Result.failure(e)
|
||||
}
|
||||
|
||||
// 3. 呼叫後端 API 進行實際刪除
|
||||
return try {
|
||||
val response = api.deleteMessage(roomId, messageId)
|
||||
|
||||
if (response.isSuccessful) {
|
||||
// 真正成功了!因為本地已經提早刪除了,所以什麼都不用做,直接回傳 Unit
|
||||
Result.success(Unit)
|
||||
} else {
|
||||
// 後端回報失敗(例如:沒權限、訊息已被他人刪除):把剛才備份的資料塞回去
|
||||
backupEntity?.let { messageDao.insertOrUpdate(it) }
|
||||
|
||||
val errorMsg = response.errorBody()?.string() ?: "未知錯誤"
|
||||
Result.failure(Exception("刪除失敗,錯誤碼: ${response.code()}, 訊息: $errorMsg"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// 網路斷線、Timeout 或伺服器崩潰:一樣把備份資料塞回去,讓 UI 訊息彈回來
|
||||
backupEntity?.let { messageDao.insertOrUpdate(it) }
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
// 編輯訊息(樂觀更新版本)
|
||||
suspend fun editMessage(roomId: String, messageId: String, content: String): Result<Unit> {
|
||||
// 1. 先從本地撈出原本的訊息作為備份
|
||||
val backupEntity = try {
|
||||
messageDao.getMessageById(messageId)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
// 2. 樂觀更新:立刻在本地修改內容
|
||||
if (backupEntity != null) {
|
||||
try {
|
||||
val updatedEntity = backupEntity.copy(
|
||||
content = content,
|
||||
isEdited = true
|
||||
)
|
||||
messageDao.insertOrUpdate(updatedEntity)
|
||||
} catch (e: Exception) {
|
||||
return Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 呼叫 API
|
||||
return try {
|
||||
val response = api.editMessage(roomId, messageId, EditMessageRequest(content))
|
||||
if (response.isSuccessful && response.body()?.ok == true) {
|
||||
Result.success(Unit)
|
||||
} else {
|
||||
// 失敗則回滾
|
||||
backupEntity?.let { messageDao.insertOrUpdate(it) }
|
||||
Result.failure(Exception("編輯失敗"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// 異常則回滾
|
||||
backupEntity?.let { messageDao.insertOrUpdate(it) }
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
//download file
|
||||
private val httpClient = OkHttpClient()
|
||||
fun downloadFileFromUrlFlow(messageId: String, fileUrl: String, fileName: String): Flow<Result<Float>> = flow {
|
||||
// 🎯 1. 檢查並自動拼接完整的 Base URL
|
||||
val finalUrl = if (fileUrl.startsWith("http://") || fileUrl.startsWith("https://")) {
|
||||
fileUrl
|
||||
} else {
|
||||
// 💡 這裡請換成你專案中實際使用的後端伺服器域名或 IP
|
||||
val baseUrl = "https://tg.technologia-tw.com"
|
||||
|
||||
// 確保路徑拼接不會有多餘的斜線
|
||||
val cleanedBase = baseUrl.removeSuffix("/")
|
||||
val cleanedPath = if (fileUrl.startsWith("/")) fileUrl else "/$fileUrl"
|
||||
"$cleanedBase$cleanedPath"
|
||||
}
|
||||
|
||||
Log.d("ChatDebug", "🚀 拼接完成後的下載任務\n🔗 修正後 URL: $finalUrl\n📁 檔名: $fileName")
|
||||
|
||||
try {
|
||||
// 2. 使用修正後的完整網址發送請求
|
||||
val request = Request.Builder()
|
||||
.url(finalUrl)
|
||||
.get()
|
||||
.build()
|
||||
|
||||
val response = httpClient.newCall(request).execute()
|
||||
|
||||
if (!response.isSuccessful) {
|
||||
val errorBody = response.body?.string() ?: "無回應內容"
|
||||
emit(Result.failure(Exception("HTTP 錯誤 ${response.code}: $errorBody")))
|
||||
return@flow
|
||||
}
|
||||
|
||||
val body = response.body
|
||||
if (body == null) {
|
||||
emit(Result.failure(Exception("ResponseBody 為 null")))
|
||||
return@flow
|
||||
}
|
||||
|
||||
val totalBytes = body.contentLength()
|
||||
// 🎯 修正後的寫法:存入 app 專屬資料夾,避免被使用者手動刪除或污染 Download 資料夾
|
||||
// 如果希望使用者也能看到,可以維持 Environment.DIRECTORY_DOWNLOADS
|
||||
val targetDirectory = context.getExternalFilesDir(Environment.DIRECTORY_PICTURES) ?: context.filesDir
|
||||
val targetFile = File(targetDirectory, fileName)
|
||||
|
||||
var bytesCopied = 0L
|
||||
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
||||
|
||||
body.byteStream().use { inputStream ->
|
||||
FileOutputStream(targetFile).use { outputStream ->
|
||||
var bytesRead = inputStream.read(buffer)
|
||||
while (bytesRead != -1) {
|
||||
outputStream.write(buffer, 0, bytesRead)
|
||||
bytesCopied += bytesRead
|
||||
|
||||
if (totalBytes > 0) {
|
||||
emit(Result.success(bytesCopied.toFloat() / totalBytes.toFloat()))
|
||||
} else {
|
||||
emit(Result.success(-1f))
|
||||
}
|
||||
bytesRead = inputStream.read(buffer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 🎯 關鍵:下載成功後更新資料庫狀態
|
||||
messageDao.updateDownloadStatus(messageId, true, targetFile.absolutePath)
|
||||
Log.d("ChatDebug", "✅ 檔案下載完成且已更新資料庫: ${targetFile.absolutePath}")
|
||||
|
||||
emit(Result.success(1.0f))
|
||||
|
||||
} catch (e: Exception) {
|
||||
emit(Result.failure(e))
|
||||
}
|
||||
}.flowOn(Dispatchers.IO)
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
package com.example.login_v3.home
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
|
||||
@Composable
|
||||
fun HomeScreen(onLogout: () -> Unit, paddingValues: PaddingValues) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Button(
|
||||
onClick = onLogout
|
||||
) {
|
||||
Text( text = "Logout")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
package com.example.login_v3.home
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.compose.animation.SharedTransitionLayout
|
||||
import androidx.compose.animation.SharedTransitionScope
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation.navArgument
|
||||
import com.example.login_v3.home.setting.setting_detail_Screen
|
||||
import com.example.login_v3.home.setting.setting_list_Screen
|
||||
import com.example.login_v3.navigation.BottomBarViewModel
|
||||
import com.example.login_v3.navigation.ScreensViewModel
|
||||
|
||||
@Composable
|
||||
fun Tg_MarketPlace(){
|
||||
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
Text(text = "marketplace")
|
||||
}
|
||||
}
|
||||
|
|
@ -1,80 +0,0 @@
|
|||
package com.example.login_v3.home.Message.UI.Detail
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.example.login_v3.data.api.api_class.Attachment
|
||||
import com.example.login_v3.data.api.api_class.CallLogInfo
|
||||
import com.example.login_v3.data.api.api_class.Message
|
||||
import com.example.login_v3.data.api.api_class.Reaction
|
||||
import com.example.login_v3.data.local.entities.MessageStatus
|
||||
|
||||
@Immutable
|
||||
data class MessageUiModel(
|
||||
val id: String,
|
||||
val senderId: String,
|
||||
val content: String,
|
||||
val type: String,
|
||||
val createdAt: String,
|
||||
val isEdited: Boolean,
|
||||
val isDeleted: Boolean,
|
||||
val replyToId: String?,
|
||||
val repliedMessage: MessageUiModel?,
|
||||
val attachment: Attachment?,
|
||||
val reactions: List<Reaction>,
|
||||
val status: MessageStatus,
|
||||
val callLogInfo: CallLogInfo?,
|
||||
val isImage: Boolean,
|
||||
val isGif: Boolean,
|
||||
val isVideo: Boolean,
|
||||
val isAudio: Boolean,
|
||||
val isFile: Boolean,
|
||||
val mediaUrl: String,
|
||||
val thumbnailUrl: String? = null,
|
||||
val aspectRatio: Float? = null,
|
||||
val isDownloaded: Boolean = false, // 👈 新增
|
||||
val localPath: String? = null // 👈 新增
|
||||
)
|
||||
|
||||
fun Message.toUiModel(): MessageUiModel {
|
||||
val finalAspectRatio = aspectRatio ?: attachment?.let {
|
||||
if (it.width > 0 && it.height > 0) it.width.toFloat() / it.height.toFloat() else null
|
||||
}
|
||||
|
||||
val baseUrl = "https://tg.technologia-tw.com"
|
||||
val finalThumbnailUrl = attachment?.thumbnailUrl?.let { raw ->
|
||||
if (raw.startsWith("http")) raw
|
||||
else "$baseUrl${if (raw.startsWith("/")) "" else "/"}$raw"
|
||||
}
|
||||
|
||||
// 🎯 核心邏輯:如果已經下載完成,且本地路徑存在,則 mediaUrl 優先指向本地檔案
|
||||
val finalMediaUrl = if (isDownloaded && !localPath.isNullOrBlank()) {
|
||||
"file://$localPath"
|
||||
} else {
|
||||
mediaUrl
|
||||
}
|
||||
|
||||
return MessageUiModel(
|
||||
id = id,
|
||||
senderId = senderId,
|
||||
content = content,
|
||||
type = type,
|
||||
createdAt = createdAt,
|
||||
isEdited = isEdited,
|
||||
isDeleted = isDeleted,
|
||||
replyToId = replyToId,
|
||||
repliedMessage = repliedMessage?.toUiModel(),
|
||||
attachment = attachment,
|
||||
reactions = reactions ?: emptyList(),
|
||||
status = status,
|
||||
callLogInfo = callLogInfo,
|
||||
isImage = isImage,
|
||||
isGif = isGif,
|
||||
isVideo = isVideo,
|
||||
isAudio = isAudio,
|
||||
isFile = isFile,
|
||||
mediaUrl = finalMediaUrl, // 👈 使用判斷後的 URL
|
||||
thumbnailUrl = finalThumbnailUrl,
|
||||
aspectRatio = finalAspectRatio,
|
||||
isDownloaded = isDownloaded,
|
||||
localPath = localPath
|
||||
)
|
||||
}
|
||||
|
|
@ -1,533 +0,0 @@
|
|||
package com.example.login_v3.home.Message.UI.Detail.Message_Component
|
||||
|
||||
import android.content.ContentUris
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.blur
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import android.provider.MediaStore
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.core.content.ContextCompat
|
||||
import coil.compose.AsyncImage // 建議使用 Coil 來載入本地圖片 Uri,效能極佳
|
||||
import android.Manifest
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Audiotrack
|
||||
import androidx.compose.material.icons.filled.ContactPage
|
||||
import androidx.compose.material.icons.filled.Description
|
||||
import androidx.compose.material.icons.filled.Image
|
||||
import androidx.compose.material.icons.filled.LocationOn
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.VideoCall
|
||||
import androidx.compose.material3.Divider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
|
||||
|
||||
// 1. 定義目前支援的選單類型
|
||||
enum class AttachmentType(val title: String) {
|
||||
MEDIA("Media"),
|
||||
VIDEO("Video"),
|
||||
AUDIO("Audio"),
|
||||
DOCUMENT("Document"),
|
||||
LOCATION("Location"),
|
||||
CONTACT("Contact")
|
||||
}
|
||||
|
||||
// 音訊資料結構
|
||||
data class AudioItem(
|
||||
val uri: Uri,
|
||||
val displayName: String,
|
||||
val duration: Long
|
||||
)
|
||||
|
||||
// 影片資料結構
|
||||
data class VideoItem(
|
||||
val uri: Uri,
|
||||
val duration: Long
|
||||
)
|
||||
|
||||
data class AttachmentOption(
|
||||
val title: String,
|
||||
val icon: ImageVector,
|
||||
val onClick: () -> Unit
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun MessageAttachmentBar(
|
||||
onImageSelected: (Uri) -> Unit,
|
||||
onVideoSelected: (Uri) -> Unit,
|
||||
onAudioSelected: (Uri) -> Unit, // 🎯 新增:音訊選取回呼
|
||||
onCancel: () -> Unit,
|
||||
onDocumentClick: () -> Unit = {},
|
||||
onLocationClick: () -> Unit = {},
|
||||
onContactClick: () -> Unit = {}
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
// 🎯 2. 追蹤當前的選單狀態,預設為 MEDIA
|
||||
var currentType by remember { mutableStateOf(AttachmentType.MEDIA) }
|
||||
|
||||
// 資料狀態
|
||||
val localImages = remember { mutableStateListOf<Uri>() }
|
||||
val localVideos = remember { mutableStateListOf<VideoItem>() }
|
||||
val localAudios = remember { mutableStateListOf<AudioItem>() }
|
||||
|
||||
var refreshTrigger by remember { mutableStateOf(0) }
|
||||
|
||||
// 下方功能按鈕清單
|
||||
val attachmentOptions = remember {
|
||||
listOf(
|
||||
AttachmentOption("圖片", Icons.Default.Image, { currentType = AttachmentType.MEDIA }),
|
||||
AttachmentOption("影片", Icons.Default.VideoCall, { currentType = AttachmentType.VIDEO }),
|
||||
AttachmentOption("音訊", Icons.Default.Audiotrack, { currentType = AttachmentType.AUDIO }),
|
||||
AttachmentOption("文件", Icons.Default.Description, { currentType = AttachmentType.DOCUMENT; onDocumentClick() }),
|
||||
AttachmentOption("位置", Icons.Default.LocationOn, { currentType = AttachmentType.LOCATION; onLocationClick() }),
|
||||
AttachmentOption("聯絡人", Icons.Default.ContactPage, { currentType = AttachmentType.CONTACT; onContactClick() })
|
||||
)
|
||||
}
|
||||
|
||||
// Android 14+ 媒體部分權限檢查
|
||||
val isPartialAccess by remember(refreshTrigger) {
|
||||
derivedStateOf {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
val partial = ContextCompat.checkSelfPermission(context, Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED) == PackageManager.PERMISSION_GRANTED
|
||||
val imageFull = ContextCompat.checkSelfPermission(context, Manifest.permission.READ_MEDIA_IMAGES) == PackageManager.PERMISSION_GRANTED
|
||||
val videoFull = ContextCompat.checkSelfPermission(context, Manifest.permission.READ_MEDIA_VIDEO) == PackageManager.PERMISSION_GRANTED
|
||||
partial && (!imageFull || !videoFull)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val permissionLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.RequestPermission()
|
||||
) { _ -> refreshTrigger++ }
|
||||
|
||||
// 🎯 3. 讀取圖片的 Effect (保持原樣)
|
||||
LaunchedEffect(refreshTrigger) {
|
||||
val imageUris = mutableListOf<Uri>()
|
||||
val projection = arrayOf(MediaStore.Images.Media._ID, MediaStore.Images.Media.DATE_TAKEN)
|
||||
val sortOrder = "${MediaStore.Images.Media.DATE_TAKEN} DESC"
|
||||
try {
|
||||
context.contentResolver.query(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, projection, null, null, sortOrder)?.use { cursor ->
|
||||
val idColumn = cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID)
|
||||
var count = 0
|
||||
while (cursor.moveToNext() && count < 24) {
|
||||
val id = cursor.getLong(idColumn)
|
||||
imageUris.add(ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id))
|
||||
count++
|
||||
}
|
||||
}
|
||||
localImages.clear()
|
||||
localImages.addAll(imageUris)
|
||||
} catch (e: SecurityException) {
|
||||
localImages.clear()
|
||||
}
|
||||
}
|
||||
|
||||
// 🎯 新增:讀取影片的 Effect
|
||||
LaunchedEffect(refreshTrigger) {
|
||||
val videoItems = mutableListOf<VideoItem>()
|
||||
val projection = arrayOf(
|
||||
MediaStore.Video.Media._ID,
|
||||
MediaStore.Video.Media.DATE_TAKEN,
|
||||
MediaStore.Video.Media.DURATION
|
||||
)
|
||||
val sortOrder = "${MediaStore.Video.Media.DATE_TAKEN} DESC"
|
||||
try {
|
||||
context.contentResolver.query(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, projection, null, null, sortOrder)?.use { cursor ->
|
||||
val idColumn = cursor.getColumnIndexOrThrow(MediaStore.Video.Media._ID)
|
||||
val durationColumn = cursor.getColumnIndexOrThrow(MediaStore.Video.Media.DURATION)
|
||||
var count = 0
|
||||
while (cursor.moveToNext() && count < 24) {
|
||||
val id = cursor.getLong(idColumn)
|
||||
val duration = cursor.getLong(durationColumn)
|
||||
val uri = ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, id)
|
||||
videoItems.add(VideoItem(uri, duration))
|
||||
count++
|
||||
}
|
||||
}
|
||||
localVideos.clear()
|
||||
localVideos.addAll(videoItems)
|
||||
} catch (e: SecurityException) {
|
||||
localVideos.clear()
|
||||
}
|
||||
}
|
||||
|
||||
// 🎯 4. 新增:讀取音訊的 Effect (記得在 AndroidManifest 宣告 READ_EXTERNAL_STORAGE 或 READ_MEDIA_AUDIO)
|
||||
LaunchedEffect(refreshTrigger) {
|
||||
val audioItems = mutableListOf<AudioItem>()
|
||||
val projection = arrayOf(
|
||||
MediaStore.Audio.Media._ID,
|
||||
MediaStore.Audio.Media.DISPLAY_NAME,
|
||||
MediaStore.Audio.Media.DURATION
|
||||
)
|
||||
val sortOrder = "${MediaStore.Audio.Media.DATE_ADDED} DESC"
|
||||
try {
|
||||
context.contentResolver.query(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, projection, null, null, sortOrder)?.use { cursor ->
|
||||
val idColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media._ID)
|
||||
val nameColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DISPLAY_NAME)
|
||||
val durationColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DURATION)
|
||||
var count = 0
|
||||
while (cursor.moveToNext() && count < 20) {
|
||||
val id = cursor.getLong(idColumn)
|
||||
val name = cursor.getString(nameColumn) ?: "未知音訊"
|
||||
val duration = cursor.getLong(durationColumn)
|
||||
val uri = ContentUris.withAppendedId(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, id)
|
||||
audioItems.add(AudioItem(uri, name, duration))
|
||||
count++
|
||||
}
|
||||
}
|
||||
localAudios.clear()
|
||||
localAudios.addAll(audioItems)
|
||||
} catch (e: SecurityException) {
|
||||
localAudios.clear()
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.navigationBarsPadding()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
) {
|
||||
// 背景
|
||||
Box(modifier = Modifier.matchParentSize().blur(10.dp))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.matchParentSize()
|
||||
.background(MaterialTheme.colorScheme.primary, RoundedCornerShape(24.dp))
|
||||
.border(1.dp, Color.White.copy(alpha = 0.3f), RoundedCornerShape(24.dp))
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
// 頂部列:顯示動態標題
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = "Send ${currentType.title}", // 🎯 動態標題
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(24.dp)
|
||||
.background(Color.White.copy(alpha = 0.15f), CircleShape)
|
||||
.clip(CircleShape)
|
||||
.clickable { onCancel() },
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = "返回輸入",
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(14.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 中部列:使用 Crossfade 達成平滑切換內容
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(240.dp)
|
||||
) {
|
||||
Crossfade(targetState = currentType, label = "ContentSwitch") { type ->
|
||||
when (type) {
|
||||
AttachmentType.MEDIA -> {
|
||||
// 圖片網格邏輯 (維持原樣)
|
||||
if (localImages.isEmpty() && !isPartialAccess) {
|
||||
EmptyStateView("無最近媒體檔案")
|
||||
} else {
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(3),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
if (isPartialAccess) {
|
||||
item {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.aspectRatio(1f)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(Color.White.copy(alpha = 0.1f))
|
||||
.clickable {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
permissionLauncher.launch(Manifest.permission.READ_MEDIA_IMAGES)
|
||||
}
|
||||
},
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Icon(imageVector = Icons.Default.Add, contentDescription = "管理照片", tint = Color.White)
|
||||
Text("管理檔案", color = Color.White, style = MaterialTheme.typography.labelSmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
items(localImages) { uri ->
|
||||
AsyncImage(
|
||||
model = uri,
|
||||
contentDescription = "媒體預覽",
|
||||
modifier = Modifier
|
||||
.aspectRatio(1f)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.clickable { onImageSelected(uri) },
|
||||
contentScale = androidx.compose.ui.layout.ContentScale.Crop
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AttachmentType.VIDEO -> {
|
||||
// 影片網格邏輯
|
||||
if (localVideos.isEmpty() && !isPartialAccess) {
|
||||
EmptyStateView("無最近影片檔案")
|
||||
} else {
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(3),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
if (isPartialAccess) {
|
||||
item {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.aspectRatio(1f)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(Color.White.copy(alpha = 0.1f))
|
||||
.clickable {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
permissionLauncher.launch(Manifest.permission.READ_MEDIA_VIDEO)
|
||||
}
|
||||
},
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Icon(imageVector = Icons.Default.Add, contentDescription = "管理影片", tint = Color.White)
|
||||
Text("管理檔案", color = Color.White, style = MaterialTheme.typography.labelSmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
items(localVideos) { video ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.aspectRatio(1f)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.clickable { onVideoSelected(video.uri) }
|
||||
) {
|
||||
AsyncImage(
|
||||
model = video.uri,
|
||||
contentDescription = "影片預覽",
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = androidx.compose.ui.layout.ContentScale.Crop
|
||||
)
|
||||
// 影片圖示疊加
|
||||
Icon(
|
||||
imageVector = Icons.Default.PlayArrow,
|
||||
contentDescription = null,
|
||||
tint = Color.White.copy(alpha = 0.8f),
|
||||
modifier = Modifier
|
||||
.size(32.dp)
|
||||
.align(Alignment.Center)
|
||||
)
|
||||
|
||||
// 🎯 新增:顯示影片時長
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(4.dp)
|
||||
.background(Color.Black.copy(alpha = 0.6f), RoundedCornerShape(4.dp))
|
||||
.padding(horizontal = 4.dp, vertical = 2.dp)
|
||||
) {
|
||||
Text(
|
||||
text = formatDuration(video.duration),
|
||||
color = Color.White,
|
||||
style = MaterialTheme.typography.labelSmall
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AttachmentType.AUDIO -> {
|
||||
// 🎯 5. 新增:音訊渲染 UI (音訊檔名較長,改用垂直捲動清單比網格好看)
|
||||
if (localAudios.isEmpty()) {
|
||||
EmptyStateView("無最近音訊檔案")
|
||||
} else {
|
||||
androidx.compose.foundation.lazy.LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
items(localAudios) { audio ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(Color.White.copy(alpha = 0.08f))
|
||||
.clickable { onAudioSelected(audio.uri) }
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Audiotrack,
|
||||
contentDescription = null,
|
||||
tint = Color.White.copy(alpha = 0.7f)
|
||||
)
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = audio.displayName,
|
||||
color = Color.White,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text(
|
||||
text = formatDuration(audio.duration),
|
||||
color = Color.White.copy(alpha = 0.5f),
|
||||
style = MaterialTheme.typography.labelSmall
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 其他沒做完的狀態可以在這裡做對應的 Placeholder 處理
|
||||
else -> {
|
||||
EmptyStateView("${type.title} 功能尚未開放")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Divider(color = Color.White.copy(alpha = 0.15f), thickness = 1.dp)
|
||||
|
||||
// 下方選單 Icon 列
|
||||
LazyRow(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
items(attachmentOptions) { option ->
|
||||
// 🎯 6. 稍微優化:如果選中當前項目,給予特別的高亮白底
|
||||
val isSelected = option.title == when(currentType) {
|
||||
AttachmentType.MEDIA -> "圖片"
|
||||
AttachmentType.VIDEO -> "影片"
|
||||
AttachmentType.AUDIO -> "音訊"
|
||||
AttachmentType.DOCUMENT -> "文件"
|
||||
AttachmentType.LOCATION -> "位置"
|
||||
AttachmentType.CONTACT -> "聯絡人"
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.width(60.dp)
|
||||
.clickable { option.onClick() },
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(44.dp)
|
||||
.clip(CircleShape)
|
||||
.background(if (isSelected) Color.White.copy(alpha = 0.3f) else Color.White.copy(alpha = 0.12f)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = option.icon,
|
||||
contentDescription = option.title,
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun EmptyStateView(text: String) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(text = text, color = Color.White.copy(alpha = 0.5f))
|
||||
}
|
||||
}
|
||||
|
||||
// 輔助函式:將毫秒轉為 mm:ss 格式
|
||||
fun formatDuration(ms: Long): String {
|
||||
val totalSec = ms / 1000
|
||||
val min = totalSec / 60
|
||||
val sec = totalSec % 60
|
||||
return String.format("%02d:%02d", min, sec)
|
||||
}
|
||||
|
|
@ -1,104 +0,0 @@
|
|||
package com.example.login_v3.home.Message.UI.Detail.Message_Component
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Pause
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.SliderDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.example.login_v3.home.Message.UI.Detail.MessageUiModel
|
||||
import com.example.login_v3.home.Message.ViewModel.Detail.ChatViewModel
|
||||
import okhttp3.internal.concurrent.formatDuration
|
||||
|
||||
@Composable
|
||||
fun AudioMessageBubble(
|
||||
message: MessageUiModel,
|
||||
audioUrl: String,
|
||||
isMe: Boolean,
|
||||
bubbleBrush: Brush,
|
||||
bubbleAlpha: Float = 1.0f,
|
||||
viewModel: ChatViewModel // 🎯 傳入 ViewModel
|
||||
) {
|
||||
// 訂閱全域的播放狀態
|
||||
val playbackState by viewModel.audioState.collectAsState()
|
||||
|
||||
// 判斷當前這顆氣泡是不是正在被操作的音訊
|
||||
val isCurrentAudio = playbackState.currentPlayingMessageId == message.id
|
||||
val isPlaying = isCurrentAudio && playbackState.isPlaying
|
||||
|
||||
// 計算當前進度 (0.0 ~ 1.0)
|
||||
val currentPos = if (isCurrentAudio) playbackState.currentPosition else 0L
|
||||
val totalDuration = if (isCurrentAudio) playbackState.duration else 0L
|
||||
val sliderProgress = if (totalDuration > 0) currentPos.toFloat() / totalDuration.toFloat() else 0f
|
||||
|
||||
Row(modifier = Modifier
|
||||
.clip(
|
||||
RoundedCornerShape(
|
||||
topStart = 12.dp, topEnd = 12.dp,
|
||||
bottomStart = if (isMe) 12.dp else 0.dp,
|
||||
bottomEnd = if (isMe) 0.dp else 12.dp
|
||||
)
|
||||
)
|
||||
.background(brush = bubbleBrush, alpha = bubbleAlpha)
|
||||
.padding(horizontal = 8.dp, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
// 播放 / 暫停按鈕
|
||||
IconButton(
|
||||
onClick = { viewModel.toggleAudioPlayback(message.id, audioUrl) }, // 🎯 觸發全域控制
|
||||
modifier = Modifier.size(36.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
|
||||
contentDescription = if (isPlaying) "暫停" else "播放",
|
||||
tint = if (isMe) Color.White else Color.Black,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
}
|
||||
|
||||
// 進度條
|
||||
Slider(
|
||||
value = sliderProgress,
|
||||
onValueChange = { ratio ->
|
||||
if (isCurrentAudio) viewModel.seekAudioTo(ratio) // 🎯 允許使用者拖拉進度
|
||||
},
|
||||
modifier = Modifier.weight(1f),
|
||||
colors = SliderDefaults.colors(
|
||||
thumbColor = if (isMe) Color.White else MaterialTheme.colorScheme.primary,
|
||||
activeTrackColor = if (isMe) Color.White else MaterialTheme.colorScheme.primary,
|
||||
inactiveTrackColor = (if (isMe) Color.White else Color.Gray).copy(alpha = 0.3f)
|
||||
)
|
||||
)
|
||||
|
||||
// 時間格式化 (00:00)
|
||||
val displayTime = remember(currentPos, totalDuration) {
|
||||
val timeToFormat = if (currentPos > 0) currentPos else totalDuration
|
||||
formatDuration(timeToFormat)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = displayTime,
|
||||
style = MaterialTheme.typography.bodySmall.copy(fontSize = 12.sp),
|
||||
color = if (isMe) Color.White.copy(alpha = 0.8f) else Color.Gray,
|
||||
modifier = Modifier.padding(end = 4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,112 +0,0 @@
|
|||
package com.example.login_v3.home.Message.UI.Detail.Message_Component
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Call
|
||||
import androidx.compose.material.icons.filled.CallMissed
|
||||
import androidx.compose.material.icons.filled.VideoCall
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.example.login_v3.data.api.api_class.CallLogInfo
|
||||
|
||||
@Composable
|
||||
fun CallLogBubble(
|
||||
callLog: CallLogInfo,
|
||||
isMe: Boolean, // 這一則訊息是不是「我」發送的
|
||||
currentUserId: String,
|
||||
partnerDisplayName: String,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
// 🎯 改造 1:配合方案 A 的變數名稱修正 (initiator_id -> initiatorId)
|
||||
val isIInitiated = callLog.initiatorId == currentUserId
|
||||
|
||||
// 根據通話狀態決定圖示、顏色與顯示主標題
|
||||
val (icon, iconColor, titleText) = when (callLog.type) {
|
||||
"call_missed" -> {
|
||||
Triple(
|
||||
Icons.Default.CallMissed,
|
||||
Color(0xFFEC1C24), // 未接來電用紅色
|
||||
if (isIInitiated) "對方未接聽" else "未接來電"
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
// 🎯 改造 2:配合方案 A 的變數名稱修正 (has_video -> hasVideo)
|
||||
val callTypeStr = if (callLog.hasVideo) "視訊通話" else "語音通話"
|
||||
Triple(
|
||||
if (callLog.hasVideo) Icons.Default.VideoCall else Icons.Default.Call,
|
||||
if (isMe) Color(0xFF4CAF50) else Color(0xFF1976D2),
|
||||
if (isIInitiated) "撥出$callTypeStr" else "已接聽$callTypeStr"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 來電顯示副標題邏輯
|
||||
val callTypeLabel = if (callLog.hasVideo) "視訊" else "語音"
|
||||
val subText = when {
|
||||
isIInitiated -> "$callTypeLabel • 撥給 $partnerDisplayName"
|
||||
else -> "$callTypeLabel • 來自 $partnerDisplayName"
|
||||
}
|
||||
|
||||
// 🎯 改造 3:外層加上氣泡背景與剪裁,讓它看起來更像聊天氣泡
|
||||
Surface(
|
||||
modifier = modifier.padding(vertical = 4.dp),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) // 給予淡淡的灰色氣泡背景
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
// 左側:圓形背景與狀態圖示
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(38.dp)
|
||||
.background(iconColor.copy(alpha = 0.12f), shape = CircleShape),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = titleText,
|
||||
tint = iconColor,
|
||||
modifier = Modifier.size(22.dp)
|
||||
)
|
||||
}
|
||||
|
||||
// 右側:通話狀態與來電顯示文字
|
||||
Column {
|
||||
Text(
|
||||
text = titleText,
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
)
|
||||
Text(
|
||||
text = subText,
|
||||
style = MaterialTheme.typography.bodySmall.copy(
|
||||
fontSize = 12.sp,
|
||||
color = Color.Gray
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,179 +0,0 @@
|
|||
package com.example.login_v3.home.Message.UI.Detail.Message_Component
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectTransformGestures
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.layout.wrapContentSize
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import coil.compose.AsyncImage
|
||||
|
||||
@Composable
|
||||
fun ImageLightbox(
|
||||
imageUrl: String,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
Dialog(
|
||||
onDismissRequest = onDismiss,
|
||||
properties = DialogProperties(
|
||||
usePlatformDefaultWidth = false
|
||||
)
|
||||
) {
|
||||
var scale by remember { mutableStateOf(1f) }
|
||||
var offset by remember { mutableStateOf(Offset.Zero) }
|
||||
|
||||
// 💡 控制下拉選單顯示/隱藏的狀態
|
||||
var menuExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black.copy(alpha = 0.9f))
|
||||
.clickable(enabled = scale == 1f) { onDismiss() },
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
AsyncImage(
|
||||
model = imageUrl,
|
||||
contentDescription = "放大檢視圖片",
|
||||
contentScale = ContentScale.Fit,
|
||||
onLoading = { android.util.Log.d("LightboxDebug", "Loading original image: $imageUrl") },
|
||||
onSuccess = { android.util.Log.d("LightboxDebug", "Successfully loaded original image") },
|
||||
onError = { error ->
|
||||
android.util.Log.e("LightboxDebug", "Failed to load original image: ${error.result.throwable.message}")
|
||||
error.result.throwable.printStackTrace()
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight(0.85f)
|
||||
.graphicsLayer(
|
||||
scaleX = scale,
|
||||
scaleY = scale,
|
||||
translationX = offset.x,
|
||||
translationY = offset.y
|
||||
)
|
||||
.pointerInput(Unit) {
|
||||
detectTransformGestures { _, pan, zoom, _ ->
|
||||
val newScale = (scale * zoom).coerceIn(1f, 4f)
|
||||
val newOffset = if (newScale > 1f) offset + pan else Offset.Zero
|
||||
scale = newScale
|
||||
offset = newOffset
|
||||
}
|
||||
}
|
||||
.clickable(enabled = false) { }
|
||||
)
|
||||
|
||||
// 2. 上層:右上角「更多」按鈕與下拉選單容器
|
||||
// 透過 windowInsetsPadding 確保按鈕不會被手機的瀏海或狀態列擋住
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.windowInsetsPadding(WindowInsets.statusBars)
|
||||
.padding(16.dp),
|
||||
contentAlignment = Alignment.TopEnd
|
||||
) {
|
||||
// 包裹 IconButton 與 DropdownMenu 的小容器
|
||||
Box(modifier = Modifier.wrapContentSize()) {
|
||||
|
||||
|
||||
IconButton(
|
||||
onClick = { menuExpanded = true }, // 點擊開啟選單
|
||||
modifier = Modifier.background(
|
||||
color = Color.Black.copy(alpha = 0.4f), // 幫按鈕加個微黑底,避免遇到白圖時看不清
|
||||
shape = CircleShape
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.MoreVert,
|
||||
contentDescription = "更多選項",
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
|
||||
// Material 3 下拉選單
|
||||
DropdownMenu(
|
||||
expanded = menuExpanded,
|
||||
onDismissRequest = { menuExpanded = false },
|
||||
shadowElevation = 0.dp,
|
||||
containerColor = Color.White.copy(alpha = 0.3f),
|
||||
modifier = Modifier.border(0.5.dp, Color.White.copy(alpha = 0.6f))
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
// 💡 使用 Box 並填滿最大寬度,將文字完美置中
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = ("Download"),
|
||||
color = MaterialTheme.colorScheme.onBackground
|
||||
)
|
||||
}
|
||||
},
|
||||
onClick = {
|
||||
menuExpanded = false
|
||||
// TODO: 實作下載圖片到相簿的邏輯
|
||||
}
|
||||
)
|
||||
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(horizontal = 8.dp), // 左右留一點內縮,視覺上更精緻
|
||||
thickness = 0.5.dp, // 纖細的線條質感更好
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f) // 使用主題色加上低透明度
|
||||
)
|
||||
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = ("Share"),
|
||||
color = MaterialTheme.colorScheme.onBackground
|
||||
)
|
||||
}
|
||||
},
|
||||
onClick = {
|
||||
menuExpanded = false
|
||||
// TODO: 實作系統分享 Intent 邏輯
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,181 +0,0 @@
|
|||
package com.example.login_v3.home.Message.UI.Detail.Message_Component
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Reply
|
||||
import androidx.compose.material.icons.filled.Edit // 🎯 新增
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.blur
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.example.login_v3.home.Message.UI.Detail.MessageUiModel
|
||||
|
||||
@Composable
|
||||
fun MessageActionMenuRow(
|
||||
message: MessageUiModel,
|
||||
onCancel: () -> Unit,
|
||||
onReplyClick: (MessageUiModel) -> Unit, // 👈 修正:這裡只需要定義型態
|
||||
onDeleteClick: (MessageUiModel) -> Unit,
|
||||
onEditClick: (MessageUiModel) -> Unit, // 🎯 新增
|
||||
isOwnMessage: Boolean,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
// ... (rest of the function)
|
||||
// 💡 取得系統剪貼簿,用來實作「複製文字」功能
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.navigationBarsPadding() // 自動適應 Android 系統導覽列(手勢列)高度
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
) {
|
||||
|
||||
|
||||
//glass effect
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.matchParentSize()
|
||||
.blur(10.dp)
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.matchParentSize()
|
||||
.background(
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
RoundedCornerShape(16.dp)
|
||||
)
|
||||
.border(
|
||||
1.dp,
|
||||
Color.White.copy(alpha = 0.3f),
|
||||
RoundedCornerShape(16.dp)
|
||||
)
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 30.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
// 左側:取消關閉選單按鈕
|
||||
IconButton(onClick = onCancel) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = "取消選取",
|
||||
tint = Color.Gray
|
||||
)
|
||||
}
|
||||
|
||||
// 中間:各個功能按鈕
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(20.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// 1. 回覆
|
||||
ChatMenuButton(
|
||||
imageVector = Icons.Default.Reply,
|
||||
label = "回覆",
|
||||
onClick = { onReplyClick(message) }
|
||||
)
|
||||
|
||||
// 2. 複製文字
|
||||
val isImage = message.isImage
|
||||
ChatMenuButton(
|
||||
imageVector = Icons.Default.ContentCopy,
|
||||
label = "複製",
|
||||
enabled = !isImage,
|
||||
onClick = {
|
||||
val text = message.content
|
||||
clipboardManager.setText(AnnotatedString(text))
|
||||
onCancel()
|
||||
}
|
||||
)
|
||||
|
||||
// 3. 編輯:只有在自己的文字訊息時顯示 🎯
|
||||
if (isOwnMessage && !isImage) {
|
||||
ChatMenuButton(
|
||||
imageVector = Icons.Default.Edit,
|
||||
label = "編輯",
|
||||
onClick = { onEditClick(message) }
|
||||
)
|
||||
}
|
||||
|
||||
// 4. 刪除:只有在自己的訊息時才顯示 👈
|
||||
if (isOwnMessage) {
|
||||
ChatMenuButton(
|
||||
imageVector = Icons.Default.Delete,
|
||||
label = "刪除",
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
onClick = { onDeleteClick(message) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 為了讓中間的按鈕群置中,右邊放一個等寬的 Spacer
|
||||
Spacer(modifier = Modifier.size(48.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 💡 記得一併附上這個按鈕元件,它也是放在同一個檔案底部
|
||||
@Composable
|
||||
fun ChatMenuButton(
|
||||
imageVector: ImageVector,
|
||||
label: String,
|
||||
enabled: Boolean = true,
|
||||
tint: Color = Color.White, // 💡 將這裡的 Color(0xFFDA7029) 改成 Color.White
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val contentColor = if (enabled) tint else Color.LightGray.copy(alpha = 0.5f)
|
||||
// 💡 既然圖標變白了,文字建議也同步改成白色(或帶點透明度的白),在深色背景上才看得清楚
|
||||
val textColor = if (enabled) Color.White else Color.LightGray.copy(alpha = 0.5f)
|
||||
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.clickable(enabled = enabled) { onClick() }
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = imageVector,
|
||||
contentDescription = label,
|
||||
tint = contentColor,
|
||||
modifier = Modifier.size(32.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = textColor
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,271 +0,0 @@
|
|||
package com.example.login_v3.home.Message.UI.Detail.Message_Component
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.input.TextFieldState
|
||||
import androidx.compose.foundation.text.input.rememberTextFieldState
|
||||
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
|
||||
import androidx.compose.foundation.text.input.clearText
|
||||
import androidx.compose.foundation.content.contentReceiver
|
||||
import androidx.compose.foundation.content.MediaType
|
||||
import androidx.compose.foundation.content.hasMediaType
|
||||
import androidx.compose.foundation.content.consume
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.AddCircle
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.Photo
|
||||
import androidx.compose.material.icons.filled.Send
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.blur
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.example.login_v3.home.Message.UI.Detail.MessageUiModel
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun MessageInputBar(
|
||||
isLoading: Boolean,
|
||||
replyingMessage: MessageUiModel?,
|
||||
editingMessage: MessageUiModel? = null,
|
||||
onCancelReply: () -> Unit,
|
||||
onCancelEdit: () -> Unit = {},
|
||||
onSendClick: (String) -> Unit,
|
||||
onEditSaveClick: (String) -> Unit = {},
|
||||
onAttachmentClick: () -> Unit,
|
||||
onMediaReceived: (Uri) -> Unit = {}
|
||||
) {
|
||||
val textState = rememberTextFieldState()
|
||||
|
||||
LaunchedEffect(editingMessage) {
|
||||
if (editingMessage != null) {
|
||||
textState.setTextAndPlaceCursorAtEnd(editingMessage.content)
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.navigationBarsPadding()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
.background(
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
RoundedCornerShape(16.dp)
|
||||
)
|
||||
.border(
|
||||
1.dp,
|
||||
Color.White.copy(alpha = 0.3f),
|
||||
RoundedCornerShape(16.dp)
|
||||
)
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
AnimatedVisibility(visible = replyingMessage != null) {
|
||||
if (replyingMessage != null) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 8.dp, end = 8.dp, top = 8.dp)
|
||||
.background(
|
||||
color = Color.White.copy(alpha = 0.92f),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
)
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(3.dp)
|
||||
.height(16.dp)
|
||||
.background(Color(0xFF4CAF50), RoundedCornerShape(2.dp))
|
||||
)
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = replyingMessage.content,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = Color.Gray,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onCancelReply, modifier = Modifier.size(24.dp)) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = "取消回覆",
|
||||
tint = Color.Gray.copy(alpha = 0.7f),
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AnimatedVisibility(visible = editingMessage != null) {
|
||||
if (editingMessage != null) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 8.dp, end = 8.dp, top = 8.dp)
|
||||
.background(
|
||||
color = Color.White.copy(alpha = 0.92f),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
)
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(3.dp)
|
||||
.height(16.dp)
|
||||
.background(Color(0xFFDA7029), RoundedCornerShape(2.dp))
|
||||
)
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
Icon(
|
||||
imageVector = Icons.Default.Edit,
|
||||
contentDescription = null,
|
||||
tint = Color(0xFFDA7029),
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = "正在編輯訊息",
|
||||
style = MaterialTheme.typography.labelMedium.copy(
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color(0xFFDA7029)
|
||||
),
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
IconButton(
|
||||
onClick = {
|
||||
onCancelEdit()
|
||||
textState.clearText()
|
||||
},
|
||||
modifier = Modifier.size(24.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = "取消編輯",
|
||||
tint = Color.Gray.copy(alpha = 0.7f),
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
IconButton(onClick = { onAttachmentClick() }) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.AddCircle,
|
||||
contentDescription = "attachment bar",
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
|
||||
BasicTextField(
|
||||
state = textState,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(horizontal = 8.dp)
|
||||
.contentReceiver { transferableContent ->
|
||||
if (transferableContent.hasMediaType(MediaType.Image)) {
|
||||
transferableContent.consume { item ->
|
||||
val uri = item.uri
|
||||
if (uri != null) {
|
||||
onMediaReceived(uri)
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
} else {
|
||||
transferableContent
|
||||
}
|
||||
},
|
||||
textStyle = MaterialTheme.typography.bodyLarge.copy(color = Color.White),
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Text,
|
||||
autoCorrectEnabled = true,
|
||||
imeAction = ImeAction.Default
|
||||
),
|
||||
decorator = { innerTextField ->
|
||||
if (textState.text.isEmpty()) {
|
||||
Text(
|
||||
text = if (editingMessage != null) "編輯訊息..." else "請輸入訊息...",
|
||||
color = Color.White.copy(alpha = 0.6f)
|
||||
)
|
||||
}
|
||||
innerTextField()
|
||||
}
|
||||
)
|
||||
|
||||
IconButton(
|
||||
onClick = {
|
||||
val content = textState.text.toString()
|
||||
if (content.isNotBlank() && !isLoading) {
|
||||
if (editingMessage != null) {
|
||||
onEditSaveClick(content)
|
||||
} else {
|
||||
onSendClick(content)
|
||||
}
|
||||
textState.clearText()
|
||||
}
|
||||
},
|
||||
enabled = textState.text.isNotBlank() && !isLoading
|
||||
) {
|
||||
if (isLoading) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(24.dp), strokeWidth = 2.dp)
|
||||
} else {
|
||||
val iconColor = if (textState.text.isNotBlank()) Color(0xFFDA7029) else Color.LightGray
|
||||
val icon = if (editingMessage != null) Icons.Default.Check else Icons.Default.Send
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = if (editingMessage != null) "儲存編輯" else "送出",
|
||||
tint = iconColor
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,186 +0,0 @@
|
|||
package com.example.login_v3.home.Message.UI.Detail.Message_Component
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.blur
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
object EmojiProvider {
|
||||
fun getFullEmojiPack(): List<String> {
|
||||
val emojis = mutableListOf<String>()
|
||||
|
||||
// 1. 經典表情與人類行為 (Smiley Faces & People)
|
||||
// 包含:😀 🤣 😘 🤠 🧑⚕️ 🏃 等
|
||||
for (i in 0x1F600..0x1F64F) {
|
||||
addCodePointIfValid(i, emojis)
|
||||
}
|
||||
|
||||
// 2. 身體部位、手勢與愛心 (Body, Gestures & Hearts)
|
||||
// 包含:👋 👍 👏 💪 👁️ ❤️ 💔 等
|
||||
for (i in 0x1F440..0x1F49F) {
|
||||
addCodePointIfValid(i, emojis)
|
||||
}
|
||||
|
||||
// 3. 額外追加的表情與角色 (Extended Smileys & Symbols)
|
||||
// 包含:🥰 🤩 🥳 🥵 🥶 🥺 等較新版表情
|
||||
for (i in 0x1F900..0x1F9FF) {
|
||||
addCodePointIfValid(i, emojis)
|
||||
}
|
||||
|
||||
// 4. 動物與大自然 (Animals & Nature)
|
||||
// 包含:🐶 🐱 🦁 🦊 🐵 🦅 🌲 🌸 🌞 等
|
||||
for (i in 0x1F400..0x1F43F) { // 常見動物頭像
|
||||
addCodePointIfValid(i, emojis)
|
||||
}
|
||||
for (i in 0x1F1E6..0x1F1FF) { // 補充大自然元素
|
||||
addCodePointIfValid(i, emojis)
|
||||
}
|
||||
|
||||
// 5. 食物與飲料 (Food & Drink)
|
||||
// 包含:🍏 🍓 🍔 🍕 🍣 🍦 🍺 ☕️ 等
|
||||
for (i in 0x1F354..0x1F37F) {
|
||||
addCodePointIfValid(i, emojis)
|
||||
}
|
||||
|
||||
// 6. 運動、娛樂與活動 (Activities & Sports)
|
||||
// 包含:⚽️ 🏀 🏈 🏆 🎮 🎤 🎬 🎨 等
|
||||
for (i in 0x1F3A0..0x1F3C4) {
|
||||
addCodePointIfValid(i, emojis)
|
||||
}
|
||||
|
||||
// 7. 旅遊、交通工具與建築 (Travel & Places)
|
||||
// 包含:🚗 ✈️ 🚀 🌋 🏖️ 🏥 🇨🇳 等
|
||||
for (i in 0x1F680..0x1F6FF) {
|
||||
addCodePointIfValid(i, emojis)
|
||||
}
|
||||
|
||||
// 8. 物品、工具與雜項 (Objects & Tools)
|
||||
// 包含:💻 📱 💡 🔑 🔨 📚 📦 💵 等
|
||||
for (i in 0x1F4A0..0x1F4FF) {
|
||||
addCodePointIfValid(i, emojis)
|
||||
}
|
||||
|
||||
return emojis
|
||||
}
|
||||
|
||||
/**
|
||||
* 輔助函式:檢查該 Unicode 編碼在當前系統是否有效,若有效則轉成 String 存入
|
||||
*/
|
||||
private fun addCodePointIfValid(codePoint: Int, list: MutableList<String>) {
|
||||
if (Character.isValidCodePoint(codePoint)) {
|
||||
list.add(String(Character.toChars(codePoint)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MessageEmojiBar(
|
||||
onEmojiClick: (String) -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
// 💡 修正 1:改為呼叫你實際定義的 getFullEmojiPack()
|
||||
val emojiList = remember { EmojiProvider.getFullEmojiPack() }
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(340.dp)
|
||||
.padding(horizontal = 6.dp, vertical = 18.dp)
|
||||
){
|
||||
|
||||
//glass effect
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.matchParentSize()
|
||||
.blur(10.dp)
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.matchParentSize()
|
||||
.background(
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
RoundedCornerShape(16.dp)
|
||||
)
|
||||
.border(
|
||||
1.dp,
|
||||
Color.White.copy(alpha = 0.3f),
|
||||
RoundedCornerShape(16.dp)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.navigationBarsPadding()
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp)
|
||||
) {
|
||||
// 頂部功能列 (標題與叉叉)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(text = "傳送回應", fontSize = 14.sp, fontWeight = FontWeight.Bold, color = Color.Gray)
|
||||
Box(
|
||||
modifier = Modifier.clip(CircleShape).clickable { onCancel() }.padding(8.dp)
|
||||
) {
|
||||
Text(text = "✕", color = Color.Gray, fontSize = 16.sp)
|
||||
}
|
||||
}
|
||||
|
||||
// 網格滾動區
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(6),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
// 💡 修正 2:明確指定 emoji 的型態為 String
|
||||
// 這樣可以強迫編譯器去認對應的 items 擴充函式,徹底解決 Text(text = emoji) 報 Int 的錯誤
|
||||
items(emojiList) { emoji: String ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.aspectRatio(1f)
|
||||
.clip(CircleShape)
|
||||
.clickable { onEmojiClick(emoji) },
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = emoji,
|
||||
fontSize = 28.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
package com.example.login_v3.home.Message.UI.Detail.Message_Component
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.example.login_v3.data.api.api_class.Reaction
|
||||
import kotlin.collections.forEach
|
||||
|
||||
@Composable
|
||||
fun ReactionRow(
|
||||
reactions: List<Reaction>,
|
||||
onReactionClick: (String) -> Unit
|
||||
) {
|
||||
// 使用 FlowRow,如果貼圖太多會自動折行
|
||||
OptIn(ExperimentalLayoutApi::class)
|
||||
FlowRow(
|
||||
modifier = Modifier.padding(top = 4.dp, start = 4.dp, end = 4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
reactions.forEach { reaction ->
|
||||
if (reaction.count > 0) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
// 如果我自己有按讚,背景變稍微深色/藍色點綴,否則為淡灰色
|
||||
if (reaction.meReacted) Color(0xFFBBDEFB) else Color(0xFFEEEEEE)
|
||||
)
|
||||
.clickable { onReactionClick(reaction.emoji) }
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Text(text = reaction.emoji, fontSize = 14.sp)
|
||||
Text(
|
||||
text = reaction.count.toString(),
|
||||
style = MaterialTheme.typography.bodySmall.copy(fontWeight = FontWeight.Bold),
|
||||
color = if (reaction.meReacted) Color(0xFF1976D2) else Color.DarkGray
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
package com.example.login_v3.home.Message.UI.Detail.Message_Component
|
||||
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.composed
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
|
||||
fun Modifier.shimmer(visible: Boolean = true): Modifier = composed {
|
||||
if (!visible) return@composed Modifier
|
||||
|
||||
var size by remember { mutableStateOf(IntSize.Zero) }
|
||||
val transition = rememberInfiniteTransition(label = "shimmer")
|
||||
val startOffsetX by transition.animateFloat(
|
||||
initialValue = -2 * size.width.toFloat(),
|
||||
targetValue = 2 * size.width.toFloat(),
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(1200, easing = LinearEasing),
|
||||
repeatMode = RepeatMode.Restart
|
||||
),
|
||||
label = "shimmer"
|
||||
)
|
||||
|
||||
background(
|
||||
brush = Brush.linearGradient(
|
||||
colors = listOf(
|
||||
Color(0xFFB8B5B5),
|
||||
Color(0xFF8F8B8B),
|
||||
Color(0xFFB8B5B5),
|
||||
),
|
||||
start = Offset(startOffsetX, 0f),
|
||||
end = Offset(startOffsetX + size.width.toFloat(), size.height.toFloat())
|
||||
)
|
||||
).onGloballyPositioned {
|
||||
size = it.size
|
||||
}
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
package com.example.login_v3.home.Message.UI.Detail.Message_Component
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import coil.compose.AsyncImage
|
||||
import com.example.login_v3.R
|
||||
|
||||
@Composable
|
||||
fun UserAvatar(
|
||||
avatarUrl: Any?, // ✨ 改成 Any?
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
AsyncImage(
|
||||
model = avatarUrl ?: R.drawable.avatar_v1, // 如果為 null,就用預設圖
|
||||
contentDescription = "用戶頭像",
|
||||
modifier = modifier
|
||||
.background(Color.LightGray, shape = CircleShape)
|
||||
.clip(CircleShape),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
}
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
package com.example.login_v3.home.Message.UI.Detail.Message_Component
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.example.login_v3.home.Message.ViewModel.UserStatus
|
||||
|
||||
@Composable
|
||||
fun UserStatusDot(
|
||||
status: UserStatus,
|
||||
modifier: Modifier = Modifier,
|
||||
size: Dp = 14.dp // 預設 14.dp,但也允許外部微調大小
|
||||
) {
|
||||
if (status != UserStatus.UNKNOWN) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.size(size)
|
||||
.background(status.color, CircleShape) // 圓形背景
|
||||
.border(2.dp, Color.White, CircleShape) // 白色外圈邊線
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,71 +0,0 @@
|
|||
package com.example.login_v3.home.Message.UI.Detail.Message_Component
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.ui.PlayerView
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
@Composable
|
||||
fun VideoLightbox(
|
||||
videoUrl: String,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
BackHandler(enabled = true) {
|
||||
onDismiss()
|
||||
}
|
||||
|
||||
val context = LocalContext.current
|
||||
|
||||
// 初始化 ExoPlayer
|
||||
val exoPlayer = remember {
|
||||
ExoPlayer.Builder(context).build().apply {
|
||||
val mediaItem = MediaItem.fromUri(videoUrl)
|
||||
setMediaItem(mediaItem)
|
||||
prepare()
|
||||
playWhenReady = true // 自動播放
|
||||
}
|
||||
}
|
||||
|
||||
// 當 Composable 銷毀時,一定要釋放播放器資源以免記憶體流失
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
exoPlayer.release()
|
||||
}
|
||||
}
|
||||
|
||||
// 全螢幕黑色背景黑燈箱
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black.copy(alpha = 0.95f))
|
||||
.clickable { onDismiss() }, // 點擊背景可以關閉
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
AndroidView(
|
||||
factory = { ctx ->
|
||||
PlayerView(ctx).apply {
|
||||
player = exoPlayer
|
||||
useController = true // 顯示預設的播放、暫停、進度條控制面板
|
||||
// 點擊播放器本體時不要觸發外層的關閉事件
|
||||
setOnClickListener { /* 阻止點擊事件穿透 */ }
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,23 +0,0 @@
|
|||
package com.example.login_v3.home.Message.UI.Detail
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.navigation.NavController
|
||||
|
||||
@Composable
|
||||
fun Message_add_contact(
|
||||
navController: NavController
|
||||
){
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(text = "add contact")
|
||||
}
|
||||
}
|
||||
|
|
@ -1,524 +0,0 @@
|
|||
package com.example.login_v3.home.Message.UI.Detail
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Chat
|
||||
import androidx.compose.material.icons.filled.Call
|
||||
import androidx.compose.material.icons.filled.Chat
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.ExpandLess
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.material.icons.filled.MoreHoriz
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
import androidx.compose.material.icons.filled.PersonAdd
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.VideoCall
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Divider
|
||||
import androidx.compose.material3.FilledTonalIconButton
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.IconButtonDefaults
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.NavController
|
||||
import coil.compose.AsyncImage
|
||||
import coil.request.ImageRequest
|
||||
import com.example.login_v3.R
|
||||
import com.example.login_v3.data.api.api_class.Friend
|
||||
import com.example.login_v3.data.api.api_class.PendingFriendApiModel
|
||||
import com.example.login_v3.data.api.api_class.UserDetail
|
||||
import com.example.login_v3.data.api.api_class.fullFriendsAvatarUrl
|
||||
import com.example.login_v3.data.api.api_class.fullPendingAvatarUrl
|
||||
import com.example.login_v3.home.Message.ViewModel.Detail.ContactListViewModel
|
||||
import com.example.login_v3.navigation.BottomBarViewModel
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import com.example.login_v3.data.api.api_class.fullSearchedAvatarUrl
|
||||
import com.example.login_v3.home.Message.UI.Detail.Message_Component.UserStatusDot
|
||||
import com.example.login_v3.home.Message.ViewModel.UserStatus
|
||||
|
||||
|
||||
@Composable
|
||||
fun Message_contact_list(
|
||||
navController: NavController,
|
||||
contactListViewModel: ContactListViewModel = hiltViewModel(),
|
||||
bottomBarViewModel: BottomBarViewModel
|
||||
) {
|
||||
// 統一觀察 uiState 即可,避免觀察多個 Flow 導致狀態不同步
|
||||
val uiState by contactListViewModel.uiState.collectAsStateWithLifecycle()
|
||||
// 判斷目前是否處於「搜尋模式」
|
||||
val friends = uiState.friends // 直接從 uiState 裡面取
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
bottomBarViewModel.setVisible(false)
|
||||
onDispose { bottomBarViewModel.setVisible(true) }
|
||||
}
|
||||
|
||||
// 使用單一 LazyColumn 處理所有內容
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 6.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
|
||||
// 頂部間距
|
||||
item { Spacer(modifier = Modifier.height(60.dp)) }
|
||||
|
||||
// --- 搜尋框 ---
|
||||
item {
|
||||
UserSearchBar(
|
||||
query = uiState.searchQuery,
|
||||
onQueryChange = { contactListViewModel.onSearchQueryChanged(it) },
|
||||
onSearch = { contactListViewModel.performSearch() },
|
||||
isLoading = uiState.isLoading
|
||||
)
|
||||
}
|
||||
|
||||
//is Searching or not
|
||||
if (uiState.isSearching) {
|
||||
|
||||
// --- 搜尋結果區塊 (僅在有結果時顯示) ---
|
||||
if (uiState.searchResults.isNotEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
"搜尋結果",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.fillMaxWidth().padding(8.dp),
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
items(uiState.searchResults) { user ->
|
||||
// 這裡你可以自定義搜尋結果的 Row
|
||||
SearchResultRow(
|
||||
user = user,
|
||||
onAddFriend = { contactListViewModel.sendFriendRequest(user.id) }
|
||||
)
|
||||
}
|
||||
item { Spacer(modifier = Modifier.height(60.dp)) }
|
||||
}
|
||||
|
||||
|
||||
//if not searching then display friends list
|
||||
}else {
|
||||
// --- 區塊一:待處理好友 (只有在有資料時才顯示) ---
|
||||
if (uiState.pendingRequests.isNotEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
"好友請求 (${uiState.pendingRequests.size})",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.fillMaxWidth().padding(8.dp)
|
||||
)
|
||||
}
|
||||
items(uiState.pendingRequests) { request ->
|
||||
PendingFriendRow(
|
||||
request,
|
||||
isLoading = uiState.isLoading,
|
||||
onAccept = { _ ->
|
||||
request.fromUserId?.let { id -> contactListViewModel.acceptFriend(id) }
|
||||
},
|
||||
onReject = { _ ->
|
||||
request.fromUserId?.let { id -> contactListViewModel.rejectFriend(id) }
|
||||
}
|
||||
)
|
||||
}
|
||||
item { HorizontalDivider(modifier = Modifier.padding(vertical = 12.dp)) }
|
||||
}
|
||||
|
||||
// --- 區塊二:正式好友標題 ---
|
||||
item {
|
||||
Text(
|
||||
"所有好友",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.fillMaxWidth().padding(8.dp)
|
||||
)
|
||||
}
|
||||
|
||||
// --- 區塊三:正式好友列表內容 ---
|
||||
if (uiState.friends.isEmpty()) {
|
||||
item {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillParentMaxHeight(0.5f) // 佔據剩餘空間的一部分
|
||||
.fillMaxWidth(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (uiState.isLoading) {
|
||||
CircularProgressIndicator()
|
||||
} else {
|
||||
Text("目前沒有好友")
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
items(uiState.friends) { friends ->
|
||||
FriendRow(friends)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
//friends list
|
||||
@Composable
|
||||
fun FriendRow(
|
||||
friend: Friend,
|
||||
onClick: () -> Unit = {} // 建議加入點擊事件,例如跳轉聊天室
|
||||
) {
|
||||
// 1. 定義展開狀態
|
||||
var isExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp, horizontal = 2.dp) // 外間距
|
||||
.animateContentSize(
|
||||
animationSpec = spring(
|
||||
dampingRatio = Spring.DampingRatioLowBouncy,
|
||||
stiffness = Spring.StiffnessLow
|
||||
)
|
||||
)
|
||||
.clickable {
|
||||
isExpanded = !isExpanded // 點擊切換展開/縮合
|
||||
// 如果原本的 onClick 還有其他用途(如跳轉)可以保留
|
||||
// onClick()
|
||||
}
|
||||
,
|
||||
shape = RoundedCornerShape(12.dp), // 圓角
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.primary // 使用 Surface 顏色
|
||||
),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
|
||||
) {
|
||||
|
||||
Column() {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(16.dp) // 內邊距
|
||||
.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// 1. 大頭貼 (左側)
|
||||
Box(contentAlignment = Alignment.BottomEnd) {
|
||||
AsyncImage(
|
||||
model = ImageRequest.Builder(LocalContext.current)
|
||||
.data(friend.fullFriendsAvatarUrl)
|
||||
.crossfade(true)
|
||||
.build(),
|
||||
contentDescription = "Avatar of ${friend.displayName}",
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.size(52.dp) // 稍微加大一點
|
||||
.clip(CircleShape)
|
||||
.border(1.dp, MaterialTheme.colorScheme.outlineVariant, CircleShape), // 加入細邊框
|
||||
placeholder = painterResource(id = R.drawable.avatar_v1),
|
||||
error = painterResource(id = R.drawable.avatar_v1)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
|
||||
// 2. 文字資訊 (中間)
|
||||
Column(
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text(
|
||||
text = friend.displayName ?: "未知用戶",
|
||||
style = MaterialTheme.typography.titleMedium.copy(
|
||||
fontWeight = FontWeight.Bold,
|
||||
letterSpacing = 0.5.sp
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
|
||||
Text(
|
||||
text = "@${friend.username ?: "unknown"}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
|
||||
UserStatusDot(
|
||||
status = UserStatus.fromString(friend.status)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
}
|
||||
|
||||
// 3. 展開後才顯示的五個內容按鈕
|
||||
if (isExpanded) {
|
||||
Divider(
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
thickness = 0.5.dp,
|
||||
color = MaterialTheme.colorScheme.outlineVariant
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly // 均勻分佈
|
||||
) {
|
||||
val icons = listOf(
|
||||
Icons.Default.Chat,
|
||||
Icons.Default.Call,
|
||||
Icons.Default.VideoCall,
|
||||
Icons.Default.Person,
|
||||
Icons.Default.MoreHoriz
|
||||
)
|
||||
|
||||
icons.forEach { icon ->
|
||||
IconButton(onClick = { /* 執行功能 */ }) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PendingFriendRow(
|
||||
request: PendingFriendApiModel,
|
||||
isLoading: Boolean,
|
||||
onAccept: (String) -> Unit,
|
||||
onReject: (String) -> Unit
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp, horizontal = 2.dp)
|
||||
,
|
||||
shape = RoundedCornerShape(12.dp), // 圓角
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.primary // 使用 Surface 顏色
|
||||
),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
|
||||
) {
|
||||
ListItem(
|
||||
colors = ListItemDefaults.colors(
|
||||
containerColor = Color.Transparent // 讓底層 Card 的顏色透出來
|
||||
),
|
||||
modifier = Modifier.padding(vertical = 4.dp),
|
||||
headlineContent = {
|
||||
Text(
|
||||
text = request.displayName ?: "未知用戶",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
},
|
||||
supportingContent = {
|
||||
Text(
|
||||
text = "想加你為好友",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
},
|
||||
leadingContent = {
|
||||
Surface(
|
||||
shape = CircleShape,
|
||||
tonalElevation = 2.dp
|
||||
) {
|
||||
AsyncImage(
|
||||
model = request.fullPendingAvatarUrl,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.clip(CircleShape),
|
||||
contentScale = ContentScale.Crop,
|
||||
error = painterResource(id = R.drawable.avatar_v1)
|
||||
)
|
||||
}
|
||||
},
|
||||
trailingContent = {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// 拒絕按鈕 (圓形、低調)
|
||||
FilledTonalIconButton(
|
||||
onClick = { request.friendshipId?.let { onReject(it) } },
|
||||
enabled = !isLoading,
|
||||
colors = IconButtonDefaults.filledTonalIconButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.8f),
|
||||
contentColor = MaterialTheme.colorScheme.error
|
||||
)
|
||||
) {
|
||||
Icon(Icons.Default.Close, contentDescription = "拒絕", modifier = Modifier.size(20.dp))
|
||||
}
|
||||
|
||||
// 接受按鈕 (綠色系)
|
||||
FilledTonalIconButton(
|
||||
onClick = { request.friendshipId?.let { onAccept(it) } },
|
||||
enabled = !isLoading,
|
||||
colors = IconButtonDefaults.filledTonalIconButtonColors(
|
||||
// containerColor: 背景色 (淺綠)
|
||||
containerColor = Color(0xFFE8F5E9),
|
||||
// contentColor: 圖示與進度條顏色 (深綠)
|
||||
contentColor = Color(0xFF2E7D32)
|
||||
)
|
||||
) {
|
||||
if (isLoading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(20.dp),
|
||||
color = Color(0xFF2E7D32), // 這裡也要同步改為深綠色
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Check,
|
||||
contentDescription = "接受",
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
//search user
|
||||
@Composable
|
||||
fun UserSearchBar(
|
||||
query: String,
|
||||
onQueryChange: (String) -> Unit,
|
||||
onSearch: () -> Unit,
|
||||
isLoading: Boolean
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = query,
|
||||
onValueChange = onQueryChange,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(8.dp),
|
||||
placeholder = { Text("搜尋使用者 ID 或名稱...") },
|
||||
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
|
||||
trailingIcon = {
|
||||
if (isLoading) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(24.dp), strokeWidth = 2.dp)
|
||||
}
|
||||
},
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
||||
keyboardActions = KeyboardActions(onSearch = { onSearch() }),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
)
|
||||
}
|
||||
@Composable
|
||||
fun SearchResultRow(
|
||||
user: UserDetail,
|
||||
onAddFriend: () -> Unit
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// 這裡放頭像
|
||||
AsyncImage(
|
||||
model = user.fullSearchedAvatarUrl,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(40.dp).clip(CircleShape)
|
||||
)
|
||||
|
||||
Column(modifier = Modifier.weight(1f).padding(horizontal = 12.dp)) {
|
||||
Text(user.displayName, style = MaterialTheme.typography.bodyLarge)
|
||||
Text("@${user.username}", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
|
||||
// 判斷是否顯示勾勾
|
||||
if (user.isRequestSent) {
|
||||
// 已發送狀態:綠色勾勾,且按鈕不具備點擊功能
|
||||
Icon(
|
||||
imageVector = Icons.Default.Check,
|
||||
contentDescription = "已發送",
|
||||
tint = Color(0xFF4CAF50), // 綠色
|
||||
modifier = Modifier.padding(12.dp)
|
||||
)
|
||||
} else {
|
||||
// 尚未發送:加好友按鈕
|
||||
IconButton(onClick = onAddFriend) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.PersonAdd,
|
||||
contentDescription = "加好友"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,321 +0,0 @@
|
|||
package com.example.login_v3.home.Message.UI.Detail
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibilityScope
|
||||
import androidx.compose.animation.SharedTransitionScope
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Create
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
import androidx.compose.material.icons.filled.QrCode
|
||||
import androidx.compose.material3.Badge
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.MenuDefaults
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.blur
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.navigation.NavController
|
||||
import coil.compose.AsyncImage
|
||||
import com.example.login_v3.data.api.api_class.ChatRoom
|
||||
import com.example.login_v3.data.api.api_class.fullContactAvatarUrl
|
||||
import com.example.login_v3.home.Message.UI.Detail.Message_Component.UserStatusDot
|
||||
import com.example.login_v3.home.Message.UI.Screen
|
||||
import com.example.login_v3.home.Message.ViewModel.ChatRoomsViewModel
|
||||
import com.example.login_v3.home.Message.ViewModel.UserStatus
|
||||
|
||||
val ChatRoom?.displayTitle: String
|
||||
get() {
|
||||
if (this == null) return "未知聊天室"
|
||||
return when (roomType) {
|
||||
"direct", "dm" -> partner?.displayName ?: partner?.username ?: "未知使用者"
|
||||
else -> roomName ?: "未命名群組"
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun Loaded_Tg_Message(
|
||||
navController: NavController,
|
||||
viewModel: ChatRoomsViewModel = hiltViewModel(),
|
||||
sharedTransitionScope: SharedTransitionScope,
|
||||
animatedVisibilityScope: AnimatedVisibilityScope,
|
||||
onRoomClick: (String, String) -> Unit = { _, _ -> }
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
|
||||
// 觀察 ViewModel 的 StateFlow
|
||||
val rooms by viewModel.roomsState.collectAsState()
|
||||
|
||||
Scaffold(
|
||||
containerColor = Color.Transparent,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Messages") },
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = Color.Transparent,
|
||||
titleContentColor = MaterialTheme.colorScheme.onBackground
|
||||
),
|
||||
actions = {
|
||||
// 2. 使用 Box 包裹按鈕與選單,確保選單彈出位置正確
|
||||
Box {
|
||||
IconButton(onClick = { expanded = true }) {
|
||||
Icon(Icons.Default.Add, contentDescription = "更多選項")
|
||||
}
|
||||
|
||||
// 3. 下拉選單組件
|
||||
DropdownMenu(
|
||||
expanded = expanded,
|
||||
containerColor = Color.Black.copy(alpha = 0.6f),
|
||||
onDismissRequest = { expanded = false },
|
||||
shadowElevation = 0.dp,
|
||||
modifier = Modifier.border(0.5.dp, Color.White.copy(alpha = 0.3f))
|
||||
) {
|
||||
|
||||
val itemColors = MenuDefaults.itemColors(
|
||||
textColor = Color.White,
|
||||
leadingIconColor = Color(0xFFDA7029) // 使用你背景的橘色作為點綴
|
||||
)
|
||||
|
||||
DropdownMenuItem(
|
||||
text = { Text("新建聯絡") },
|
||||
onClick = {
|
||||
expanded = false
|
||||
navController.navigate(Screen.CreateContact.route)
|
||||
},
|
||||
colors = itemColors,
|
||||
leadingIcon = { Icon(Icons.Default.Create, contentDescription = null) }
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("好友列表") },
|
||||
onClick = {
|
||||
expanded = false
|
||||
navController.navigate(Screen.FriendsList.route)
|
||||
},
|
||||
colors = itemColors,
|
||||
leadingIcon = { Icon(Icons.Default.Person, contentDescription = null) }
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("QR code") },
|
||||
onClick = {
|
||||
expanded = false
|
||||
navController.navigate(Screen.ScanQRcode.route)
|
||||
},
|
||||
colors = itemColors,
|
||||
leadingIcon = { Icon(Icons.Default.QrCode, contentDescription = null) }
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.background(
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
shape = RoundedCornerShape(0.dp)
|
||||
)
|
||||
)
|
||||
},
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
|
||||
val currentRooms = rooms
|
||||
if (currentRooms == null) {
|
||||
// 正在從資料庫讀取,不顯示「目前沒有聊天室」,保持空白更平滑
|
||||
Box(modifier = Modifier.fillMaxSize())
|
||||
} else if (currentRooms.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().padding(innerPadding),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text("目前沒有聊天室")
|
||||
}
|
||||
} else {
|
||||
LazyColumn(modifier = Modifier.padding(innerPadding)) {
|
||||
items(
|
||||
items = currentRooms,
|
||||
key = { it.roomId }
|
||||
) { room ->
|
||||
RoomItem(
|
||||
navController = navController,
|
||||
room = room,
|
||||
sharedTransitionScope = sharedTransitionScope, // 傳下去
|
||||
animatedVisibilityScope = animatedVisibilityScope, // 傳下去
|
||||
onClick = { roomId, roomName ->
|
||||
onRoomClick(roomId, roomName)
|
||||
}
|
||||
)
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Composable
|
||||
fun RoomItem(
|
||||
navController: NavController,
|
||||
room: ChatRoom,
|
||||
sharedTransitionScope: SharedTransitionScope,
|
||||
animatedVisibilityScope: AnimatedVisibilityScope,
|
||||
// 🚀 修正 2:onClick 改成攜帶兩個參數 (roomId, roomName)
|
||||
onClick: (String, String) -> Unit
|
||||
) {
|
||||
val title = room.displayTitle
|
||||
|
||||
with(sharedTransitionScope){
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 6.dp)
|
||||
// 1. 先處理轉場動畫(確保容器變形基底乾淨)
|
||||
.sharedElement(
|
||||
rememberSharedContentState(key = "container_${room.roomId}"),
|
||||
animatedVisibilityScope = animatedVisibilityScope
|
||||
)
|
||||
//card color
|
||||
.background(
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
RoundedCornerShape(16.dp)
|
||||
)
|
||||
// 2. 再綁定點擊事件
|
||||
.clickable { onClick(room.roomId, room.displayTitle) }
|
||||
){
|
||||
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(12.dp),
|
||||
) {
|
||||
// 1. 頭像
|
||||
val imageUrl = room.roomIconUrl ?: room.partner?.fullContactAvatarUrl
|
||||
AsyncImage(
|
||||
model = imageUrl,
|
||||
contentDescription = "Avatar",
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.size(56.dp) // 稍微加大一點
|
||||
.clip(CircleShape)
|
||||
.border(1.dp, Color.White, CircleShape) // 頭像加個細白邊
|
||||
.background(Color.LightGray)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
|
||||
// 2. 中間內容
|
||||
Column(
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text(
|
||||
text = room.roomName.orEmpty().ifEmpty {
|
||||
room.partner?.displayName ?: "Unknown"
|
||||
},
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
val lastMsgText = when {
|
||||
room.lastMessage?.isDeleted == true -> "訊息已刪除"
|
||||
room.lastMessage?.content != null -> room.lastMessage.content
|
||||
room.lastMessage?.attachment != null -> "[檔案] ${room.lastMessage.attachment.filename}"
|
||||
else -> "尚未有訊息"
|
||||
}
|
||||
|
||||
Row() {
|
||||
Text(
|
||||
text = lastMsgText,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = Color.Gray,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
|
||||
if (room.unreadCount > 0) {
|
||||
Badge(
|
||||
containerColor = Color(0xFFDA7029), // 使用你的主橘色
|
||||
contentColor = Color.White
|
||||
) {
|
||||
Text(
|
||||
text = if (room.unreadCount > 99) "99+" else room.unreadCount.toString(),
|
||||
modifier = Modifier.padding(horizontal = 4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(2.dp))
|
||||
}
|
||||
|
||||
|
||||
// online status
|
||||
val partner = room.partner // 先從 room 取得 partner
|
||||
if (partner != null) {
|
||||
val status = UserStatus.fromString(partner.status)
|
||||
UserStatusDot(status = status)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
package com.example.login_v3.home.Message.UI.Detail
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.navigation.NavController
|
||||
import com.example.login_v3.navigation.BottomBarViewModel
|
||||
|
||||
@Composable
|
||||
fun Scan_QRcode(
|
||||
navController: NavController,
|
||||
bottomBarViewModel: BottomBarViewModel
|
||||
){
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(text = "Scan_QRcode")
|
||||
}
|
||||
}
|
||||
|
|
@ -1,106 +0,0 @@
|
|||
package com.example.login_v3.home.Message.UI
|
||||
|
||||
import androidx.compose.animation.SharedTransitionLayout
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation.navArgument
|
||||
import com.example.login_v3.home.Message.UI.Detail.MessageMessaging
|
||||
import com.example.login_v3.home.Message.UI.Detail.Message_add_contact
|
||||
import com.example.login_v3.home.Message.UI.Detail.Message_contact_list
|
||||
import com.example.login_v3.home.Message.UI.Detail.Scan_QRcode
|
||||
import com.example.login_v3.navigation.BottomBarViewModel
|
||||
import com.example.login_v3.home.Message.UI.Detail.Loaded_Tg_Message
|
||||
|
||||
|
||||
sealed class Screen(val route: String) {
|
||||
object Messages : Screen("messages")
|
||||
object CreateContact : Screen("create_contact")
|
||||
object FriendsList : Screen("friends_list")
|
||||
object ScanQRcode : Screen("scan_QRcode")
|
||||
object MessageMessaging : Screen("message_messaging?roomId={roomId}") {
|
||||
fun createRoute(roomId: String): String {
|
||||
return "message_messaging?roomId=$roomId"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Tg_Message(
|
||||
bottomBarViewModel: BottomBarViewModel = hiltViewModel()
|
||||
) {
|
||||
val navController = rememberNavController()
|
||||
|
||||
SharedTransitionLayout{
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = Screen.Messages.route,
|
||||
) {
|
||||
// 你的訊息主頁面
|
||||
// 訊息主頁面
|
||||
composable(Screen.Messages.route) {
|
||||
Loaded_Tg_Message(
|
||||
navController = navController,
|
||||
// 2. 傳入 sharedTransitionScope (this@SharedTransitionLayout)
|
||||
sharedTransitionScope = this@SharedTransitionLayout,
|
||||
// 3. 傳入 animatedVisibilityScope (this@composable)
|
||||
animatedVisibilityScope = this@composable,
|
||||
onRoomClick = { roomId, _ ->
|
||||
val route = Screen.MessageMessaging.createRoute(roomId)
|
||||
navController.navigate(route)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// 新建聯絡頁面
|
||||
composable(Screen.CreateContact.route) {
|
||||
Message_add_contact(navController)
|
||||
}
|
||||
|
||||
// 好友列表頁面
|
||||
composable(Screen.FriendsList.route) {
|
||||
Message_contact_list(
|
||||
navController,
|
||||
bottomBarViewModel = bottomBarViewModel
|
||||
)
|
||||
}
|
||||
|
||||
//QR code
|
||||
composable(Screen.ScanQRcode.route) {
|
||||
Scan_QRcode(
|
||||
navController,
|
||||
bottomBarViewModel = bottomBarViewModel
|
||||
)
|
||||
}
|
||||
|
||||
//Message detail
|
||||
composable(
|
||||
route = Screen.MessageMessaging.route,
|
||||
arguments = listOf(
|
||||
navArgument("roomId") {
|
||||
type = NavType.StringType
|
||||
nullable = true
|
||||
defaultValue = ""
|
||||
}
|
||||
)
|
||||
) { backStackEntry ->
|
||||
val roomId = backStackEntry.arguments?.getString("roomId").orEmpty()
|
||||
|
||||
MessageMessaging(
|
||||
roomId = roomId,
|
||||
navController = navController,
|
||||
// 4. 同樣把這兩個 Scope 傳入詳情頁
|
||||
sharedTransitionScope = this@SharedTransitionLayout,
|
||||
animatedVisibilityScope = this@composable,
|
||||
onBackClick = { navController.popBackStack() },
|
||||
bottomBarViewModel = bottomBarViewModel
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
@ -1,192 +0,0 @@
|
|||
package com.example.login_v3.home.Message.ViewModel.Detail
|
||||
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.example.login_v3.data.api.api_class.Friend
|
||||
import com.example.login_v3.data.api.api_class.PendingFriendApiModel
|
||||
import com.example.login_v3.data.api.api_class.UserDetail
|
||||
import com.example.login_v3.data.repository.basic.FriendsRepository
|
||||
import com.example.login_v3.data.repository.basic.SyncRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
|
||||
data class ContactUiState(
|
||||
val isLoading: Boolean = false,
|
||||
val friends: List<Friend> = emptyList(),
|
||||
val pendingRequests: List<PendingFriendApiModel> = emptyList(),
|
||||
val searchResults: List<UserDetail> = emptyList(),
|
||||
val searchQuery: String = "",
|
||||
val error: String? = null,
|
||||
|
||||
//UI state
|
||||
val isSearching: Boolean = false
|
||||
)
|
||||
@HiltViewModel
|
||||
class ContactListViewModel @Inject constructor(
|
||||
private val repository: FriendsRepository,
|
||||
private val syncRepository: SyncRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(ContactUiState())
|
||||
val uiState: StateFlow<ContactUiState> = _uiState.asStateFlow()
|
||||
|
||||
init {
|
||||
// 1. 監聽本地好友 Flow,一旦資料庫變動,UI 自動刷新
|
||||
observeFriends()
|
||||
// 2. 啟動時跑一次同步
|
||||
refreshAll()
|
||||
}
|
||||
|
||||
private fun observeFriends() {
|
||||
viewModelScope.launch {
|
||||
repository.friendsFlow.collect { friends ->
|
||||
_uiState.update { it.copy(friends = friends) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshAll() {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isLoading = true, error = null) }
|
||||
|
||||
// 🎯 同步核心:跑 Delta-Sync
|
||||
val syncResult = syncRepository.performSync()
|
||||
// 同時還是要抓一下 Pending Request (目前 Sync API 沒給這塊)
|
||||
val pendingResult = repository.getPendingRequests()
|
||||
|
||||
_uiState.update { state ->
|
||||
state.copy(
|
||||
isLoading = false,
|
||||
pendingRequests = pendingResult.getOrDefault(state.pendingRequests),
|
||||
error = (syncResult.exceptionOrNull() ?: pendingResult.exceptionOrNull())?.message
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
//accept and reject
|
||||
fun acceptFriend(friendshipId: String) {
|
||||
viewModelScope.launch {
|
||||
|
||||
//loading
|
||||
_uiState.update { it.copy(isLoading = true) }
|
||||
|
||||
val result = repository.acceptFriendRequest(friendshipId)
|
||||
|
||||
result.onSuccess {
|
||||
// 關鍵:重新整理資料,讓 UI 自動更新
|
||||
refreshAll()
|
||||
}.onFailure { error ->
|
||||
_uiState.update { it.copy(error = "無法接受請求:${error.message}") }
|
||||
}
|
||||
}
|
||||
}
|
||||
fun rejectFriend(friendshipId: String) {
|
||||
viewModelScope.launch {
|
||||
|
||||
_uiState.update { it.copy(isLoading = true) }
|
||||
|
||||
val result = repository.rejectFriendRequest(friendshipId)
|
||||
|
||||
result.onSuccess {
|
||||
refreshAll() // 刷新列表,該筆請求會消失
|
||||
}.onFailure { error ->
|
||||
_uiState.update { it.copy(error = "無法拒絕請求:${error.message}") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//add friends
|
||||
fun sendFriendRequest(friendId: String) {
|
||||
viewModelScope.launch {
|
||||
// 1. 顯示載入中
|
||||
_uiState.update { it.copy(isLoading = true, error = null) }
|
||||
|
||||
// 2. 呼叫 Repository
|
||||
val result = repository.sendFriendRequest(friendId)
|
||||
|
||||
// 3. 處理結果
|
||||
result.onSuccess {
|
||||
// 關鍵修改:更新搜尋結果清單中的 isRequestSent 狀態
|
||||
_uiState.update { state ->
|
||||
val updatedSearchResults = state.searchResults.map { user ->
|
||||
if (user.id == friendId) {
|
||||
user.copy(isRequestSent = true) // 找到目標,標記為已發送
|
||||
} else {
|
||||
user
|
||||
}
|
||||
}
|
||||
state.copy(
|
||||
isLoading = false,
|
||||
searchResults = updatedSearchResults
|
||||
)
|
||||
}
|
||||
// refreshAll()
|
||||
}.onFailure { error ->
|
||||
Log.e("DEBUG_API", "發送好友申請錯誤: ${error.message}")
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
error = "發送申請失敗:${error.message}"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
//search user
|
||||
fun onSearchQueryChanged(newQuery: String) {
|
||||
// 1. 即時更新文字輸入,確保 UI TextField 顯示順暢
|
||||
_uiState.update { it.copy(
|
||||
searchQuery = newQuery,
|
||||
isSearching = newQuery.isNotBlank()
|
||||
) }
|
||||
}
|
||||
|
||||
fun performSearch() {
|
||||
val query = _uiState.value.searchQuery
|
||||
if (query.isBlank()) {
|
||||
// 如果關鍵字為空,清空搜尋結果
|
||||
_uiState.update { it.copy(searchResults = emptyList()) }
|
||||
return
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
// 2. 顯示 Loading
|
||||
_uiState.update { it.copy(isLoading = true, error = null) }
|
||||
|
||||
// 3. 呼叫 Repository
|
||||
val result = repository.searchUsers(query)
|
||||
|
||||
// 4. 處理結果
|
||||
result.onSuccess { response ->
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
searchResults = response.users // UserSearchResponse 裡的列表
|
||||
)
|
||||
}
|
||||
}.onFailure { error ->
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
error = "搜尋失敗:${error.message}",
|
||||
searchResults = emptyList() // 失敗時清空列表
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,591 +0,0 @@
|
|||
package com.example.login_v3.home.Message.ViewModel.Detail
|
||||
|
||||
|
||||
import android.app.Application
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.ExoPlayer.*
|
||||
import coil.imageLoader
|
||||
import coil.request.CachePolicy
|
||||
import coil.request.ImageRequest
|
||||
import com.example.login_v3.R
|
||||
import com.example.login_v3.data.repository.dm.ChatRoomsRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
import com.example.login_v3.data.api.api_class.Message
|
||||
import com.example.login_v3.home.Message.UI.Detail.MessageUiModel
|
||||
import com.example.login_v3.home.Message.UI.Detail.toUiModel
|
||||
import com.example.login_v3.data.api.api_class.fullContactAvatarUrl
|
||||
import com.example.login_v3.data.repository.basic.TokenManager
|
||||
import com.example.login_v3.home.Message.ViewModel.UserStatus
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.isActive
|
||||
|
||||
//emoji reaction state
|
||||
sealed class ReactionUsersState {
|
||||
object Idle : ReactionUsersState() // 沒點擊時的閒置狀態
|
||||
object Loading : ReactionUsersState() // 載入中
|
||||
data class Success(val users: List<String>) : ReactionUsersState() // 成功取得名單
|
||||
data class Error(val message: String) : ReactionUsersState() // 取得失敗
|
||||
}
|
||||
|
||||
// 🎯 修正後的發送狀態管理
|
||||
sealed interface SendMessageState {
|
||||
object Idle : SendMessageState
|
||||
object Loading : SendMessageState
|
||||
object Success : SendMessageState // 👈 🌟 這裡改成 object
|
||||
data class Error(val message: String) : SendMessageState
|
||||
}
|
||||
|
||||
|
||||
// 刪除訊息狀態
|
||||
sealed class DeleteMessageState {
|
||||
object Idle : DeleteMessageState() // 閒置
|
||||
object Loading : DeleteMessageState() // 刪除中
|
||||
object Success : DeleteMessageState() // 刪除成功
|
||||
data class Error(val message: String) : DeleteMessageState() // 刪除失敗
|
||||
}
|
||||
|
||||
sealed interface MessagesUiState {
|
||||
object Loading : MessagesUiState
|
||||
data class Success(
|
||||
val roomTitle: String,
|
||||
val partnerStatus: UserStatus,
|
||||
val partnerAvatarUrl: Any?,
|
||||
val messages: List<MessageUiModel>,
|
||||
val currentUserId: String
|
||||
) : MessagesUiState
|
||||
|
||||
data class Error(val message: String) : MessagesUiState
|
||||
}
|
||||
|
||||
//audio status
|
||||
data class AudioPlaybackState(
|
||||
val currentPlayingMessageId: String? = null, // 當前播放的 Message ID
|
||||
val isPlaying: Boolean = false, // 是否正在播放
|
||||
val currentPosition: Long = 0L, // 當前播放位置 (毫秒)
|
||||
val duration: Long = 0L // 總時長 (毫秒)
|
||||
)
|
||||
|
||||
// 🎯 檔案下載狀態管理
|
||||
sealed interface DownloadStatus {
|
||||
object NotStarted : DownloadStatus
|
||||
data class Downloading(val progress: Float) : DownloadStatus // 儲存 0.0 ~ 1.0f 的進度
|
||||
object Completed : DownloadStatus
|
||||
data class Error(val message: String) : DownloadStatus
|
||||
}
|
||||
|
||||
|
||||
@HiltViewModel
|
||||
class ChatViewModel @Inject constructor(
|
||||
private val repository: ChatRoomsRepository,
|
||||
private val tokenManager: TokenManager,
|
||||
private val application: Application,
|
||||
savedStateHandle: SavedStateHandle
|
||||
) : ViewModel() {
|
||||
|
||||
// 🚀 核心優化:穩定 UI Model 實例,避免無意義的重繪
|
||||
private val uiModelCache = mutableMapOf<String, Pair<Message, MessageUiModel>>()
|
||||
|
||||
// 🚀 核心優化:避免重複預載相同的 URL
|
||||
private val prefetchedUrls = mutableSetOf<String>()
|
||||
|
||||
// 從導航參數自動獲取 roomId
|
||||
val roomId: String = savedStateHandle.get<String>("roomId").orEmpty()
|
||||
|
||||
// --- 核心狀態管理 (改為完全響應式) ---
|
||||
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
|
||||
val uiState: StateFlow<MessagesUiState> = combine(
|
||||
repository.getChatMessagesFlow(roomId),
|
||||
tokenManager.currentUserId,
|
||||
repository.getChatRoomFlow(roomId)
|
||||
) { messages, userId, room ->
|
||||
if (userId == null) {
|
||||
MessagesUiState.Loading
|
||||
} else {
|
||||
val title = room?.partner?.displayName ?: room?.partner?.username ?: "聊天室"
|
||||
val userStatus = UserStatus.fromString(room?.partner?.status)
|
||||
val avatar = room?.partner?.fullContactAvatarUrl ?: R.drawable.avatar_v1
|
||||
|
||||
MessagesUiState.Success(
|
||||
roomTitle = title,
|
||||
partnerStatus = userStatus,
|
||||
partnerAvatarUrl = avatar,
|
||||
messages = messages.map { rawMessage ->
|
||||
// 🚀 核心:如果 raw 數據沒變,就沿用同一個 UI Model 實例
|
||||
val cached = uiModelCache[rawMessage.id]
|
||||
if (cached != null && cached.first == rawMessage) {
|
||||
cached.second
|
||||
} else {
|
||||
val uiModel = rawMessage.toUiModel()
|
||||
uiModelCache[rawMessage.id] = rawMessage to uiModel
|
||||
uiModel
|
||||
}
|
||||
},
|
||||
currentUserId = userId
|
||||
)
|
||||
}
|
||||
}.onEach { state ->
|
||||
// 🚀 核心優化:智能預加載媒體內容
|
||||
if (state is MessagesUiState.Success) {
|
||||
prefetchMedia(state.messages)
|
||||
}
|
||||
}.stateIn(viewModelScope, SharingStarted.Eagerly, MessagesUiState.Loading)
|
||||
|
||||
private fun prefetchMedia(messages: List<MessageUiModel>) {
|
||||
// 🚀 核心優化:優先預載最近的 10 則訊息
|
||||
messages.take(10).forEach { msg ->
|
||||
if ((msg.isImage || msg.isVideo || msg.isGif) && !prefetchedUrls.contains(msg.mediaUrl)) {
|
||||
prefetchedUrls.add(msg.mediaUrl)
|
||||
val request = ImageRequest.Builder(application)
|
||||
.data(msg.mediaUrl)
|
||||
.crossfade(true)
|
||||
.memoryCachePolicy(CachePolicy.ENABLED)
|
||||
.diskCachePolicy(CachePolicy.ENABLED)
|
||||
.build()
|
||||
application.imageLoader.enqueue(request)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// --- 其餘狀態 (保持原樣) ---
|
||||
private val _deleteMessageState = MutableStateFlow<DeleteMessageState>(DeleteMessageState.Idle)
|
||||
val deleteMessageState: StateFlow<DeleteMessageState> = _deleteMessageState.asStateFlow()
|
||||
private val _actionMessage = MutableStateFlow<MessageUiModel?>(null)
|
||||
val actionMessage: StateFlow<MessageUiModel?> = _actionMessage.asStateFlow()
|
||||
|
||||
fun setActionMessage(message: MessageUiModel?) { _actionMessage.value = message }
|
||||
fun clearActionMessage() { _actionMessage.value = null }
|
||||
|
||||
private val _editingMessage = MutableStateFlow<MessageUiModel?>(null)
|
||||
val editingMessage: StateFlow<MessageUiModel?> = _editingMessage.asStateFlow()
|
||||
fun setEditingMessage(message: MessageUiModel?) { _editingMessage.value = message }
|
||||
fun clearEditingMessage() { _editingMessage.value = null }
|
||||
|
||||
private val _replyingMessage = MutableStateFlow<MessageUiModel?>(null)
|
||||
val replyingMessage: StateFlow<MessageUiModel?> = _replyingMessage.asStateFlow()
|
||||
fun setReplyingMessage(message: MessageUiModel?) { _replyingMessage.value = message }
|
||||
|
||||
private val _sendMessageState = MutableStateFlow<SendMessageState>(SendMessageState.Idle)
|
||||
val sendMessageState: StateFlow<SendMessageState> = _sendMessageState
|
||||
|
||||
// --- 分頁加載狀態控制 ---
|
||||
private var nextCursor: String? = null
|
||||
private var hasMore: Boolean = true
|
||||
private var isLoadingMore = false // 防止重複戳 API
|
||||
|
||||
init {
|
||||
if (roomId.isNotEmpty()) {
|
||||
initChat(roomId)
|
||||
}
|
||||
}
|
||||
|
||||
private fun initChat(roomId: String) {
|
||||
Log.d("ChatDebug", "🛠️ [ViewModel] initChat 開始 | roomId: $roomId")
|
||||
viewModelScope.launch {
|
||||
// 啟動 Session
|
||||
try {
|
||||
Log.d("ChatDebug", "🌐 [ViewModel] 嘗試啟動 WebSocket Session...")
|
||||
repository.startChatSession(roomId)
|
||||
} catch (e: Exception) {
|
||||
Log.e("ChatDebug", "❌ [ViewModel] WS Error", e)
|
||||
}
|
||||
|
||||
// 🎯 核心優化:Delta-Sync 邏輯
|
||||
try {
|
||||
Log.d("ChatDebug", "🔍 [ViewModel] 正在檢查本地資料一致性...")
|
||||
val currentRoom = repository.getChatRoomFlow(roomId).first()
|
||||
val localLatestId = repository.getLatestMessageId(roomId)
|
||||
val serverLatestId = currentRoom?.lastMessage?.id
|
||||
|
||||
Log.d("ChatDebug", "📊 [ViewModel] 比較結果 - LocalLatest: $localLatestId | ServerLatest: $serverLatestId")
|
||||
|
||||
if (localLatestId == null || localLatestId != serverLatestId) {
|
||||
Log.d("ChatDebug", "🔄 [ViewModel] 資料不一致,發起增量同步...")
|
||||
// 背景同步
|
||||
repository.refreshChatMessages(roomId, cursor = null, limit = 20)
|
||||
.onSuccess { response ->
|
||||
nextCursor = response.nextCursor
|
||||
hasMore = response.hasMore
|
||||
Log.d("ChatDebug", "✅ [ViewModel] 同步成功 | nextCursor: $nextCursor")
|
||||
repository.markAsRead(roomId)
|
||||
}
|
||||
.onFailure { e ->
|
||||
Log.e("ChatDebug", "❌ [ViewModel] 同步失敗: ${e.message}")
|
||||
}
|
||||
} else {
|
||||
Log.d("ChatDebug", "✅ [ViewModel] 資料已是最新,跳過 refresh")
|
||||
repository.markAsRead(roomId)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("ChatDebug", "❌ [ViewModel] initChat 執行過程發生異常", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 相容性方法
|
||||
fun loadMessages(roomId: String) { }
|
||||
|
||||
// =====================================================================
|
||||
// 💡 核心新增:往上滑載入更多老訊息 (補上 limit)
|
||||
// =====================================================================
|
||||
fun loadMoreMessages(roomId: String) {
|
||||
if (isLoadingMore) {
|
||||
Log.d("ChatDebug", "⚠️ [ViewModel] loadMoreMessages 略過: 正在加載中...")
|
||||
return
|
||||
}
|
||||
if (!hasMore) {
|
||||
Log.d("ChatDebug", "⚠️ [ViewModel] loadMoreMessages 略過: 沒有更多訊息了 (hasMore=false)")
|
||||
return
|
||||
}
|
||||
if (nextCursor.isNullOrBlank()) {
|
||||
Log.d("ChatDebug", "⚠️ [ViewModel] loadMoreMessages 略過: nextCursor 為空")
|
||||
return
|
||||
}
|
||||
|
||||
isLoadingMore = true
|
||||
Log.d("ChatDebug", "🚀 [ViewModel] 開始載入更多 | 當前 cursor: $nextCursor | roomId: $roomId")
|
||||
|
||||
viewModelScope.launch {
|
||||
// 🎯 補上 limit = 20 傳遞給 Repository 與 API
|
||||
repository.refreshChatMessages(roomId, cursor = nextCursor, limit = 20)
|
||||
.onSuccess { response ->
|
||||
this@ChatViewModel.nextCursor = response.nextCursor
|
||||
this@ChatViewModel.hasMore = response.hasMore
|
||||
Log.d("ChatDebug", "✅ [ViewModel] 載入更多成功 | 新 cursor: $nextCursor | hasMore: $hasMore")
|
||||
}
|
||||
.onFailure { error ->
|
||||
Log.e("ChatDebug", "❌ [ViewModel] 載入更多失敗", error)
|
||||
}
|
||||
|
||||
isLoadingMore = false
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// 💡 核心改造二:發送文字訊息(樂觀更新)
|
||||
// =====================================================================
|
||||
fun sendMessage(roomId: String, content: String) {
|
||||
// 1. 安全防護:空白直接攔截
|
||||
if (content.trim().isBlank()) return
|
||||
|
||||
viewModelScope.launch {
|
||||
// 2. 進入 Loading,讓輸入框知道目前在打 API
|
||||
_sendMessageState.value = SendMessageState.Loading
|
||||
|
||||
// 3. 保持你原本非常棒的 token 讀取機制
|
||||
val replyToId = _replyingMessage.value?.id
|
||||
val currentLoggedInUserId = tokenManager.currentUserId.first() ?: ""
|
||||
|
||||
if (currentLoggedInUserId.isBlank()) {
|
||||
_sendMessageState.value = SendMessageState.Error("找不到使用者資訊,請重新登入")
|
||||
return@launch
|
||||
}
|
||||
|
||||
// 4. 呼叫樂觀更新方法(此時 SENDING 狀態訊息已秒入 Room,畫面同步劃出半透明氣泡)
|
||||
val result = repository.sendMessageOptimistically(
|
||||
roomId = roomId,
|
||||
currentUserId = currentLoggedInUserId,
|
||||
content = content,
|
||||
replyToId = replyToId
|
||||
)
|
||||
|
||||
// 5. 順手清空回覆狀態
|
||||
_replyingMessage.value = null
|
||||
|
||||
result.onSuccess {
|
||||
// 🌟 完美:因為變成了 object,直接指派名字即可,絕不會再噴 Too many arguments!
|
||||
_sendMessageState.value = SendMessageState.Success
|
||||
|
||||
}.onFailure { error ->
|
||||
Log.e("ChatViewModel", "Send message failed", error)
|
||||
_sendMessageState.value = SendMessageState.Error(error.message ?: "未知錯誤")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// 💡 核心改造三:刪除訊息
|
||||
// =====================================================================
|
||||
// =====================================================================
|
||||
// 💡 核心改造三:刪除訊息(配合樂觀更新優化)
|
||||
// =====================================================================
|
||||
fun deleteMessage(roomId: String, messageId: String) {
|
||||
// 1. 🌟 樂觀更新的核心精神:立刻關閉長按選單(Action Menu)
|
||||
// 因為訊息在下一行就會從資料庫消失,選單如果還開著會點不到東西或出錯
|
||||
clearActionMessage()
|
||||
|
||||
viewModelScope.launch {
|
||||
// 2. 💡 注意:這裡「不要」在 UI 顯示阻擋式的 Loading 轉圈圈。
|
||||
// 這裡的 Loading 狀態只留在背景,或者用於特定的微小 UI 提示(例如標題列小藍點)。
|
||||
_deleteMessageState.value = DeleteMessageState.Loading
|
||||
|
||||
val result = repository.deleteMessage(roomId, messageId)
|
||||
|
||||
result.onSuccess {
|
||||
_deleteMessageState.value = DeleteMessageState.Success
|
||||
// 🌟 刪除成功!本地早就不見了,Flow 也早已更新,這裡乾淨俐落。
|
||||
}.onFailure { error ->
|
||||
Log.e("ChatViewModel", "Delete message failed", error)
|
||||
_deleteMessageState.value = DeleteMessageState.Error(error.message ?: "刪除訊息失敗")
|
||||
|
||||
// 3. 🌟 失敗處理:因為 Repository 把訊息彈回來了,我們可以讓 UI 知道
|
||||
// 這裡可以透過監聽 deleteMessageState 的 Error,在 Activity/Fragment/Compose 彈出 Toast 提示:「刪除失敗,請檢查網路」
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun editMessage(roomId: String, messageId: String, content: String) {
|
||||
if (content.trim().isBlank()) return
|
||||
|
||||
viewModelScope.launch {
|
||||
_sendMessageState.value = SendMessageState.Loading
|
||||
|
||||
val result = repository.editMessage(roomId, messageId, content)
|
||||
|
||||
_editingMessage.value = null
|
||||
|
||||
result.onSuccess {
|
||||
_sendMessageState.value = SendMessageState.Success
|
||||
}.onFailure { error ->
|
||||
Log.e("ChatViewModel", "Edit message failed", error)
|
||||
_sendMessageState.value = SendMessageState.Error(error.message ?: "編輯訊息失敗")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- 其餘 uploadAttachment、Emoji 功能保持原樣 ---
|
||||
fun uploadAttachment(roomId: String, fileUri: Uri) {
|
||||
viewModelScope.launch {
|
||||
_sendMessageState.value = SendMessageState.Loading
|
||||
|
||||
// 🎯 【偵錯步驟 1】檢查這個音訊 Uri 到底長怎樣、MimeType 是什麼
|
||||
try {
|
||||
val contentResolver = application.contentResolver
|
||||
val mimeType = contentResolver.getType(fileUri)
|
||||
|
||||
// 🎯 【偵錯步驟 2】呼叫 Repository
|
||||
val result = repository.uploadAttachment(roomId, fileUri)
|
||||
|
||||
result.onSuccess {
|
||||
_sendMessageState.value = SendMessageState.Success
|
||||
repository.refreshChatMessages(roomId)
|
||||
}.onFailure { error ->
|
||||
// 🎯 【偵錯步驟 3】這裡能抓到 Repository 傳回來的 API 錯誤
|
||||
Log.e("ChatDebug", "Repository 回報上傳失敗: ${error.message}", error)
|
||||
_sendMessageState.value = SendMessageState.Error(error.message ?: "上傳失敗")
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
// 🎯 【偵錯步驟 4】這裡能抓到本機讀取檔案就崩潰的錯誤(例如權限或 Stream 問題)
|
||||
Log.e("ChatDebug", "ViewModel 處理檔案時發生異常: ${e.message}", e)
|
||||
_sendMessageState.value = SendMessageState.Error(e.message ?: "讀取檔案失敗")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun addMessageReaction(roomId: String, messageId: String, emoji: String) {
|
||||
viewModelScope.launch {
|
||||
repository.addMessageReaction(roomId, messageId, emoji)
|
||||
.onSuccess {
|
||||
// 🌟 這裡也不用 loadMessages(roomId) 了,直接背景 refresh 即可
|
||||
repository.refreshChatMessages(roomId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun removeMessageReaction(roomId: String, messageId: String, emoji: String) {
|
||||
viewModelScope.launch {
|
||||
repository.deleteMessageReaction(roomId, messageId, emoji)
|
||||
.onSuccess {
|
||||
repository.refreshChatMessages(roomId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
//audio status
|
||||
private val _audioState = MutableStateFlow(AudioPlaybackState())
|
||||
val audioState: StateFlow<AudioPlaybackState> = _audioState.asStateFlow()
|
||||
|
||||
// 唯一播放器實例
|
||||
private var exoPlayer: ExoPlayer? = null
|
||||
// 用來定時輪詢進度條的 Job
|
||||
private var progressLogJob: Job? = null
|
||||
|
||||
init {
|
||||
// 初始化 ExoPlayer
|
||||
initializePlayer()
|
||||
}
|
||||
|
||||
private fun initializePlayer() {
|
||||
exoPlayer = Builder(application).build().apply {
|
||||
addListener(object : Player.Listener {
|
||||
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||
_audioState.value = _audioState.value.copy(isPlaying = isPlaying)
|
||||
if (isPlaying) {
|
||||
startProgressTracker()
|
||||
} else {
|
||||
stopProgressTracker()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||
when (playbackState) {
|
||||
Player.STATE_READY -> {
|
||||
_audioState.value = _audioState.value.copy(
|
||||
duration = duration.coerceAtLeast(0L)
|
||||
)
|
||||
}
|
||||
Player.STATE_ENDED -> {
|
||||
// 播放完畢,重置進度與播放狀態
|
||||
stopProgressTracker()
|
||||
seekTo(0)
|
||||
_audioState.value = _audioState.value.copy(
|
||||
isPlaying = false,
|
||||
currentPosition = 0L
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 點擊音訊氣泡的外部接口
|
||||
fun toggleAudioPlayback(messageId: String, audioUrl: String) {
|
||||
val player = exoPlayer ?: return
|
||||
val currentState = _audioState.value
|
||||
|
||||
if (currentState.currentPlayingMessageId == messageId) {
|
||||
// 點擊的是同一個音訊 -> 切換 播放/暫停
|
||||
if (player.isPlaying) {
|
||||
player.pause()
|
||||
} else {
|
||||
player.play()
|
||||
}
|
||||
} else {
|
||||
// 點擊的是新音訊 -> 停止上一個,載入新音訊播放
|
||||
player.stop()
|
||||
_audioState.value = AudioPlaybackState(
|
||||
currentPlayingMessageId = messageId,
|
||||
isPlaying = false
|
||||
)
|
||||
|
||||
val mediaItem = MediaItem.fromUri(audioUrl)
|
||||
player.setMediaItem(mediaItem)
|
||||
player.prepare()
|
||||
player.play()
|
||||
}
|
||||
}
|
||||
|
||||
// 拖動進度條時調整播放位置
|
||||
fun seekAudioTo(progressRatio: Float) {
|
||||
val player = exoPlayer ?: return
|
||||
val targetPosition = (progressRatio * _audioState.value.duration).toLong()
|
||||
player.seekTo(targetPosition)
|
||||
_audioState.value = _audioState.value.copy(currentPosition = targetPosition)
|
||||
}
|
||||
|
||||
// 啟動協程計時器,即時刷新播放進度
|
||||
private fun startProgressTracker() {
|
||||
progressLogJob?.cancel()
|
||||
progressLogJob = viewModelScope.launch {
|
||||
while (isActive) {
|
||||
exoPlayer?.let { player ->
|
||||
_audioState.value = _audioState.value.copy(
|
||||
currentPosition = player.currentPosition
|
||||
)
|
||||
}
|
||||
delay(200) // 每 200 毫秒刷新一次 UI
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopProgressTracker() {
|
||||
progressLogJob?.cancel()
|
||||
progressLogJob = null
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
stopProgressTracker()
|
||||
exoPlayer?.release()
|
||||
exoPlayer = null
|
||||
uiModelCache.clear()
|
||||
prefetchedUrls.clear()
|
||||
}
|
||||
// =====================================================================
|
||||
|
||||
|
||||
//file download
|
||||
val fileDownloadStates = mutableStateMapOf<String, DownloadStatus>()
|
||||
|
||||
// 取得特定訊息的下載狀態,若無則預設為 NotStarted
|
||||
fun getDownloadStatus(messageId: String): DownloadStatus {
|
||||
return fileDownloadStates[messageId] ?: DownloadStatus.NotStarted
|
||||
}
|
||||
fun downloadFile(message: MessageUiModel, displayFileName: String) {
|
||||
val messageId = message.id
|
||||
|
||||
// 🎯 取得後端給的直接 URL。請根據你實際的 Message 資料結構調整欄位名稱
|
||||
val fileUrl = message.content // 假設你把 URL 存放在 content 裡,或是 message.fileUrl
|
||||
|
||||
if (fileUrl.isNullOrBlank()) {
|
||||
fileDownloadStates[messageId] = DownloadStatus.Error("檔案下載連結無效")
|
||||
return
|
||||
}
|
||||
|
||||
if (fileDownloadStates[messageId] is DownloadStatus.Downloading) return
|
||||
|
||||
viewModelScope.launch {
|
||||
fileDownloadStates[messageId] = DownloadStatus.Downloading(0f)
|
||||
|
||||
// 呼叫剛剛改好的 URL 版 Flow,傳入 messageId
|
||||
repository.downloadFileFromUrlFlow(messageId, fileUrl, displayFileName)
|
||||
.collect { progressResult ->
|
||||
progressResult.onSuccess { progress ->
|
||||
if (progress >= 1.0f) {
|
||||
fileDownloadStates[messageId] = DownloadStatus.Completed
|
||||
} else {
|
||||
fileDownloadStates[messageId] = DownloadStatus.Downloading(progress)
|
||||
}
|
||||
}.onFailure { error ->
|
||||
Log.e("ChatViewModel", "下載失敗: ${error.message}")
|
||||
fileDownloadStates[messageId] = DownloadStatus.Error(error.message ?: "下載失敗")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// (選填)如果想要清除特定訊息的狀態(例如錯誤後想重試)
|
||||
fun resetDownloadStatus(messageId: String) {
|
||||
fileDownloadStates.remove(messageId)
|
||||
}
|
||||
|
||||
fun resetDeleteMessageState() { _deleteMessageState.value = DeleteMessageState.Idle }
|
||||
fun resetSendMessageState() { _sendMessageState.value = SendMessageState.Idle }
|
||||
}
|
||||
|
|
@ -1,76 +0,0 @@
|
|||
// Message_ViewModel.kt
|
||||
package com.example.login_v3.home.Message.ViewModel
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.example.login_v3.R
|
||||
import com.example.login_v3.data.api.api_class.ChatRoom
|
||||
import com.example.login_v3.data.api.api_class.Message
|
||||
import com.example.login_v3.data.repository.dm.ChatRoomsRepository
|
||||
import com.example.login_v3.data.repository.basic.SyncRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class ChatRoomsViewModel @Inject constructor(
|
||||
private val repository: ChatRoomsRepository,
|
||||
private val syncRepository: SyncRepository
|
||||
) : ViewModel() {
|
||||
|
||||
// 🌟 核心優化:直接將 Repository 的 Flow 轉換為 StateFlow
|
||||
// SharingStarted.Eagerly 確保 ViewModel 一建立就開始讀取資料庫
|
||||
val roomsState: StateFlow<List<ChatRoom>?> = repository.chatRoomsFlow
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.Eagerly,
|
||||
initialValue = null
|
||||
)
|
||||
|
||||
init {
|
||||
// App 啟動時或進入頁面時默默同步一次
|
||||
loadRooms()
|
||||
}
|
||||
|
||||
fun loadRooms() {
|
||||
viewModelScope.launch {
|
||||
Log.d("ChatRoomsDebug", "📡 [ChatRoomsViewModel] 觸發同步...")
|
||||
val result = syncRepository.performSync()
|
||||
result.onFailure { error ->
|
||||
Log.e("ChatRoomsDebug", "❌ 同步失敗", error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum class UserStatus(val color: Color) {
|
||||
ONLINE(Color(0xFF4CAF50)),
|
||||
BUSY(Color(0xFFF44336)),
|
||||
OFFLINE(Color(0xFF9E9E9E)),
|
||||
UNKNOWN(Color.Transparent);
|
||||
|
||||
companion object {
|
||||
fun fromString(status: String?): UserStatus {
|
||||
if (status == null) return UNKNOWN
|
||||
|
||||
// 使用 try-catch 或者是 safe valueOf 確保絕對不閃退
|
||||
return try {
|
||||
// uppercase() 預防後端一下給大寫一下給小寫 (例如 "online" vs "ONLINE")
|
||||
val normalizedStatus = status.uppercase()
|
||||
when (normalizedStatus) {
|
||||
"ONLINE" -> ONLINE
|
||||
"BUSY" -> BUSY
|
||||
"OFFLINE" -> OFFLINE
|
||||
else -> OFFLINE // 如果是其他自定義狀態,統一顯示為離線 (灰色)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
OFFLINE
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,209 +0,0 @@
|
|||
package com.example.login_v3.home
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.blur
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.Divider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
|
||||
@Composable
|
||||
fun Tg_Server(
|
||||
viewModel: ServerViewModel
|
||||
){
|
||||
val serverList by viewModel.serverList.collectAsState()
|
||||
val currentServer by viewModel.currentServer.collectAsState()
|
||||
|
||||
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.align(Alignment.Center),
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
|
||||
//TOP_SPACER
|
||||
Spacer(
|
||||
modifier = Modifier.weight(0.6f)
|
||||
)
|
||||
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(8f)
|
||||
.fillMaxWidth()
|
||||
){
|
||||
//glass effect
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.matchParentSize()
|
||||
.blur(10.dp)
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.matchParentSize()
|
||||
.background(
|
||||
Color.Black.copy(alpha = 0.08f),
|
||||
RoundedCornerShape(16.dp)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
//row contain server list and function block
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
) {
|
||||
//server list
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.width(80.dp)
|
||||
.fillMaxHeight()
|
||||
) {
|
||||
LazyColumn {
|
||||
items(serverList) { server ->
|
||||
Image(
|
||||
painter = painterResource(id = server.server_icon),
|
||||
contentDescription = server.name,
|
||||
modifier = Modifier
|
||||
.size(72.dp)
|
||||
.clip(CircleShape)
|
||||
.clickable {
|
||||
viewModel.selectServer(server.id)
|
||||
}
|
||||
.padding(8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
//server function block
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight()
|
||||
) {
|
||||
|
||||
Box(modifier = Modifier
|
||||
.fillMaxSize()
|
||||
){
|
||||
//glass effect
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.matchParentSize()
|
||||
.blur(10.dp)
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.matchParentSize()
|
||||
.background(
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
RoundedCornerShape(16.dp)
|
||||
)
|
||||
.border(
|
||||
1.dp,
|
||||
Color.White.copy(alpha = 0.3f),
|
||||
RoundedCornerShape(16.dp)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
//column contain function block
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
|
||||
//server name
|
||||
Text(
|
||||
text = if (currentServer != null) {
|
||||
"目前伺服器:${currentServer!!.name}"
|
||||
} else {
|
||||
"尚未選擇伺服器"
|
||||
},
|
||||
fontSize = 20.sp, // 調整字體大小
|
||||
fontWeight = FontWeight.Bold, // 設定字體加粗
|
||||
color = Color(0xFFFFEDD9)
|
||||
)
|
||||
|
||||
|
||||
Divider(
|
||||
color = Color(0xFFFFEDD9), // 線的顏色
|
||||
thickness = 2.dp, // 線的粗細
|
||||
modifier = Modifier.padding(vertical = 8.dp) // 與上下內容的間距
|
||||
)
|
||||
|
||||
//gallery
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(120.dp)
|
||||
.padding(horizontal = 28.dp, vertical = 16.dp)
|
||||
.background(
|
||||
brush = Brush.linearGradient(
|
||||
colors = listOf(
|
||||
Color(0xFFFFEDD9), // 漸層起始色
|
||||
Color(0xFFAB47BC) // 漸層結束色
|
||||
),
|
||||
start = Offset(0f, 0f),
|
||||
end = Offset(1000f, 1000f) // 控制漸層方向
|
||||
),
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
)
|
||||
.padding(24.dp)
|
||||
) {}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
//bottom spacer
|
||||
Spacer(
|
||||
modifier = Modifier.weight(1.4f)
|
||||
)
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue