Compare commits

...

No commits in common. "7c2c89fc8b6bdf4b1ddf6144cc5f568560ce2064" and "71ae5deb3e0951bc80ecc94dd9f9f5e6636583cb" have entirely different histories.

162 changed files with 11 additions and 13998 deletions

View file

@ -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.

View file

@ -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

View file

@ -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.

View file

@ -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.

View file

@ -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`

View file

@ -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
View file

@ -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
View file

@ -1,3 +0,0 @@
# Default ignored files
/shelf/
/workspace.xml

View file

@ -1 +0,0 @@
Login_V3

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -1,5 +0,0 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
</state>
</component>

View file

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="21" />
</component>
</project>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="StudioBotProjectSettings">
<option name="shareContext" value="OptedIn" />
</component>
</project>

View file

@ -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>

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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
View 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
View file

@ -0,0 +1,2 @@
# technologia-jetpack-compose

1
app/.gitignore vendored
View file

@ -1 +0,0 @@
/build

View file

@ -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 保持一致
}

View file

@ -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

View file

@ -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)
}
}

View file

@ -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>

View file

@ -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()
}
}
}
}

View file

@ -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()
}
}

View file

@ -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
)
}
}

View file

@ -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
)
}
}
}
}
}

View file

@ -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))
}
}

View file

@ -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{}
)
}
}
}
}

View file

@ -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
)
}
}
}
}

View file

@ -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
)
}

View file

@ -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)
}
}
}

View file

@ -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
}
}
}
}

View file

@ -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}")
}
}
}
}

View file

@ -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("導航至登入頁面")
}
}
}
}

View file

@ -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>
}

View file

@ -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? = ""
)

View file

@ -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
}
}
}
}

View file

@ -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"
}

View file

@ -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
}

View file

@ -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
)

View file

@ -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
}

View file

@ -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)
}
}

View file

@ -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()
}
}

View file

@ -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)
}
}

View file

@ -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
}
}
}

View file

@ -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
}
}

View file

@ -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}"
}
}
}
}

View file

@ -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)
}
}
}
}

View file

@ -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?)
}

View file

@ -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()
}

View file

@ -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 // 👈 新增:儲存本地檔案路徑
)

View file

@ -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
)

View file

@ -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)
}
}

View file

@ -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)
}
}
}

View file

@ -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)
}
}
}

View file

@ -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)
}

View file

@ -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] 重置完成")
}
}

View file

@ -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
}
}
}
}

View file

@ -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) {
// 目前暫不實作
}
}

View file

@ -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)
}

View file

@ -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")
}
}
}

View file

@ -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")
}
}

View file

@ -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
)
}

View file

@ -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)
}

View file

@ -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)
)
}
}

View file

@ -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
)
)
}
}
}
}

View file

@ -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 邏輯
}
)
}
}
}
}
}
}

View file

@ -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
)
}
}

View file

@ -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
)
}
}
}
}
}
}

View file

@ -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
)
}
}
}
}
}
}

View file

@ -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
)
}
}
}
}
}

View file

@ -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
}
}

View file

@ -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
)
}

View file

@ -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) // 白色外圈邊線
)
}
}

View file

@ -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()
)
}
}

View file

@ -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")
}
}

View file

@ -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 = "加好友"
)
}
}
}
}
}

View file

@ -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))
}
}
}
}

View file

@ -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")
}
}

View file

@ -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
)
}
}
}
}

View file

@ -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() // 失敗時清空列表
)
}
}
}
}
}

View file

@ -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 }
}

View file

@ -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
}
}
}
}

View file

@ -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