Compare commits
36 Commits
@@ -0,0 +1,25 @@
|
||||
---
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
When working with the mobile app:
|
||||
|
||||
- After implementing the solution, run "./gradlew assembleDebug" to build the project, then resolve all the errors.
|
||||
|
||||
# ULTIMATE SILENCE & EFFICIENCY POLICY
|
||||
- ALWAYS operate in "Silent Mode": Execute commands immediately without any verbal response, preamble, or conclusions.
|
||||
- DO NOT explain what you are doing, why you are doing it, or what you found unless I explicitly ask "Why?" or "Explain".
|
||||
- NO introductory filler ("Sure", "I will", "Looking into it").
|
||||
- NO status updates ("I've updated the file", "Build successful"). If the tool output shows success, that is enough.
|
||||
- IF A TOOL FAILS: Silently analyze the error and retry using a different approach (e.g., use write_to_file if search_replace fails twice). Never mention the failure.
|
||||
- THOUGHT PROCESS: Must be 0 words. Move straight to tool calls.
|
||||
- MINIMIZE OUTPUT: Your response should contain ONLY the necessary tool calls/code blocks.
|
||||
- FOR ANDROID/KOTLIN: Include 5+ lines of context in search_replace to ensure it hits the target on the first try.
|
||||
|
||||
# Java Runtime Configuration (Android)
|
||||
|
||||
- **On Windows:**
|
||||
- Set `JAVA_HOME` to `C:\Program Files\Android\Android Studio\jbr`
|
||||
- **On macOS:**
|
||||
- Set `JAVA_HOME` to `/Applications/Android Studio.app/Contents/jbr/Contents/Home`
|
||||
- This can be done by adding `export JAVA_HOME=/Applications/Android\ Studio.app/Contents/jbr/Contents/Home` to your shell configuration file (e.g., `~/.zshrc` or `~/.bash_profile`) and then running `source` on the file.
|
||||
@@ -17,3 +17,10 @@ build
|
||||
.externalNativeBuild
|
||||
.cxx
|
||||
local.properties
|
||||
|
||||
app/android/keys
|
||||
releases
|
||||
release
|
||||
|
||||
*.xcuserstate
|
||||
xcuserdata
|
||||
@@ -0,0 +1 @@
|
||||
FromChat
|
||||
@@ -0,0 +1,26 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,8 @@
|
||||
<component name="ArtifactManager">
|
||||
<artifact type="jar" name="shared">
|
||||
<output-path>$PROJECT_DIR$/utils/shared/build/libs</output-path>
|
||||
<root id="archive" name="shared.jar">
|
||||
<element id="module-output" name="FromChat.utils.shared.androidMain" />
|
||||
</root>
|
||||
</artifact>
|
||||
</component>
|
||||
@@ -0,0 +1,8 @@
|
||||
<component name="ArtifactManager">
|
||||
<artifact type="jar" name="utils">
|
||||
<output-path>$PROJECT_DIR$/utils/build/libs</output-path>
|
||||
<root id="archive" name="utils.jar">
|
||||
<element id="module-output" name="FromChat.utils.androidMain" />
|
||||
</root>
|
||||
</artifact>
|
||||
</component>
|
||||
@@ -0,0 +1,123 @@
|
||||
<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>
|
||||
@@ -0,0 +1,5 @@
|
||||
<component name="ProjectCodeStyleConfiguration">
|
||||
<state>
|
||||
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
|
||||
</state>
|
||||
</component>
|
||||
@@ -1,5 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="GradleMigrationSettings" migrationVersion="1" />
|
||||
<component name="GradleSettings">
|
||||
<option name="linkedExternalProjectsSettings">
|
||||
<GradleProjectSettings>
|
||||
@@ -10,6 +11,11 @@
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$" />
|
||||
<option value="$PROJECT_DIR$/app" />
|
||||
<option value="$PROJECT_DIR$/app/android" />
|
||||
<option value="$PROJECT_DIR$/app/shared" />
|
||||
<option value="$PROJECT_DIR$/utils" />
|
||||
<option value="$PROJECT_DIR$/utils/android" />
|
||||
<option value="$PROJECT_DIR$/utils/shared" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<component name="InspectionProjectProfileManager">
|
||||
<profile version="1.0">
|
||||
<option name="myName" value="Project Default" />
|
||||
<inspection_tool class="ClassName" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
|
||||
<inspection_tool class="ComposePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
@@ -17,6 +18,7 @@
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="FunctionName" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
|
||||
<inspection_tool class="GlancePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
@@ -29,6 +31,8 @@
|
||||
<inspection_tool class="GlancePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="LocalVariableName" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
|
||||
<inspection_tool class="NestedLambdaShadowedImplicitParameter" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
|
||||
<inspection_tool class="PreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
@@ -57,7 +61,15 @@
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PrivatePropertyName" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
|
||||
<inspection_tool class="PropertyName" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
|
||||
<inspection_tool class="SpellCheckingInspection" enabled="false" level="TYPO" enabled_by_default="false">
|
||||
<option name="processCode" value="true" />
|
||||
<option name="processLiterals" value="true" />
|
||||
<option name="processComments" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="UnnecessaryModuleDependencyInspection" enabled="false" level="WARNING" enabled_by_default="false" />
|
||||
<inspection_tool class="UnstableApiUsage" enabled="false" level="WARNING" enabled_by_default="false" />
|
||||
<inspection_tool class="UnusedSymbol" enabled="true" level="WEAK WARNING" enabled_by_default="true" editorAttributes="INFO_ATTRIBUTES" />
|
||||
</profile>
|
||||
</component>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="MarkdownSettings">
|
||||
<option name="previewPanelProviderInfo">
|
||||
<ProviderInfo name="Compose (experimental)" className="com.intellij.markdown.compose.preview.ComposePanelProvider" />
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
@@ -1,837 +1,4 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="DeviceStreaming">
|
||||
<option name="deviceSelectionList">
|
||||
<list>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="34" />
|
||||
<option name="brand" value="Sony" />
|
||||
<option name="codename" value="A402SO" />
|
||||
<option name="id" value="A402SO" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Sony" />
|
||||
<option name="name" value="Xperia 10" />
|
||||
<option name="screenDensity" value="450" />
|
||||
<option name="screenX" value="1080" />
|
||||
<option name="screenY" value="2520" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="27" />
|
||||
<option name="brand" value="DOCOMO" />
|
||||
<option name="codename" value="F01L" />
|
||||
<option name="id" value="F01L" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="FUJITSU" />
|
||||
<option name="name" value="F-01L" />
|
||||
<option name="screenDensity" value="360" />
|
||||
<option name="screenX" value="720" />
|
||||
<option name="screenY" value="1280" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="34" />
|
||||
<option name="brand" value="OnePlus" />
|
||||
<option name="codename" value="OP535DL1" />
|
||||
<option name="id" value="OP535DL1" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="OnePlus" />
|
||||
<option name="name" value="CPH2409" />
|
||||
<option name="screenDensity" value="401" />
|
||||
<option name="screenX" value="1080" />
|
||||
<option name="screenY" value="2412" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="34" />
|
||||
<option name="brand" value="OnePlus" />
|
||||
<option name="codename" value="OP5552L1" />
|
||||
<option name="id" value="OP5552L1" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="OnePlus" />
|
||||
<option name="name" value="CPH2415" />
|
||||
<option name="screenDensity" value="480" />
|
||||
<option name="screenX" value="1080" />
|
||||
<option name="screenY" value="2412" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="34" />
|
||||
<option name="brand" value="OPPO" />
|
||||
<option name="codename" value="OP573DL1" />
|
||||
<option name="id" value="OP573DL1" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="OPPO" />
|
||||
<option name="name" value="CPH2557" />
|
||||
<option name="screenDensity" value="480" />
|
||||
<option name="screenX" value="1080" />
|
||||
<option name="screenY" value="2400" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="28" />
|
||||
<option name="brand" value="DOCOMO" />
|
||||
<option name="codename" value="SH-01L" />
|
||||
<option name="id" value="SH-01L" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="SHARP" />
|
||||
<option name="name" value="AQUOS sense2 SH-01L" />
|
||||
<option name="screenDensity" value="480" />
|
||||
<option name="screenX" value="1080" />
|
||||
<option name="screenY" value="2160" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="34" />
|
||||
<option name="brand" value="samsung" />
|
||||
<option name="codename" value="a14m" />
|
||||
<option name="id" value="a14m" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Samsung" />
|
||||
<option name="name" value="SM-A145R" />
|
||||
<option name="screenDensity" value="450" />
|
||||
<option name="screenX" value="1080" />
|
||||
<option name="screenY" value="2408" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="34" />
|
||||
<option name="brand" value="samsung" />
|
||||
<option name="codename" value="a15" />
|
||||
<option name="id" value="a15" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Samsung" />
|
||||
<option name="name" value="A15" />
|
||||
<option name="screenDensity" value="450" />
|
||||
<option name="screenX" value="1080" />
|
||||
<option name="screenY" value="2340" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="34" />
|
||||
<option name="brand" value="samsung" />
|
||||
<option name="codename" value="a15x" />
|
||||
<option name="id" value="a15x" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Samsung" />
|
||||
<option name="name" value="A15 5G" />
|
||||
<option name="screenDensity" value="450" />
|
||||
<option name="screenX" value="1080" />
|
||||
<option name="screenY" value="2340" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="34" />
|
||||
<option name="brand" value="samsung" />
|
||||
<option name="codename" value="a16x" />
|
||||
<option name="id" value="a16x" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Samsung" />
|
||||
<option name="name" value="A16 5G" />
|
||||
<option name="screenDensity" value="450" />
|
||||
<option name="screenX" value="1080" />
|
||||
<option name="screenY" value="2340" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="34" />
|
||||
<option name="brand" value="samsung" />
|
||||
<option name="codename" value="a35x" />
|
||||
<option name="id" value="a35x" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Samsung" />
|
||||
<option name="name" value="A35" />
|
||||
<option name="screenDensity" value="450" />
|
||||
<option name="screenX" value="1080" />
|
||||
<option name="screenY" value="2340" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="34" />
|
||||
<option name="brand" value="google" />
|
||||
<option name="codename" value="akita" />
|
||||
<option name="id" value="akita" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Google" />
|
||||
<option name="name" value="Pixel 8a" />
|
||||
<option name="screenDensity" value="420" />
|
||||
<option name="screenX" value="1080" />
|
||||
<option name="screenY" value="2400" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="35" />
|
||||
<option name="brand" value="google" />
|
||||
<option name="codename" value="akita" />
|
||||
<option name="id" value="akita" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Google" />
|
||||
<option name="name" value="Pixel 8a" />
|
||||
<option name="screenDensity" value="420" />
|
||||
<option name="screenX" value="1080" />
|
||||
<option name="screenY" value="2400" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="34" />
|
||||
<option name="brand" value="motorola" />
|
||||
<option name="codename" value="arcfox" />
|
||||
<option name="id" value="arcfox" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Motorola" />
|
||||
<option name="name" value="razr plus 2024" />
|
||||
<option name="screenDensity" value="360" />
|
||||
<option name="screenX" value="1080" />
|
||||
<option name="screenY" value="1272" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="33" />
|
||||
<option name="brand" value="motorola" />
|
||||
<option name="codename" value="austin" />
|
||||
<option name="id" value="austin" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Motorola" />
|
||||
<option name="name" value="moto g 5G (2022)" />
|
||||
<option name="screenDensity" value="280" />
|
||||
<option name="screenX" value="720" />
|
||||
<option name="screenY" value="1600" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="33" />
|
||||
<option name="brand" value="samsung" />
|
||||
<option name="codename" value="b0q" />
|
||||
<option name="id" value="b0q" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Samsung" />
|
||||
<option name="name" value="Galaxy S22 Ultra" />
|
||||
<option name="screenDensity" value="600" />
|
||||
<option name="screenX" value="1440" />
|
||||
<option name="screenY" value="3088" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="34" />
|
||||
<option name="brand" value="samsung" />
|
||||
<option name="codename" value="b6q" />
|
||||
<option name="id" value="b6q" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Samsung" />
|
||||
<option name="name" value="Flip 6" />
|
||||
<option name="screenDensity" value="340" />
|
||||
<option name="screenX" value="1080" />
|
||||
<option name="screenY" value="2640" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="32" />
|
||||
<option name="brand" value="google" />
|
||||
<option name="codename" value="bluejay" />
|
||||
<option name="id" value="bluejay" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Google" />
|
||||
<option name="name" value="Pixel 6a" />
|
||||
<option name="screenDensity" value="420" />
|
||||
<option name="screenX" value="1080" />
|
||||
<option name="screenY" value="2400" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="34" />
|
||||
<option name="brand" value="google" />
|
||||
<option name="codename" value="caiman" />
|
||||
<option name="id" value="caiman" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Google" />
|
||||
<option name="name" value="Pixel 9 Pro" />
|
||||
<option name="screenDensity" value="360" />
|
||||
<option name="screenX" value="960" />
|
||||
<option name="screenY" value="2142" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="35" />
|
||||
<option name="brand" value="google" />
|
||||
<option name="codename" value="caiman" />
|
||||
<option name="id" value="caiman" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Google" />
|
||||
<option name="name" value="Pixel 9 Pro" />
|
||||
<option name="screenDensity" value="360" />
|
||||
<option name="screenX" value="960" />
|
||||
<option name="screenY" value="2142" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="34" />
|
||||
<option name="brand" value="google" />
|
||||
<option name="codename" value="comet" />
|
||||
<option name="default" value="true" />
|
||||
<option name="id" value="comet" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Google" />
|
||||
<option name="name" value="Pixel 9 Pro Fold" />
|
||||
<option name="screenDensity" value="390" />
|
||||
<option name="screenX" value="2076" />
|
||||
<option name="screenY" value="2152" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="35" />
|
||||
<option name="brand" value="google" />
|
||||
<option name="codename" value="comet" />
|
||||
<option name="default" value="true" />
|
||||
<option name="id" value="comet" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Google" />
|
||||
<option name="name" value="Pixel 9 Pro Fold" />
|
||||
<option name="screenDensity" value="390" />
|
||||
<option name="screenX" value="2076" />
|
||||
<option name="screenY" value="2152" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="29" />
|
||||
<option name="brand" value="samsung" />
|
||||
<option name="codename" value="crownqlteue" />
|
||||
<option name="id" value="crownqlteue" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Samsung" />
|
||||
<option name="name" value="Galaxy Note9" />
|
||||
<option name="screenDensity" value="420" />
|
||||
<option name="screenX" value="2220" />
|
||||
<option name="screenY" value="1080" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="34" />
|
||||
<option name="brand" value="samsung" />
|
||||
<option name="codename" value="dm2q" />
|
||||
<option name="id" value="dm2q" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Samsung" />
|
||||
<option name="name" value="S23 Plus" />
|
||||
<option name="screenDensity" value="450" />
|
||||
<option name="screenX" value="1080" />
|
||||
<option name="screenY" value="2340" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="34" />
|
||||
<option name="brand" value="samsung" />
|
||||
<option name="codename" value="dm3q" />
|
||||
<option name="id" value="dm3q" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Samsung" />
|
||||
<option name="name" value="Galaxy S23 Ultra" />
|
||||
<option name="screenDensity" value="600" />
|
||||
<option name="screenX" value="1440" />
|
||||
<option name="screenY" value="3088" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="34" />
|
||||
<option name="brand" value="motorola" />
|
||||
<option name="codename" value="dubai" />
|
||||
<option name="id" value="dubai" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Motorola" />
|
||||
<option name="name" value="edge 30" />
|
||||
<option name="screenDensity" value="405" />
|
||||
<option name="screenX" value="1080" />
|
||||
<option name="screenY" value="2400" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="34" />
|
||||
<option name="brand" value="samsung" />
|
||||
<option name="codename" value="e1q" />
|
||||
<option name="default" value="true" />
|
||||
<option name="id" value="e1q" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Samsung" />
|
||||
<option name="name" value="Galaxy S24" />
|
||||
<option name="screenDensity" value="480" />
|
||||
<option name="screenX" value="1080" />
|
||||
<option name="screenY" value="2340" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="34" />
|
||||
<option name="brand" value="samsung" />
|
||||
<option name="codename" value="e3q" />
|
||||
<option name="id" value="e3q" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Samsung" />
|
||||
<option name="name" value="Galaxy S24 Ultra" />
|
||||
<option name="screenDensity" value="450" />
|
||||
<option name="screenX" value="1440" />
|
||||
<option name="screenY" value="3120" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="33" />
|
||||
<option name="brand" value="google" />
|
||||
<option name="codename" value="eos" />
|
||||
<option name="id" value="eos" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Google" />
|
||||
<option name="name" value="Eos" />
|
||||
<option name="screenDensity" value="320" />
|
||||
<option name="screenX" value="384" />
|
||||
<option name="screenY" value="384" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="35" />
|
||||
<option name="brand" value="motorola" />
|
||||
<option name="codename" value="eqe" />
|
||||
<option name="id" value="eqe" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Motorola" />
|
||||
<option name="name" value="edge 50 pro" />
|
||||
<option name="screenDensity" value="450" />
|
||||
<option name="screenX" value="1220" />
|
||||
<option name="screenY" value="2712" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="33" />
|
||||
<option name="brand" value="google" />
|
||||
<option name="codename" value="felix" />
|
||||
<option name="id" value="felix" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Google" />
|
||||
<option name="name" value="Pixel Fold" />
|
||||
<option name="screenDensity" value="420" />
|
||||
<option name="screenX" value="2208" />
|
||||
<option name="screenY" value="1840" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="34" />
|
||||
<option name="brand" value="google" />
|
||||
<option name="codename" value="felix" />
|
||||
<option name="id" value="felix" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Google" />
|
||||
<option name="name" value="Pixel Fold" />
|
||||
<option name="screenDensity" value="420" />
|
||||
<option name="screenX" value="2208" />
|
||||
<option name="screenY" value="1840" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="33" />
|
||||
<option name="brand" value="google" />
|
||||
<option name="codename" value="felix_camera" />
|
||||
<option name="id" value="felix_camera" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Google" />
|
||||
<option name="name" value="Pixel Fold (Camera-enabled)" />
|
||||
<option name="screenDensity" value="420" />
|
||||
<option name="screenX" value="2208" />
|
||||
<option name="screenY" value="1840" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="34" />
|
||||
<option name="brand" value="motorola" />
|
||||
<option name="codename" value="fogona" />
|
||||
<option name="id" value="fogona" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Motorola" />
|
||||
<option name="name" value="moto g play - 2024" />
|
||||
<option name="screenDensity" value="280" />
|
||||
<option name="screenX" value="720" />
|
||||
<option name="screenY" value="1600" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="34" />
|
||||
<option name="brand" value="motorola" />
|
||||
<option name="codename" value="fogos" />
|
||||
<option name="id" value="fogos" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Motorola" />
|
||||
<option name="name" value="moto g34 5G" />
|
||||
<option name="screenDensity" value="280" />
|
||||
<option name="screenX" value="720" />
|
||||
<option name="screenY" value="1600" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="34" />
|
||||
<option name="brand" value="samsung" />
|
||||
<option name="codename" value="g0q" />
|
||||
<option name="id" value="g0q" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Samsung" />
|
||||
<option name="name" value="SM-S906U1" />
|
||||
<option name="screenDensity" value="450" />
|
||||
<option name="screenX" value="1080" />
|
||||
<option name="screenY" value="2340" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="34" />
|
||||
<option name="brand" value="samsung" />
|
||||
<option name="codename" value="gta9pwifi" />
|
||||
<option name="id" value="gta9pwifi" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Samsung" />
|
||||
<option name="name" value="SM-X210" />
|
||||
<option name="screenDensity" value="240" />
|
||||
<option name="screenX" value="1200" />
|
||||
<option name="screenY" value="1920" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="33" />
|
||||
<option name="brand" value="samsung" />
|
||||
<option name="codename" value="gts7lwifi" />
|
||||
<option name="id" value="gts7lwifi" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Samsung" />
|
||||
<option name="name" value="SM-T870" />
|
||||
<option name="screenDensity" value="340" />
|
||||
<option name="screenX" value="1600" />
|
||||
<option name="screenY" value="2560" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="34" />
|
||||
<option name="brand" value="samsung" />
|
||||
<option name="codename" value="gts7xllite" />
|
||||
<option name="id" value="gts7xllite" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Samsung" />
|
||||
<option name="name" value="SM-T738U" />
|
||||
<option name="screenDensity" value="340" />
|
||||
<option name="screenX" value="1600" />
|
||||
<option name="screenY" value="2560" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="33" />
|
||||
<option name="brand" value="samsung" />
|
||||
<option name="codename" value="gts8uwifi" />
|
||||
<option name="formFactor" value="Tablet" />
|
||||
<option name="id" value="gts8uwifi" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Samsung" />
|
||||
<option name="name" value="Galaxy Tab S8 Ultra" />
|
||||
<option name="screenDensity" value="320" />
|
||||
<option name="screenX" value="1848" />
|
||||
<option name="screenY" value="2960" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="34" />
|
||||
<option name="brand" value="samsung" />
|
||||
<option name="codename" value="gts8wifi" />
|
||||
<option name="formFactor" value="Tablet" />
|
||||
<option name="id" value="gts8wifi" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Samsung" />
|
||||
<option name="name" value="Galaxy Tab S8" />
|
||||
<option name="screenDensity" value="274" />
|
||||
<option name="screenX" value="1600" />
|
||||
<option name="screenY" value="2560" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="34" />
|
||||
<option name="brand" value="samsung" />
|
||||
<option name="codename" value="gts9fe" />
|
||||
<option name="id" value="gts9fe" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Samsung" />
|
||||
<option name="name" value="Galaxy Tab S9 FE 5G" />
|
||||
<option name="screenDensity" value="280" />
|
||||
<option name="screenX" value="1440" />
|
||||
<option name="screenY" value="2304" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="34" />
|
||||
<option name="brand" value="samsung" />
|
||||
<option name="codename" value="gts9wifi" />
|
||||
<option name="id" value="gts9wifi" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Samsung" />
|
||||
<option name="name" value="SM-X710" />
|
||||
<option name="screenDensity" value="340" />
|
||||
<option name="screenX" value="1600" />
|
||||
<option name="screenY" value="2560" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="34" />
|
||||
<option name="brand" value="google" />
|
||||
<option name="codename" value="husky" />
|
||||
<option name="id" value="husky" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Google" />
|
||||
<option name="name" value="Pixel 8 Pro" />
|
||||
<option name="screenDensity" value="390" />
|
||||
<option name="screenX" value="1008" />
|
||||
<option name="screenY" value="2244" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="30" />
|
||||
<option name="brand" value="motorola" />
|
||||
<option name="codename" value="java" />
|
||||
<option name="id" value="java" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Motorola" />
|
||||
<option name="name" value="G20" />
|
||||
<option name="screenDensity" value="280" />
|
||||
<option name="screenX" value="720" />
|
||||
<option name="screenY" value="1600" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="34" />
|
||||
<option name="brand" value="google" />
|
||||
<option name="codename" value="komodo" />
|
||||
<option name="id" value="komodo" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Google" />
|
||||
<option name="name" value="Pixel 9 Pro XL" />
|
||||
<option name="screenDensity" value="360" />
|
||||
<option name="screenX" value="1008" />
|
||||
<option name="screenY" value="2244" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="35" />
|
||||
<option name="brand" value="google" />
|
||||
<option name="codename" value="komodo" />
|
||||
<option name="id" value="komodo" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Google" />
|
||||
<option name="name" value="Pixel 9 Pro XL" />
|
||||
<option name="screenDensity" value="360" />
|
||||
<option name="screenX" value="1008" />
|
||||
<option name="screenY" value="2244" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="34" />
|
||||
<option name="brand" value="motorola" />
|
||||
<option name="codename" value="lion" />
|
||||
<option name="id" value="lion" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Motorola" />
|
||||
<option name="name" value="moto g04" />
|
||||
<option name="screenDensity" value="280" />
|
||||
<option name="screenX" value="720" />
|
||||
<option name="screenY" value="1612" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="33" />
|
||||
<option name="brand" value="google" />
|
||||
<option name="codename" value="lynx" />
|
||||
<option name="id" value="lynx" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Google" />
|
||||
<option name="name" value="Pixel 7a" />
|
||||
<option name="screenDensity" value="420" />
|
||||
<option name="screenX" value="1080" />
|
||||
<option name="screenY" value="2400" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="34" />
|
||||
<option name="brand" value="motorola" />
|
||||
<option name="codename" value="lyriq" />
|
||||
<option name="id" value="lyriq" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Motorola" />
|
||||
<option name="name" value="edge 40" />
|
||||
<option name="screenDensity" value="400" />
|
||||
<option name="screenX" value="1080" />
|
||||
<option name="screenY" value="2400" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="34" />
|
||||
<option name="brand" value="motorola" />
|
||||
<option name="codename" value="manaus" />
|
||||
<option name="id" value="manaus" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Motorola" />
|
||||
<option name="name" value="edge 40 neo" />
|
||||
<option name="screenDensity" value="400" />
|
||||
<option name="screenX" value="1080" />
|
||||
<option name="screenY" value="2400" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="33" />
|
||||
<option name="brand" value="motorola" />
|
||||
<option name="codename" value="maui" />
|
||||
<option name="id" value="maui" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Motorola" />
|
||||
<option name="name" value="moto g play - 2023" />
|
||||
<option name="screenDensity" value="280" />
|
||||
<option name="screenX" value="720" />
|
||||
<option name="screenY" value="1600" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="34" />
|
||||
<option name="brand" value="samsung" />
|
||||
<option name="codename" value="o1q" />
|
||||
<option name="id" value="o1q" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Samsung" />
|
||||
<option name="name" value="Galaxy S21" />
|
||||
<option name="screenDensity" value="421" />
|
||||
<option name="screenX" value="1080" />
|
||||
<option name="screenY" value="2400" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="31" />
|
||||
<option name="brand" value="google" />
|
||||
<option name="codename" value="oriole" />
|
||||
<option name="id" value="oriole" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Google" />
|
||||
<option name="name" value="Pixel 6" />
|
||||
<option name="screenDensity" value="420" />
|
||||
<option name="screenX" value="1080" />
|
||||
<option name="screenY" value="2400" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="35" />
|
||||
<option name="brand" value="samsung" />
|
||||
<option name="codename" value="pa3q" />
|
||||
<option name="id" value="pa3q" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Samsung" />
|
||||
<option name="name" value="Galaxy S25 Ultra" />
|
||||
<option name="screenDensity" value="600" />
|
||||
<option name="screenX" value="1440" />
|
||||
<option name="screenY" value="3120" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="33" />
|
||||
<option name="brand" value="google" />
|
||||
<option name="codename" value="panther" />
|
||||
<option name="id" value="panther" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Google" />
|
||||
<option name="name" value="Pixel 7" />
|
||||
<option name="screenDensity" value="420" />
|
||||
<option name="screenX" value="1080" />
|
||||
<option name="screenY" value="2400" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="34" />
|
||||
<option name="brand" value="samsung" />
|
||||
<option name="codename" value="q5q" />
|
||||
<option name="id" value="q5q" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Samsung" />
|
||||
<option name="name" value="Galaxy Z Fold5" />
|
||||
<option name="screenDensity" value="420" />
|
||||
<option name="screenX" value="1812" />
|
||||
<option name="screenY" value="2176" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="34" />
|
||||
<option name="brand" value="samsung" />
|
||||
<option name="codename" value="q6q" />
|
||||
<option name="id" value="q6q" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Samsung" />
|
||||
<option name="name" value="Galaxy Z Fold6" />
|
||||
<option name="screenDensity" value="420" />
|
||||
<option name="screenX" value="1856" />
|
||||
<option name="screenY" value="2160" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="30" />
|
||||
<option name="brand" value="google" />
|
||||
<option name="codename" value="r11" />
|
||||
<option name="formFactor" value="Wear OS" />
|
||||
<option name="id" value="r11" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Google" />
|
||||
<option name="name" value="Pixel Watch" />
|
||||
<option name="screenDensity" value="320" />
|
||||
<option name="screenX" value="384" />
|
||||
<option name="screenY" value="384" />
|
||||
<option name="type" value="WEAR_OS" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="34" />
|
||||
<option name="brand" value="samsung" />
|
||||
<option name="codename" value="r11q" />
|
||||
<option name="id" value="r11q" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Samsung" />
|
||||
<option name="name" value="SM-S711U" />
|
||||
<option name="screenDensity" value="450" />
|
||||
<option name="screenX" value="1080" />
|
||||
<option name="screenY" value="2340" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="30" />
|
||||
<option name="brand" value="google" />
|
||||
<option name="codename" value="redfin" />
|
||||
<option name="id" value="redfin" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Google" />
|
||||
<option name="name" value="Pixel 5" />
|
||||
<option name="screenDensity" value="440" />
|
||||
<option name="screenX" value="1080" />
|
||||
<option name="screenY" value="2340" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="34" />
|
||||
<option name="brand" value="google" />
|
||||
<option name="codename" value="shiba" />
|
||||
<option name="id" value="shiba" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Google" />
|
||||
<option name="name" value="Pixel 8" />
|
||||
<option name="screenDensity" value="420" />
|
||||
<option name="screenX" value="1080" />
|
||||
<option name="screenY" value="2400" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="34" />
|
||||
<option name="brand" value="samsung" />
|
||||
<option name="codename" value="t2q" />
|
||||
<option name="id" value="t2q" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Samsung" />
|
||||
<option name="name" value="Galaxy S21 Plus" />
|
||||
<option name="screenDensity" value="394" />
|
||||
<option name="screenX" value="1080" />
|
||||
<option name="screenY" value="2400" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="33" />
|
||||
<option name="brand" value="google" />
|
||||
<option name="codename" value="tangorpro" />
|
||||
<option name="formFactor" value="Tablet" />
|
||||
<option name="id" value="tangorpro" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Google" />
|
||||
<option name="name" value="Pixel Tablet" />
|
||||
<option name="screenDensity" value="320" />
|
||||
<option name="screenX" value="1600" />
|
||||
<option name="screenY" value="2560" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="35" />
|
||||
<option name="brand" value="google" />
|
||||
<option name="codename" value="tegu" />
|
||||
<option name="id" value="tegu" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Google" />
|
||||
<option name="name" value="Pixel 9a" />
|
||||
<option name="screenDensity" value="420" />
|
||||
<option name="screenX" value="1080" />
|
||||
<option name="screenY" value="2424" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="34" />
|
||||
<option name="brand" value="google" />
|
||||
<option name="codename" value="tokay" />
|
||||
<option name="default" value="true" />
|
||||
<option name="id" value="tokay" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Google" />
|
||||
<option name="name" value="Pixel 9" />
|
||||
<option name="screenDensity" value="420" />
|
||||
<option name="screenX" value="1080" />
|
||||
<option name="screenY" value="2424" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="35" />
|
||||
<option name="brand" value="google" />
|
||||
<option name="codename" value="tokay" />
|
||||
<option name="default" value="true" />
|
||||
<option name="id" value="tokay" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Google" />
|
||||
<option name="name" value="Pixel 9" />
|
||||
<option name="screenDensity" value="420" />
|
||||
<option name="screenX" value="1080" />
|
||||
<option name="screenY" value="2424" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="34" />
|
||||
<option name="brand" value="samsung" />
|
||||
<option name="codename" value="xcover7" />
|
||||
<option name="id" value="xcover7" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Samsung" />
|
||||
<option name="name" value="SM-G556B" />
|
||||
<option name="screenDensity" value="450" />
|
||||
<option name="screenX" value="1080" />
|
||||
<option name="screenY" value="2408" />
|
||||
</PersistentDeviceSelectionData>
|
||||
</list>
|
||||
</option>
|
||||
</component>
|
||||
<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" />
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="XcodeMetaData" PROJECT_DIR="$PROJECT_DIR$/iosApp" PROJECT_FILE="$PROJECT_DIR$/app/ios/iosApp.xcodeproj" />
|
||||
</project>
|
||||
@@ -1 +0,0 @@
|
||||
/build
|
||||
@@ -0,0 +1,124 @@
|
||||
|
||||
import com.android.build.gradle.tasks.MergeSourceSetFolders
|
||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
import java.io.FileInputStream
|
||||
import java.util.Properties
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.jetbrains.kotlin.android)
|
||||
alias(libs.plugins.compose.compiler)
|
||||
alias(libs.plugins.compose.multiplatform)
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget = JvmTarget.JVM_17
|
||||
}
|
||||
}
|
||||
|
||||
val fixComposeResourcesStructure = tasks.register<Copy>("fixComposeResourcesStructure") {
|
||||
val sharedProject = rootProject.project(":app:shared")
|
||||
|
||||
from(
|
||||
sharedProject
|
||||
.layout
|
||||
.buildDirectory
|
||||
.dir("generated/compose/resourceGenerator/preparedResources/commonMain/composeResources")
|
||||
)
|
||||
|
||||
into(
|
||||
layout
|
||||
.buildDirectory
|
||||
.dir("intermediates/fixed_compose_res/composeResources/ru.fromchat")
|
||||
)
|
||||
|
||||
dependsOn(sharedProject.tasks.matching { it.name.contains("prepareComposeResources", ignoreCase = true) })
|
||||
dependsOn(sharedProject.tasks.matching { it.name.contains("copyNonXmlValueResources", ignoreCase = true) })
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "ru.fromchat"
|
||||
compileSdk = 36
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "ru.fromchat"
|
||||
minSdk = 24
|
||||
targetSdk = 36
|
||||
versionCode = 1
|
||||
versionName = "1.0"
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
create("release") {
|
||||
val keystoreProperties = Properties().apply {
|
||||
load(FileInputStream(file("keys/keystore.properties")))
|
||||
}
|
||||
|
||||
storeFile = file("keys/release.jks")
|
||||
keyAlias = "key0"
|
||||
storePassword = keystoreProperties["storePassword"].toString()
|
||||
keyPassword = keystoreProperties["keyPassword"].toString()
|
||||
enableV3Signing = true
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
packaging {
|
||||
resources {
|
||||
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
sourceSets["main"].apply {
|
||||
assets.srcDirs(
|
||||
layout.buildDirectory.dir("intermediates/fixed_compose_res")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
tasks.withType<MergeSourceSetFolders>().configureEach {
|
||||
dependsOn(fixComposeResourcesStructure)
|
||||
}
|
||||
|
||||
tasks.matching { it.name.contains("lintVital", ignoreCase = true) }.configureEach {
|
||||
dependsOn(fixComposeResourcesStructure)
|
||||
}
|
||||
|
||||
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)
|
||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||
implementation(libs.androidx.core.splashscreen)
|
||||
implementation(libs.androidx.adaptive.android)
|
||||
implementation(libs.ktor.client.okhttp)
|
||||
implementation(libs.ktor.client.core)
|
||||
implementation(libs.slf4j.android)
|
||||
implementation(libs.material)
|
||||
|
||||
implementation(project(":app:shared"))
|
||||
implementation(project(":utils:shared"))
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
# Add project specific ProGuard rules here.
|
||||
# You can control the set of applied configuration files using the
|
||||
# proguardFiles setting in build.gradle.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# If your project uses WebView with JS, uncomment the following
|
||||
# and specify the fully qualified class name to the JavaScript interface
|
||||
# class:
|
||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||
# public *;
|
||||
#}
|
||||
|
||||
# Uncomment this to preserve the line number information for
|
||||
# debugging stack traces.
|
||||
-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
||||
@@ -1,6 +1,8 @@
|
||||
<?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.ACCESS_NETWORK_STATE" />
|
||||
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
@@ -9,19 +11,19 @@
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.FromChat"
|
||||
android:usesCleartextTraffic="true">
|
||||
android:theme="@style/Theme.FromChat.SplashScreen"
|
||||
android:usesCleartextTraffic="true"
|
||||
android:name=".App"
|
||||
android:networkSecurityConfig="@xml/network_security_config">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.FromChat">
|
||||
android:theme="@style/Theme.FromChat.SplashScreen"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
After Width: | Height: | Size: 121 KiB |
@@ -0,0 +1,11 @@
|
||||
package ru.fromchat
|
||||
|
||||
import android.app.Application
|
||||
import com.pr0gramm3r101.utils.UtilsLibrary
|
||||
|
||||
class App: Application() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
UtilsLibrary.init(this)
|
||||
}
|
||||
}
|
||||
@@ -4,12 +4,15 @@ import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import ru.fromchat.ui.App
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
installSplashScreen()
|
||||
enableEdgeToEdge()
|
||||
|
||||
setContent {
|
||||
App()
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<vector
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="200dp"
|
||||
android:height="200dp"
|
||||
android:viewportWidth="1000"
|
||||
android:viewportHeight="1000">
|
||||
<group
|
||||
android:scaleX="0.7"
|
||||
android:scaleY="0.7"
|
||||
android:translateX="150"
|
||||
android:translateY="150">
|
||||
<path
|
||||
android:pathData="M494,221.5c43.2,0 85.8,0.1 128.4,-0 42.7,-0.1 81.5,12.2 114.8,39.8 30.5,25.2 49.7,58 57,97.6 1.8,9.4 2.6,19.1 2.6,28.7 0.3,47.8 0,95.6 0.1,143.4 0.1,32.6 -9.9,61.9 -28.3,88.2 -21.2,30.3 -48.8,52.2 -83.2,64.2 -12.9,4.5 -26.9,7.5 -40.5,7.6 -95.6,0.6 -191.2,0.2 -286.8,0.6 -5.2,0 -11.3,2.8 -15.6,6.2 -22.2,17.2 -43.8,35.1 -65.8,52.5 -10.9,8.6 -21.9,17.4 -33.8,24.4 -14.4,8.4 -31.4,2.4 -37.5,-12.1 -1.8,-4.1 -2.1,-9.2 -2.1,-13.8 -0.1,-97.8 -0.2,-195.6 -0.2,-293.4 0,-24.8 -0.9,-49.6 0.3,-74.4 1.8,-37.5 14.7,-71 39.1,-99.2 31.7,-36.6 71.7,-57 119.4,-59.2 38.1,-1.7 76.3,-0.9 114.5,-1.1 5.6,-0 11.1,0 17.3,0M330.4,269.1c-16,5.5 -30.2,14.3 -43,25.6 -30.2,26.8 -46.7,60.7 -46.8,101.7 -0.4,108 -0.2,216 -0.2,324 0,2.5 0.2,4.9 0.4,8.1 1.5,-0.8 2.4,-1.1 3.1,-1.6 22.5,-18 45.5,-35.4 67.3,-54.3 15.9,-13.9 33.5,-19.1 54,-19 88.1,0.4 176.1,0.1 264.2,0.2 22.8,0 44,-6.1 63.9,-17 22.3,-12.2 40.4,-29.1 52.5,-52.1 11.1,-21.2 14.6,-44 14.3,-67.8 -0.4,-42.7 0.2,-85.3 -1.3,-128 -1.1,-34.2 -15.6,-63.3 -39.1,-87.4 -24.5,-25.1 -54,-39.6 -88.9,-39.8 -84,-0.4 -168.1,-0.1 -252.1,-0.2 -16.3,0 -32.1,2 -48.4,7.7h0Z"
|
||||
android:fillColor="#fff" />
|
||||
<path
|
||||
android:pathData="M401.4,512.8c-30.4,8.6 -59,-5.7 -73.3,-29.9 -13.2,-22.3 -11,-51.9 4.9,-72.2 23.3,-29.7 68.1,-35.5 96.6,-8.4 23.3,22.2 27,58.5 7,85.5 -8.9,12.1 -20.5,20.5 -35.2,25.1h0Z"
|
||||
android:fillColor="#fff" />
|
||||
<path
|
||||
android:pathData="M484.5,471.4c-9.1,-6.2 -12.4,-14.2 -9.9,-22.8 2,-7 10.4,-13.8 20,-13.7 39.3,0.4 78.7,0.2 118,0.2h7.3c0.3,-0.5 0.5,-1 0.8,-1.5 -3.5,-3.7 -6.9,-7.5 -10.4,-11.2 -7.9,-8.4 -8.3,-20 -0.9,-27.5 8.2,-8.2 21.2,-8.7 29.1,-0.7 15.2,15.3 30.3,30.7 45.3,46.1 8.1,8.4 8.4,20.7 0.3,29.1 -16.3,16.7 -32.8,33.1 -49.3,49.5 -6.1,6.1 -18.3,5.5 -24.8,-0.8 -6.9,-6.7 -8,-17.9 -2,-25 4.5,-5.3 9.7,-10.1 14.5,-15.1 1.1,-1.2 2.2,-2.4 4.2,-4.6 -3.3,-0.2 -5.3,-0.4 -7.3,-0.4 -42,0 -84.1,0.1 -126.1,-0 -2.8,0 -5.6,-0.9 -8.8,-1.5h-0Z"
|
||||
android:fillColor="#fff" />
|
||||
</group>
|
||||
</vector>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@drawable/logo" />
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@drawable/logo" />
|
||||
</adaptive-icon>
|
||||
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 8.0 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 8.0 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 18 KiB |
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.FromChat.SplashScreen" parent="Theme.SplashScreen">
|
||||
<item name="windowSplashScreenBackground">?attr/colorSurface</item>
|
||||
<item name="android:windowSplashScreenBackground">?attr/colorSurface</item>
|
||||
<item name="postSplashScreenTheme">@style/Theme.FromChat</item>
|
||||
</style>
|
||||
</resources>
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<resources>
|
||||
<string name="app_name" translatable="false">FromChat</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.FromChat" parent="Theme.Material3.DayNight.NoActionBar" />
|
||||
|
||||
<style name="Theme.FromChat.SplashScreen" parent="Theme.SplashScreen">
|
||||
<item name="windowSplashScreenBackground">?attr/colorSurface</item>
|
||||
<item name="postSplashScreenTheme">@style/Theme.FromChat</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<network-security-config xmlns:tools="http://schemas.android.com/tools">
|
||||
<base-config cleartextTrafficPermitted="true"
|
||||
tools:ignore="InsecureBaseConfiguration">
|
||||
<trust-anchors>
|
||||
<certificates src="system" />
|
||||
</trust-anchors>
|
||||
</base-config>
|
||||
<domain-config cleartextTrafficPermitted="true">
|
||||
<domain includeSubdomains="true">localhost</domain>
|
||||
<domain includeSubdomains="true">127.0.0.1</domain>
|
||||
<domain includeSubdomains="true">10.0.2.2</domain>
|
||||
</domain-config>
|
||||
</network-security-config>
|
||||
@@ -1,74 +0,0 @@
|
||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget.set(JvmTarget.JVM_11)
|
||||
}
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "ru.fromchat"
|
||||
compileSdk = 36
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "ru.fromchat"
|
||||
minSdk = 24
|
||||
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_11
|
||||
targetCompatibility = JavaVersion.VERSION_11
|
||||
}
|
||||
|
||||
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.ui)
|
||||
implementation(libs.androidx.ui.graphics)
|
||||
implementation(libs.androidx.ui.tooling.preview)
|
||||
implementation(libs.androidx.material3)
|
||||
implementation(libs.androidx.navigation.compose)
|
||||
implementation(libs.androidx.material.icons.extended)
|
||||
|
||||
implementation(libs.ktor.client.core)
|
||||
implementation(libs.ktor.client.okhttp)
|
||||
implementation(libs.ktor.client.websockets)
|
||||
implementation(libs.ktor.client.logging)
|
||||
implementation(libs.ktor.client.content.negotiation)
|
||||
implementation(libs.ktor.serialization.json)
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
|
||||
testImplementation(libs.junit)
|
||||
androidTestImplementation(libs.androidx.junit)
|
||||
androidTestImplementation(libs.androidx.espresso.core)
|
||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||
androidTestImplementation(libs.androidx.ui.test.junit4)
|
||||
debugImplementation(libs.androidx.ui.tooling)
|
||||
debugImplementation(libs.androidx.ui.test.manifest)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
TEAM_ID=82MWK23YNM
|
||||
BUNDLE_ID=ru.fromchat.app
|
||||
APP_NAME=FromChat
|
||||
@@ -0,0 +1,408 @@
|
||||
// !$*UTF8*$!
|
||||
{
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 56;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
058557BB273AAA24004C7B11 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 058557BA273AAA24004C7B11 /* Assets.xcassets */; };
|
||||
058557D9273AAEEB004C7B11 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 058557D8273AAEEB004C7B11 /* Preview Assets.xcassets */; };
|
||||
2152FB042600AC8F00CF470E /* iOSApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2152FB032600AC8F00CF470E /* iOSApp.swift */; };
|
||||
7555FF83242A565900829871 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7555FF82242A565900829871 /* ContentView.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
058557BA273AAA24004C7B11 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
058557D8273AAEEB004C7B11 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = "<group>"; };
|
||||
2152FB032600AC8F00CF470E /* iOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSApp.swift; sourceTree = "<group>"; };
|
||||
7555FF7B242A565900829871 /* FromChat.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = FromChat.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
7555FF82242A565900829871 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
|
||||
7555FF8C242A565B00829871 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
AB3632DC29227652001CCB65 /* Config.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Config.xcconfig; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
B92378962B6B1156000C7307 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
058557D7273AAEEB004C7B11 /* Preview Content */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
058557D8273AAEEB004C7B11 /* Preview Assets.xcassets */,
|
||||
);
|
||||
path = "Preview Content";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
42799AB246E5F90AF97AA0EF /* Frameworks */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
);
|
||||
name = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
7555FF72242A565900829871 = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
AB1DB47929225F7C00F7AF9C /* Configuration */,
|
||||
7555FF7D242A565900829871 /* iosApp */,
|
||||
7555FF7C242A565900829871 /* Products */,
|
||||
42799AB246E5F90AF97AA0EF /* Frameworks */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
7555FF7C242A565900829871 /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
7555FF7B242A565900829871 /* FromChat.app */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
7555FF7D242A565900829871 /* iosApp */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
058557BA273AAA24004C7B11 /* Assets.xcassets */,
|
||||
7555FF82242A565900829871 /* ContentView.swift */,
|
||||
7555FF8C242A565B00829871 /* Info.plist */,
|
||||
2152FB032600AC8F00CF470E /* iOSApp.swift */,
|
||||
058557D7273AAEEB004C7B11 /* Preview Content */,
|
||||
);
|
||||
path = iosApp;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
AB1DB47929225F7C00F7AF9C /* Configuration */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
AB3632DC29227652001CCB65 /* Config.xcconfig */,
|
||||
);
|
||||
path = Configuration;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
7555FF7A242A565900829871 /* iosApp */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 7555FFA5242A565B00829871 /* Build configuration list for PBXNativeTarget "iosApp" */;
|
||||
buildPhases = (
|
||||
F36B1CEB2AD83DDC00CB74D5 /* Compile Kotlin Framework */,
|
||||
7555FF77242A565900829871 /* Sources */,
|
||||
B92378962B6B1156000C7307 /* Frameworks */,
|
||||
7555FF79242A565900829871 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
name = iosApp;
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = iosApp;
|
||||
productReference = 7555FF7B242A565900829871 /* FromChat.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
7555FF73242A565900829871 /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = YES;
|
||||
LastSwiftUpdateCheck = 1130;
|
||||
LastUpgradeCheck = 2610;
|
||||
ORGANIZATIONNAME = orgName;
|
||||
TargetAttributes = {
|
||||
7555FF7A242A565900829871 = {
|
||||
CreatedOnToolsVersion = 11.3.1;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = 7555FF76242A565900829871 /* Build configuration list for PBXProject "iosApp" */;
|
||||
compatibilityVersion = "Xcode 14.0";
|
||||
developmentRegion = en;
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
en,
|
||||
Base,
|
||||
);
|
||||
mainGroup = 7555FF72242A565900829871;
|
||||
packageReferences = (
|
||||
);
|
||||
productRefGroup = 7555FF7C242A565900829871 /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
7555FF7A242A565900829871 /* iosApp */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
7555FF79242A565900829871 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
058557D9273AAEEB004C7B11 /* Preview Assets.xcassets in Resources */,
|
||||
058557BB273AAA24004C7B11 /* Assets.xcassets in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXShellScriptBuildPhase section */
|
||||
F36B1CEB2AD83DDC00CB74D5 /* Compile Kotlin Framework */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
alwaysOutOfDate = 1;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
);
|
||||
inputPaths = (
|
||||
);
|
||||
name = "Compile Kotlin Framework";
|
||||
outputFileListPaths = (
|
||||
);
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "if [ \"YES\" = \"$OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED\" ]; then\n echo \"Skipping Gradle build task invocation due to OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED environment variable set to \\\"YES\\\"\"\n exit 0\nfi\ncd \"$SRCROOT/../../\"\n./gradlew :app:shared:embedAndSignAppleFrameworkForXcode\n";
|
||||
};
|
||||
/* End PBXShellScriptBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
7555FF77242A565900829871 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
2152FB042600AC8F00CF470E /* iOSApp.swift in Sources */,
|
||||
7555FF83242A565900829871 /* ContentView.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
7555FFA3242A565B00829871 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = AB3632DC29227652001CCB65 /* Config.xcconfig */;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
GCC_DYNAMIC_NO_PIC = NO;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_OPTIMIZATION_LEVEL = 0;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||
"DEBUG=1",
|
||||
"$(inherited)",
|
||||
);
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.3;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
7555FFA4242A565B00829871 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = AB3632DC29227652001CCB65 /* Config.xcconfig */;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.3;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
MTL_FAST_MATH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
7555FFA6242A565B00829871 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\"";
|
||||
DEVELOPMENT_TEAM = 82MWK23YNM;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
FRAMEWORK_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"$(SRCROOT)/../shared/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)",
|
||||
);
|
||||
INFOPLIST_FILE = iosApp/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = FromChat;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.3;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "${BUNDLE_ID}${TEAM_ID}";
|
||||
PRODUCT_NAME = "${APP_NAME}";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
SUPPORTS_MACCATALYST = NO;
|
||||
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
7555FFA7242A565B00829871 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\"";
|
||||
DEVELOPMENT_TEAM = 82MWK23YNM;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
FRAMEWORK_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"$(SRCROOT)/../shared/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)",
|
||||
);
|
||||
INFOPLIST_FILE = iosApp/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = FromChat;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.3;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "${BUNDLE_ID}${TEAM_ID}";
|
||||
PRODUCT_NAME = "${APP_NAME}";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
SUPPORTS_MACCATALYST = NO;
|
||||
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
7555FF76242A565900829871 /* Build configuration list for PBXProject "iosApp" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
7555FFA3242A565B00829871 /* Debug */,
|
||||
7555FFA4242A565B00829871 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
7555FFA5242A565B00829871 /* Build configuration list for PBXNativeTarget "iosApp" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
7555FFA6242A565B00829871 /* Debug */,
|
||||
7555FFA7242A565B00829871 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
};
|
||||
rootObject = 7555FF73242A565900829871 /* Project object */;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "logo_square.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 184 KiB |
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import UIKit
|
||||
import SwiftUI
|
||||
import ComposeApp
|
||||
|
||||
struct ComposeView: UIViewControllerRepresentable {
|
||||
func makeUIViewController(context: Context) -> UIViewController {
|
||||
MainViewControllerKt.MainViewController()
|
||||
}
|
||||
|
||||
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
|
||||
}
|
||||
|
||||
struct ContentView: View {
|
||||
var body: some View {
|
||||
ComposeView()
|
||||
.ignoresSafeArea(.all) // Compose has own keyboard handler
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||
<true/>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>0.2.1</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>021</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
<false/>
|
||||
</dict>
|
||||
<key>UILaunchScreen</key>
|
||||
<dict/>
|
||||
<key>UIRequiredDeviceCapabilities</key>
|
||||
<array>
|
||||
<string>armv7</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct iOSApp: App {
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
|
||||
import com.android.build.api.dsl.androidLibrary
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.kotlin.multiplatform)
|
||||
alias(libs.plugins.compose.multiplatform)
|
||||
alias(libs.plugins.compose.compiler)
|
||||
alias(libs.plugins.android.kotlin.multiplatform.library)
|
||||
kotlin("plugin.serialization") version "1.9.22"
|
||||
}
|
||||
|
||||
kotlin {
|
||||
androidLibrary {
|
||||
namespace = "ru.fromchat.shared"
|
||||
minSdk = 24
|
||||
compileSdk = 36
|
||||
}
|
||||
|
||||
compilerOptions {
|
||||
freeCompilerArgs.addAll("-Xexpect-actual-classes")
|
||||
}
|
||||
|
||||
listOf(
|
||||
iosX64(),
|
||||
iosArm64(),
|
||||
iosSimulatorArm64()
|
||||
).forEach { iosTarget ->
|
||||
iosTarget.binaries.framework {
|
||||
baseName = "ComposeApp"
|
||||
isStatic = true
|
||||
}
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
all {
|
||||
languageSettings {
|
||||
optIn("kotlin.RequiresOptIn")
|
||||
}
|
||||
}
|
||||
|
||||
androidMain.dependencies {
|
||||
implementation(libs.ktor.client.okhttp)
|
||||
}
|
||||
|
||||
commonMain.dependencies {
|
||||
implementation(compose.runtime)
|
||||
implementation(compose.foundation)
|
||||
implementation(compose.material3)
|
||||
implementation(compose.ui)
|
||||
implementation(compose.components.resources)
|
||||
implementation(compose.components.uiToolingPreview)
|
||||
implementation(libs.constraintlayout)
|
||||
implementation(libs.navigation.compose)
|
||||
implementation(compose.materialIconsExtended)
|
||||
implementation(libs.haze)
|
||||
implementation(libs.haze.materials)
|
||||
|
||||
// Serialization
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
|
||||
implementation(libs.kotlinx.io.core)
|
||||
|
||||
// Ktor - force version 2.3.12 to avoid conflicts with Coil 3's Ktor 3
|
||||
implementation(libs.ktor.client.core)
|
||||
implementation(libs.ktor.client.content.negotiation)
|
||||
implementation(libs.ktor.client.serialization.kotlinx.json)
|
||||
implementation(libs.ktor.client.websockets)
|
||||
implementation(libs.ktor.client.logging)
|
||||
|
||||
// Datetime
|
||||
implementation(libs.kotlinx.datetime)
|
||||
|
||||
// Coil for image loading (multiplatform)
|
||||
implementation(libs.coil.compose)
|
||||
implementation(libs.coil.network.ktor3)
|
||||
|
||||
implementation(project(":utils:shared"))
|
||||
}
|
||||
|
||||
iosMain.dependencies {
|
||||
implementation(libs.jetbrains.kotlinx.io.bytestring)
|
||||
implementation(libs.jetbrains.kotlinx.coroutines.core)
|
||||
implementation(libs.ktor.client.darwin)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
compose.resources {
|
||||
publicResClass = true
|
||||
packageOfResClass = "ru.fromchat"
|
||||
generateResClass = auto
|
||||
}
|
||||
|
||||
tasks.register("generateResourceAccessors") {
|
||||
dependsOn(
|
||||
*(
|
||||
tasks.filter {
|
||||
it.name.startsWith("generateResourceAccessors") &&
|
||||
!it.name.matches("^(:${project.name})?generateResourceAccessors$".toRegex())
|
||||
}.toTypedArray()
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package ru.fromchat.api
|
||||
|
||||
import android.util.Log
|
||||
import io.ktor.client.plugins.logging.Logger
|
||||
|
||||
/**
|
||||
* Custom Android logger for Ktor that uses Android's Log system
|
||||
*/
|
||||
object AndroidKtorLogger : Logger {
|
||||
private const val TAG = "Ktor"
|
||||
|
||||
override fun log(message: String) {
|
||||
Log.d(TAG, message)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
package ru.fromchat.api
|
||||
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.HttpClientConfig
|
||||
import io.ktor.client.engine.okhttp.OkHttp
|
||||
|
||||
actual fun createPlatformHttpClient(block: HttpClientConfig<*>.() -> Unit) = HttpClient(OkHttp, block)
|
||||
@@ -0,0 +1,22 @@
|
||||
package ru.fromchat.core
|
||||
|
||||
import android.util.Log
|
||||
|
||||
actual object Logger {
|
||||
actual fun d(tag: String, message: String, throwable: Throwable?) {
|
||||
Log.d(tag, message, throwable)
|
||||
}
|
||||
|
||||
actual fun i(tag: String, message: String, throwable: Throwable?) {
|
||||
Log.i(tag, message, throwable)
|
||||
}
|
||||
|
||||
actual fun w(tag: String, message: String, throwable: Throwable?) {
|
||||
Log.w(tag, message, throwable)
|
||||
}
|
||||
|
||||
actual fun e(tag: String, message: String, throwable: Throwable?) {
|
||||
Log.e(tag, message, throwable)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package ru.fromchat.ui
|
||||
|
||||
import android.os.Build
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
||||
@Composable
|
||||
actual fun getColorScheme(darkTheme: Boolean, dynamicColor: Boolean) =
|
||||
if (dynamicColor && Build.VERSION.SDK_INT >= 31) {
|
||||
if (darkTheme) dynamicDarkColorScheme(LocalContext.current)
|
||||
else dynamicLightColorScheme(LocalContext.current)
|
||||
} else {
|
||||
if (darkTheme) darkColorScheme()
|
||||
else lightColorScheme()
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<vector
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="1024"
|
||||
android:viewportHeight="1024">
|
||||
<path
|
||||
android:pathData="M0 0 h 1024 v 1024 h -1024 L0 0"
|
||||
android:fillColor="#4484f4"
|
||||
android:fillType="evenOdd"
|
||||
android:strokeColor="#00000000" />
|
||||
<path
|
||||
android:pathData="M500.1,129.1C497.2,129.6 491.7,131.1 488,132.4C483.9,133.8 427.2,165.8 339.9,216C262.1,260.7 196.7,298.5 194.5,300C188.8,303.9 178.1,315.6 174.2,322.2C170,329.3 166.4,339.6 165,348.5C164.3,353.3 164,407.1 164.2,522L164.5,688.5 167.1,696.2C172.4,711.6 183,726 195.3,734.3C205,741 481.1,890.9 487.3,892.9C504.8,898.6 523.3,897.9 539.4,891.1C547.3,887.7 827.3,721.9 833.7,716.8C841.4,710.7 848.2,701.9 853.1,691.5C860.4,675.9 860,686.3 860,509.5C860,331.6 860.5,344.2 852.5,327.5C846.2,314.4 837,304.3 824.3,296.7C811.8,289.2 549.2,138.1 544.5,135.8C532.1,129.5 513.5,126.7 500.1,129.1M502.5,149.4C502,149.6 499,150.6 496,151.5C489,153.7 208.7,314.7 202.8,320C200.4,322.1 198.7,324 199,324.3C199.3,324.6 225.1,339.5 256.5,357.5C292.1,377.9 313.9,389.9 314.6,389.3C315.3,388.9 317.3,387.3 319.1,385.8C324.2,381.9 492.9,284.6 498.6,282.4C505.5,279.7 519.6,279.9 526.5,282.7C532.9,285.2 702.2,383.2 707.2,387.3L710.9,390.3 768.5,357.4L826.1,324.5 821.6,320.4C816.6,315.8 540.6,156.2 530.5,152.1C525.7,150.2 522.3,149.6 514,149.4C508.2,149.2 503,149.2 502.5,149.4M186.2,347.1C184.6,353.8 184.5,680.2 186.1,687C188.1,696 193,704.2 200.4,711.4C207.3,718.1 211.3,720.4 348.5,795.1C426,837.4 491.4,872.6 493.9,873.5C502.9,876.5 502,883.5 502,809.6L502,744.2 497.7,742.7C493.2,741.2 327.2,650.6 320.4,645.9C314.5,641.9 310.2,636.6 306.6,629L303.5,622.5 303.5,515.3L303.5,408.2 245.8,375.1C214,356.9 187.9,342 187.7,342C187.5,342 186.8,344.3 186.2,347.1M779.1,375L721.8,407.9 721.4,507.7C721.1,562.6 720.7,609.3 720.4,611.6C719.6,618 714.5,627.6 709.2,632.8C705.7,636.1 679.1,652.4 617.9,688.7C570.4,716.9 529.3,740.8 526.7,742L522,744.1 522,809.6L522,875 524.8,874.4C530.2,873.2 543.7,865.4 679.1,785.5C828,697.6 826.1,698.9 832.8,686.3C839.7,673.1 839.3,685.9 839.4,509.5C839.5,370.5 839.3,350 838,346.4L836.5,342.2 779.1,375M504.5,357.1C502.4,357.6 474.2,373.2 442,391.8C378.9,428.2 376.8,429.6 373,439.6C371,445.1 371,446.4 371.2,515.8L371.5,586.5 373.8,591.1C375,593.7 377.8,597.5 380,599.7C385.5,605.1 497.7,666.5 505.1,668.1C511.3,669.4 518.9,668.6 524.3,666C533.3,661.6 639.5,598.2 643.3,594.9C648.3,590.6 651.6,584.3 653.1,576.7C653.7,573.1 654,548.2 653.8,507.8L653.5,444.5 650.2,437.8C648.4,434.2 645.5,429.9 643.7,428.3C640.3,425.4 529.5,361 523.4,358.4C518.9,356.5 509.5,355.8 504.5,357.1"
|
||||
android:fillColor="#fafbfc"
|
||||
android:fillType="evenOdd"
|
||||
android:strokeColor="#00000000" />
|
||||
</vector>
|
||||
@@ -0,0 +1,8 @@
|
||||
<resources>
|
||||
<!-- Typing Indicators -->
|
||||
<string name="typing_single">%1$s печатает…</string>
|
||||
<string name="typing_two">%1$s и %2$s печатают…</string>
|
||||
<string name="typing_many">%1$s, %2$s и еще %3$d печатают…</string>
|
||||
</resources>
|
||||
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
<resources>
|
||||
<!-- App -->
|
||||
<string name="app_name">FromChat</string>
|
||||
|
||||
<!-- Navigation -->
|
||||
<string name="back">Back</string>
|
||||
<string name="settings">Settings</string>
|
||||
<string name="home">Home</string>
|
||||
<string name="about">About app</string>
|
||||
<string name="app_desc">A 100% free, open source and private messenger.</string>
|
||||
|
||||
<!-- Authentication -->
|
||||
<string name="welcome">Welcome!</string>
|
||||
<string name="login">Sign In</string>
|
||||
<string name="login_d">Sign in to your account</string>
|
||||
<string name="register">Registration</string>
|
||||
<string name="register_d">Create a new account</string>
|
||||
<string name="register_button">Register</string>
|
||||
<string name="username">Username</string>
|
||||
<string name="password">Password</string>
|
||||
<string name="confirm_password">Confirm Password</string>
|
||||
<string name="display_name">Display Name</string>
|
||||
<string name="display_name_error">Display name must be between 1 and 64 characters</string>
|
||||
|
||||
<!-- Validation Errors -->
|
||||
<string name="fill_all_fields">Please fill in all fields</string>
|
||||
<string name="username_length_error">Username must be between 3 and 20 characters</string>
|
||||
<string name="password_length_error">Password must be between 5 and 50 characters</string>
|
||||
<string name="passwords_dont_match">Passwords do not match</string>
|
||||
|
||||
<!-- Main Screen -->
|
||||
<string name="chats">Chats</string>
|
||||
<string name="contacts">Contacts</string>
|
||||
<string name="dms">Direct Messages</string>
|
||||
<string name="coming_soon">Coming soon...</string>
|
||||
<string name="public_chat">General Chat</string>
|
||||
<string name="chat_last_mesaage">You: Last message</string>
|
||||
<string name="message_placeholder">Type a message...</string>
|
||||
|
||||
<!-- Server Configuration -->
|
||||
<string name="server_config_title">Server Configuration</string>
|
||||
<string name="server_config_subtitle">Enter server details to connect</string>
|
||||
<string name="server_url_label">Server URL</string>
|
||||
<string name="server_url_hint">example.com</string>
|
||||
<string name="https_enabled">Use HTTPS</string>
|
||||
<string name="save_continue">Save & Continue</string>
|
||||
|
||||
|
||||
<!-- Settings -->
|
||||
<string name="change_server">Change Server</string>
|
||||
<string name="change_server_d">Changes the FromChat instance you are connecting to. This will log you out of your account.</string>
|
||||
<string name="logout">Logout</string>
|
||||
<string name="materialYou">Enable Material You theming</string>
|
||||
<string name="materialYou_d">Enable the dynamic theme based on your wallpaper. Works only on Android 12 or higher.</string>
|
||||
<string name="theme">Theme</string>
|
||||
<string name="as_system">As system</string>
|
||||
<string name="light">Light</string>
|
||||
<string name="dark">Dark</string>
|
||||
|
||||
<!-- Error Messages -->
|
||||
<string name="error_unexpected">Unexpected error</string>
|
||||
<string name="error_invalid_credentials">Invalid username or password</string>
|
||||
<string name="error_connection">Connection error</string>
|
||||
<string name="error_unknown">An unknown error occurred</string>
|
||||
|
||||
<!-- Typing Indicators -->
|
||||
<string name="typing_single">%1$s is typing…</string>
|
||||
<string name="typing_two">%1$s and %2$s are typing…</string>
|
||||
<string name="typing_many">%1$s, %2$s and %3$d more are typing…</string>
|
||||
|
||||
<string name="more">More</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,18 @@
|
||||
package ru.fromchat
|
||||
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
import kotlinx.datetime.format.char
|
||||
|
||||
val DATETIME_FORMAT = LocalDateTime.Format {
|
||||
day()
|
||||
char('.')
|
||||
monthNumber()
|
||||
char('.')
|
||||
year()
|
||||
|
||||
char(' ')
|
||||
|
||||
hour()
|
||||
char(':')
|
||||
minute()
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
package ru.fromchat.api
|
||||
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.call.body
|
||||
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
||||
import io.ktor.client.plugins.logging.LogLevel
|
||||
import io.ktor.client.plugins.logging.Logger
|
||||
import io.ktor.client.plugins.logging.Logging
|
||||
import io.ktor.client.plugins.logging.SIMPLE
|
||||
import io.ktor.client.plugins.websocket.WebSockets
|
||||
import io.ktor.client.plugins.websocket.pingInterval
|
||||
import io.ktor.client.request.bearerAuth
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.client.request.parameter
|
||||
import io.ktor.client.request.post
|
||||
import io.ktor.client.request.setBody
|
||||
import io.ktor.http.ContentType
|
||||
import io.ktor.http.contentType
|
||||
import io.ktor.serialization.kotlinx.json.json
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.encodeToJsonElement
|
||||
import ru.fromchat.core.config.Config
|
||||
import ru.fromchat.utils.failOnError
|
||||
import kotlin.concurrent.Volatile
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
/**
|
||||
* Creates a platform-specific HTTP client that supports WebSockets
|
||||
* The config block is applied to configure plugins like WebSockets, JSON, etc.
|
||||
*/
|
||||
expect fun createPlatformHttpClient(
|
||||
block: io.ktor.client.HttpClientConfig<*>.() -> Unit = {}
|
||||
): HttpClient
|
||||
|
||||
object ApiClient {
|
||||
val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
isLenient = true
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
val http = createPlatformHttpClient {
|
||||
install(ContentNegotiation) {
|
||||
json(json)
|
||||
}
|
||||
|
||||
install(Logging) {
|
||||
logger = Logger.SIMPLE
|
||||
level = LogLevel.INFO
|
||||
}
|
||||
|
||||
install(WebSockets) {
|
||||
pingInterval = 5000.milliseconds // Send a ping every 5 seconds to keep the connection alive
|
||||
}
|
||||
}
|
||||
|
||||
@Volatile
|
||||
var token: String? = null
|
||||
|
||||
@Volatile
|
||||
var user: User? = null
|
||||
|
||||
suspend fun login(request: LoginRequest) =
|
||||
http
|
||||
.post("${Config.apiBaseUrl}/login") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(request.also { ru.fromchat.core.Logger.d("ApiClient", "Login request: $it") })
|
||||
}
|
||||
.failOnError()
|
||||
.body<LoginResponse>()
|
||||
.also {
|
||||
token = it.token
|
||||
user = it.user
|
||||
}
|
||||
|
||||
suspend fun register(request: RegisterRequest) =
|
||||
http
|
||||
.post("${Config.apiBaseUrl}/register") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(request)
|
||||
}
|
||||
.failOnError()
|
||||
|
||||
suspend fun getMessages(limit: Int = 50, beforeId: Int? = null) =
|
||||
http
|
||||
.get("${Config.apiBaseUrl}/get_messages") {
|
||||
contentType(ContentType.Application.Json)
|
||||
bearerAuth(token ?: throw IllegalStateException("Not authenticated"))
|
||||
parameter("limit", limit)
|
||||
beforeId?.let { parameter("before_id", it) }
|
||||
}
|
||||
.failOnError()
|
||||
.body<MessagesResponse>()
|
||||
|
||||
suspend fun send(message: String) =
|
||||
http
|
||||
.post("${Config.apiBaseUrl}/send_message") {
|
||||
contentType(ContentType.Application.Json)
|
||||
bearerAuth(token!!)
|
||||
setBody(SendMessageRequest(message))
|
||||
}
|
||||
.failOnError()
|
||||
.body<SendMessageResponse>()
|
||||
|
||||
|
||||
suspend fun logout(authToken: String) {
|
||||
// Don't throw on logout errors, just try to logout
|
||||
try {
|
||||
http.get("${Config.apiBaseUrl}/logout") {
|
||||
bearerAuth(authToken)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// Ignore logout errors
|
||||
}
|
||||
}
|
||||
|
||||
// WebSocket send helpers
|
||||
suspend fun sendMessage(content: String, replyToId: Int? = null, clientMessageId: String? = null) {
|
||||
val token = token ?: throw IllegalStateException("Not authenticated")
|
||||
WebSocketManager.send(
|
||||
WebSocketMessage(
|
||||
type = "sendMessage",
|
||||
credentials = WebSocketCredentials(
|
||||
scheme = "Bearer",
|
||||
credentials = token
|
||||
),
|
||||
data = json.encodeToJsonElement(
|
||||
WebSocketSendMessageRequest(
|
||||
content = content,
|
||||
reply_to_id = replyToId,
|
||||
client_message_id = clientMessageId
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun editMessage(messageId: Int, content: String) {
|
||||
val token = token ?: throw IllegalStateException("Not authenticated")
|
||||
WebSocketManager.send(
|
||||
WebSocketMessage(
|
||||
type = "editMessage",
|
||||
credentials = WebSocketCredentials(
|
||||
scheme = "Bearer",
|
||||
credentials = token
|
||||
),
|
||||
data = json.encodeToJsonElement(
|
||||
WebSocketEditMessageRequest(
|
||||
message_id = messageId,
|
||||
content = content
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun deleteMessage(messageId: Int) {
|
||||
val token = token ?: throw IllegalStateException("Not authenticated")
|
||||
WebSocketManager.send(
|
||||
WebSocketMessage(
|
||||
type = "deleteMessage",
|
||||
credentials = WebSocketCredentials(
|
||||
scheme = "Bearer",
|
||||
credentials = token
|
||||
),
|
||||
data = json.encodeToJsonElement(
|
||||
WebSocketDeleteMessageRequest(
|
||||
message_id = messageId
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun sendTyping() {
|
||||
val token = token ?: throw IllegalStateException("Not authenticated")
|
||||
try {
|
||||
WebSocketManager.send(
|
||||
WebSocketMessage(
|
||||
type = "typing",
|
||||
credentials = WebSocketCredentials(
|
||||
scheme = "Bearer",
|
||||
credentials = token
|
||||
)
|
||||
)
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
// Silently ignore if WebSocket is not connected yet
|
||||
// Typing indicators are not critical
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun sendStopTyping() {
|
||||
val token = token ?: throw IllegalStateException("Not authenticated")
|
||||
try {
|
||||
WebSocketManager.send(
|
||||
WebSocketMessage(
|
||||
type = "stopTyping",
|
||||
credentials = WebSocketCredentials(
|
||||
scheme = "Bearer",
|
||||
credentials = token
|
||||
)
|
||||
)
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
// Silently ignore if WebSocket is not connected yet
|
||||
// Typing indicators are not critical
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ data class LoginRequest(
|
||||
@Serializable
|
||||
data class RegisterRequest(
|
||||
val username: String,
|
||||
val display_name: String,
|
||||
val password: String,
|
||||
val confirm_password: String
|
||||
)
|
||||
@@ -48,17 +49,29 @@ data class MessagesResponse(
|
||||
@Serializable
|
||||
data class Message(
|
||||
val id: Int,
|
||||
val user_id: Int,
|
||||
val content: String,
|
||||
val timestamp: String,
|
||||
val is_read: Boolean,
|
||||
val is_edited: Boolean,
|
||||
val username: String,
|
||||
val profile_picture: String?,
|
||||
val reply_to: Message?
|
||||
)
|
||||
val profile_picture: String? = null,
|
||||
val verified: Boolean? = null,
|
||||
val reply_to: Message? = null,
|
||||
val client_message_id: String? = null,
|
||||
val reactions: List<ReactionData>? = null
|
||||
) {
|
||||
val utcTimestamp = "${timestamp}Z"
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class SendMessageRequest(
|
||||
val content: String,
|
||||
val reply_to_id: Int? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class EditMessageRequest(
|
||||
val content: String
|
||||
)
|
||||
|
||||
@@ -87,4 +100,59 @@ data class WebSocketMessage(
|
||||
val credentials: WebSocketCredentials? = null,
|
||||
val data: JsonElement? = null,
|
||||
val error: WebSocketError? = null
|
||||
)
|
||||
|
||||
// WebSocket message data types
|
||||
@Serializable
|
||||
data class NewMessageData(
|
||||
val message: Message
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MessageEditedData(
|
||||
val message: Message
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MessageDeletedData(
|
||||
val message_id: Int
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class TypingData(
|
||||
val userId: Int,
|
||||
val username: String
|
||||
)
|
||||
|
||||
// Batched updates message
|
||||
@Serializable
|
||||
data class UpdateItem(
|
||||
val type: String,
|
||||
val data: JsonElement? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class UpdatesMessage(
|
||||
val type: String,
|
||||
val seq: Int,
|
||||
val updates: List<UpdateItem>
|
||||
)
|
||||
|
||||
// WebSocket request types
|
||||
@Serializable
|
||||
data class WebSocketSendMessageRequest(
|
||||
val content: String,
|
||||
val reply_to_id: Int? = null,
|
||||
val client_message_id: String? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class WebSocketEditMessageRequest(
|
||||
val message_id: Int,
|
||||
val content: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class WebSocketDeleteMessageRequest(
|
||||
val message_id: Int
|
||||
)
|
||||
@@ -1,10 +1,11 @@
|
||||
package ru.fromchat.api
|
||||
|
||||
import android.util.Log
|
||||
import ru.fromchat.core.Logger
|
||||
import io.ktor.client.call.body
|
||||
import io.ktor.client.plugins.ClientRequestException
|
||||
|
||||
suspend inline fun <Response> apiRequest(
|
||||
unexpectedError: String,
|
||||
onError: (String, Exception) -> Unit = { _, _ -> },
|
||||
onSuccess: (Response) -> Unit = {},
|
||||
request: suspend () -> Response
|
||||
@@ -17,16 +18,16 @@ suspend inline fun <Response> apiRequest(
|
||||
val message = if (e.response.status.value in arrayOf(401, 403)) {
|
||||
e.response.body<ErrorResponse>().detail
|
||||
} else {
|
||||
"Unexpected error"
|
||||
unexpectedError
|
||||
}
|
||||
|
||||
Log.e("API", "API request failed: $message, exception:", e)
|
||||
Logger.e("API", "API request failed: $message", e)
|
||||
|
||||
onError(message, e)
|
||||
return Result.failure(e)
|
||||
} catch (e: Exception) {
|
||||
Log.e("API", "API request failed:", e)
|
||||
onError("Unexpected error", e)
|
||||
Logger.e("API", "API request failed: ${e.message}", e)
|
||||
onError(unexpectedError, e)
|
||||
return Result.failure(e)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package ru.fromchat.api
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class WebSocketUpdatesData(
|
||||
val seq: Int,
|
||||
val updates: List<WebSocketMessage>
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ReactionUpdateData(
|
||||
val message_id: Int,
|
||||
val emoji: String,
|
||||
val action: String,
|
||||
val user_id: Int,
|
||||
val username: String,
|
||||
val reactions: List<ReactionData>
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ReactionData(
|
||||
val emoji: String,
|
||||
val count: Int,
|
||||
val users: List<ReactionUser>
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ReactionUser(
|
||||
val id: Int,
|
||||
val username: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class TypingUpdateData(
|
||||
val userId: Int,
|
||||
val username: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class WebSocketAuthMessage(
|
||||
val token: String
|
||||
)
|
||||
@@ -0,0 +1,224 @@
|
||||
package ru.fromchat.api
|
||||
|
||||
import io.ktor.client.plugins.websocket.DefaultClientWebSocketSession
|
||||
import io.ktor.client.plugins.websocket.webSocket
|
||||
import io.ktor.client.request.url
|
||||
import io.ktor.http.HttpMethod
|
||||
import io.ktor.websocket.Frame
|
||||
import io.ktor.websocket.readText
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.IO
|
||||
import kotlinx.coroutines.TimeoutCancellationException
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import ru.fromchat.core.Logger
|
||||
import ru.fromchat.core.config.Config
|
||||
import kotlin.concurrent.Volatile
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.ExperimentalTime
|
||||
|
||||
object WebSocketManager {
|
||||
// Config
|
||||
private val scope = CoroutineScope(Dispatchers.IO)
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
private val _messages = MutableSharedFlow<WebSocketMessage>(replay = 0, extraBufferCapacity = 64)
|
||||
val messages = _messages.asSharedFlow()
|
||||
|
||||
private val globalHandlers = mutableListOf<((WebSocketMessage) -> Unit)>()
|
||||
|
||||
fun addGlobalMessageHandler(handler: ((WebSocketMessage) -> Unit)) {
|
||||
globalHandlers += handler
|
||||
}
|
||||
|
||||
fun removeGlobalMessageHandler(handler: ((WebSocketMessage) -> Unit)) {
|
||||
globalHandlers -= handler
|
||||
}
|
||||
|
||||
// State
|
||||
@Volatile private var connecting = false
|
||||
@Volatile private var session: DefaultClientWebSocketSession? = null
|
||||
|
||||
/**
|
||||
* Check if WebSocket is connected
|
||||
*/
|
||||
val isConnected get() = session != null
|
||||
|
||||
/**
|
||||
* Wait for WebSocket connection with timeout
|
||||
*/
|
||||
@OptIn(ExperimentalTime::class)
|
||||
suspend fun waitForConnection(timeoutMs: Long = 10000): Boolean {
|
||||
Logger.d("WebSocketManager", "waitForConnection: session=${session != null}, connecting=$connecting")
|
||||
if (session != null) return true
|
||||
val startTime = Clock.System.now().toEpochMilliseconds()
|
||||
while (session == null && (Clock.System.now().toEpochMilliseconds() - startTime) < timeoutMs) {
|
||||
delay(100)
|
||||
}
|
||||
Logger.d("WebSocketManager", "waitForConnection finished: session=${session != null}")
|
||||
return session != null
|
||||
}
|
||||
|
||||
fun connect() {
|
||||
Logger.d("WebSocketManager", "connect() called. current session=${session != null}, connecting=$connecting")
|
||||
if (connecting) {
|
||||
Logger.d("WebSocketManager", "connect() ignored: already connecting")
|
||||
return
|
||||
}
|
||||
connecting = true
|
||||
Logger.d("WebSocketManager", "connecting set to true")
|
||||
|
||||
scope.launch {
|
||||
while (isActive) {
|
||||
Logger.d("WebSocketManager", "Connection loop active. isActive=$isActive")
|
||||
try {
|
||||
val wsUrl = Config.webSocketUrl
|
||||
Logger.d("WebSocketManager", "Attempting to connect to: $wsUrl")
|
||||
ApiClient.http.webSocket(
|
||||
method = HttpMethod.Get,
|
||||
request = {
|
||||
url(wsUrl)
|
||||
}
|
||||
) {
|
||||
session = this
|
||||
connecting = false
|
||||
Logger.d("WebSocketManager", "WebSocket connected. connecting set to false")
|
||||
|
||||
// Send ping message immediately after connection for authentication
|
||||
ApiClient.token?.let {
|
||||
Logger.d("WebSocketManager", "Sending WebSocket ping for authentication")
|
||||
send(WebSocketMessage(type = "ping", credentials = WebSocketCredentials(scheme = "Bearer", credentials = it)))
|
||||
}
|
||||
|
||||
for (frame in incoming) {
|
||||
val text = (frame as? Frame.Text)?.readText() ?: continue
|
||||
Logger.d("WebSocketManager", "Received payload: $text")
|
||||
try {
|
||||
val jsonTree = json.parseToJsonElement(text)
|
||||
val messageType = jsonTree.jsonObject["type"]?.jsonPrimitive?.content
|
||||
|
||||
val msg = when (messageType) {
|
||||
"updates" -> {
|
||||
WebSocketMessage(
|
||||
type = "updates",
|
||||
data = jsonTree
|
||||
)
|
||||
}
|
||||
"typing", "stopTyping" -> {
|
||||
// These messages are expected to be direct, without additional data in the web client
|
||||
WebSocketMessage(
|
||||
type = messageType,
|
||||
data = jsonTree.jsonObject["data"] // Extract data if present
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
json.decodeFromString<WebSocketMessage>(text)
|
||||
}
|
||||
}
|
||||
|
||||
globalHandlers.forEach { it(msg) }
|
||||
_messages.emit(msg)
|
||||
} catch (e: Throwable) {
|
||||
Logger.w("WebSocketManager", "Received malformed payload: ${e.message}", e)
|
||||
// ignore malformed
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Logger.w("WebSocketManager", "An error occurred during WebSocket connection: ${e.message}", e)
|
||||
connecting = false
|
||||
session = null
|
||||
Logger.d("WebSocketManager", "Reconnecting in 3 seconds...")
|
||||
delay(3000)
|
||||
} finally {
|
||||
Logger.w("WebSocketManager", "WebSocket disconnected. session set to null, connecting set to false")
|
||||
session = null
|
||||
connecting = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun send(message: WebSocketMessage) {
|
||||
// Wait for connection if not connected yet
|
||||
if (session == null) {
|
||||
if (!waitForConnection(5000)) {
|
||||
Logger.w("WebSocketManager", "Cannot send message: no active session after waiting")
|
||||
throw IllegalStateException("No active WebSocket session")
|
||||
}
|
||||
}
|
||||
|
||||
val currentSession = session
|
||||
if (currentSession != null) {
|
||||
try {
|
||||
currentSession.send(Frame.Text(json.encodeToString(message)))
|
||||
} catch (e: Exception) {
|
||||
Logger.e("WebSocketManager", "Failed to send message: ${e.message}", e)
|
||||
throw e
|
||||
}
|
||||
} else {
|
||||
Logger.w("WebSocketManager", "Cannot send message: no active session")
|
||||
throw IllegalStateException("No active WebSocket session")
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
suspend fun request(message: WebSocketMessage, timeoutMs: Long = 10_000): WebSocketMessage? {
|
||||
Logger.d("WebSocketManager", "WebSocket request: $message")
|
||||
var handler: ((WebSocketMessage) -> Unit)? = null
|
||||
|
||||
return try {
|
||||
// Check if we have a valid session before sending
|
||||
if (session == null) {
|
||||
Logger.w("WebSocketManager", "No active WebSocket session")
|
||||
return null
|
||||
}
|
||||
|
||||
send(message)
|
||||
withTimeout(timeoutMs) {
|
||||
suspendCoroutine { continuation ->
|
||||
handler = { response ->
|
||||
// Only process responses that match our request type
|
||||
if (response.type == message.type) {
|
||||
continuation.resumeWith(Result.success(response))
|
||||
removeGlobalMessageHandler(handler!!)
|
||||
}
|
||||
}
|
||||
addGlobalMessageHandler(handler)
|
||||
}
|
||||
}
|
||||
} catch (_: TimeoutCancellationException) {
|
||||
Logger.w("WebSocketManager", "Request timed out")
|
||||
null
|
||||
} catch (e: Exception) {
|
||||
Logger.e("WebSocketManager", "Request failed: ${e.message}", e)
|
||||
null
|
||||
} finally {
|
||||
handler?.let { removeGlobalMessageHandler(it) }
|
||||
}
|
||||
}
|
||||
|
||||
fun shutdown() {
|
||||
Logger.d("WebSocketManager", "shutdown() called. Cancelling scope.")
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
Logger.d("WebSocketManager", "disconnect() called. current session=${session != null}")
|
||||
session?.cancel() // Close the WebSocket session
|
||||
session = null
|
||||
connecting = false
|
||||
Logger.d("WebSocketManager", "Disconnected. session set to null, connecting set to false")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package ru.fromchat.core
|
||||
|
||||
expect object Logger {
|
||||
fun d(tag: String, message: String, throwable: Throwable? = null)
|
||||
fun i(tag: String, message: String, throwable: Throwable? = null)
|
||||
fun w(tag: String, message: String, throwable: Throwable? = null)
|
||||
fun e(tag: String, message: String, throwable: Throwable? = null)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package ru.fromchat.core
|
||||
|
||||
import com.pr0gramm3r101.utils.settings.Settings
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.IO
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import ru.fromchat.ui.Theme
|
||||
|
||||
object Settings {
|
||||
private val settings = Settings()
|
||||
|
||||
private fun runIO(block: suspend CoroutineScope.() -> Unit) {
|
||||
CoroutineScope(Dispatchers.IO).launch(block = block)
|
||||
}
|
||||
|
||||
var materialYou: Boolean
|
||||
get() = runBlocking { settings.getBoolean("materialYou", true) }
|
||||
set(value) = runIO { settings.putBoolean("materialYou", value) }
|
||||
|
||||
var theme: Theme
|
||||
get() = runBlocking { Theme.entries[settings.getInt("theme", Theme.AsSystem.ordinal)] }
|
||||
set(value) = runIO { settings.putInt("theme", value.ordinal) }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package ru.fromchat.core.config
|
||||
|
||||
import com.pr0gramm3r101.utils.storage.ServerConfigData
|
||||
import com.pr0gramm3r101.utils.storage.ServerConfigStorage
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
/**
|
||||
* Application configuration
|
||||
*/
|
||||
object Config {
|
||||
private val _serverConfig = MutableStateFlow<ServerConfigData?>(null)
|
||||
val serverConfig: StateFlow<ServerConfigData?> = _serverConfig.asStateFlow()
|
||||
|
||||
private val config
|
||||
get() = _serverConfig.value ?: throw IllegalStateException("Server configuration not initialized")
|
||||
|
||||
/**
|
||||
* Initialize configuration by loading from storage
|
||||
*/
|
||||
suspend fun initialize() {
|
||||
_serverConfig.value = ServerConfigStorage.getConfig()
|
||||
|
||||
// Set default values and prevent corruption
|
||||
ServerConfigStorage.getConfig()
|
||||
}
|
||||
|
||||
/**
|
||||
* Update server configuration
|
||||
*/
|
||||
suspend fun updateServerConfig(config: ServerConfigData) {
|
||||
ServerConfigStorage.saveConfig(config)
|
||||
_serverConfig.value = config
|
||||
}
|
||||
|
||||
/**
|
||||
* Get API base URL based on current server configuration
|
||||
*/
|
||||
val apiBaseUrl
|
||||
get() = "${if (config.httpsEnabled) "https" else "http"}://${config.serverUrl}/api"
|
||||
|
||||
/**
|
||||
* Get WebSocket URL based on current server configuration
|
||||
*/
|
||||
val webSocketUrl
|
||||
get() = "${if (config.httpsEnabled) "wss" else "ws"}://${config.serverUrl}/api/chat/ws"
|
||||
|
||||
/**
|
||||
* Checks if server configuration exists
|
||||
*/
|
||||
suspend fun hasServerConfig() = ServerConfigStorage.hasConfiguration()
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
@file:Suppress("NOTHING_TO_INLINE")
|
||||
|
||||
package ru.fromchat.ui
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.MediumTopAppBar
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.rememberTopAppBarState
|
||||
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.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import ru.fromchat.Res
|
||||
import ru.fromchat.about
|
||||
import ru.fromchat.app_desc
|
||||
import ru.fromchat.app_icon
|
||||
import ru.fromchat.app_name
|
||||
import ru.fromchat.back
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
inline fun AboutScreen() {
|
||||
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
|
||||
val navController = LocalNavController.current
|
||||
|
||||
Scaffold(
|
||||
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
topBar = {
|
||||
MediumTopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
stringResource(Res.string.about),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { navController.navigateUp() }) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(Res.string.back)
|
||||
)
|
||||
}
|
||||
},
|
||||
scrollBehavior = scrollBehavior
|
||||
)
|
||||
},
|
||||
) { innerPadding ->
|
||||
Box(Modifier.padding(innerPadding)) {
|
||||
Column(
|
||||
Modifier
|
||||
.padding(top = 16.dp, start = 16.dp, end = 16.dp)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Box(Modifier.padding(bottom = 10.dp)) {
|
||||
Image(
|
||||
painter = painterResource(Res.drawable.app_icon),
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(20.dp))
|
||||
.size(70.dp)
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = stringResource(Res.string.app_name),
|
||||
fontSize = 20.sp,
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
modifier = Modifier.padding(bottom = 16.dp)
|
||||
)
|
||||
Text(
|
||||
text = stringResource(Res.string.app_desc),
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
package ru.fromchat.ui
|
||||
|
||||
import androidx.compose.animation.AnimatedContentTransitionScope.SlideDirection.Companion.End
|
||||
import androidx.compose.animation.AnimatedContentTransitionScope.SlideDirection.Companion.Start
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import ru.fromchat.api.WebSocketManager
|
||||
import ru.fromchat.core.config.Config
|
||||
import ru.fromchat.ui.auth.LoginScreen
|
||||
import ru.fromchat.ui.auth.RegisterScreen
|
||||
import ru.fromchat.ui.chat.PublicChatScreen
|
||||
import ru.fromchat.ui.main.MainScreen
|
||||
import ru.fromchat.ui.setup.ServerConfigScreen
|
||||
|
||||
val LocalNavController = compositionLocalOf<NavController> { error("") }
|
||||
|
||||
@Composable
|
||||
fun App() {
|
||||
LaunchedEffect(Unit) {
|
||||
runCatching {
|
||||
Config.initialize()
|
||||
}
|
||||
}
|
||||
|
||||
// Observe lifecycle events to manage WebSocket connection
|
||||
val lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current
|
||||
DisposableEffect(lifecycleOwner) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
when (event) {
|
||||
Lifecycle.Event.ON_RESUME -> {
|
||||
// Connect WebSocket when app comes to foreground
|
||||
WebSocketManager.connect()
|
||||
}
|
||||
Lifecycle.Event.ON_PAUSE -> {
|
||||
// Disconnect WebSocket when app goes to background
|
||||
WebSocketManager.disconnect()
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
lifecycleOwner.lifecycle.addObserver(observer)
|
||||
|
||||
onDispose {
|
||||
lifecycleOwner.lifecycle.removeObserver(observer)
|
||||
}
|
||||
}
|
||||
|
||||
FromChatTheme {
|
||||
val navController = rememberNavController()
|
||||
val animationSpec = tween<IntOffset>(400)
|
||||
|
||||
CompositionLocalProvider(
|
||||
LocalNavController provides navController
|
||||
) {
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = "login",
|
||||
enterTransition = {
|
||||
slideIntoContainer(
|
||||
Start,
|
||||
animationSpec = animationSpec
|
||||
)
|
||||
},
|
||||
exitTransition = {
|
||||
slideOutOfContainer(
|
||||
Start,
|
||||
animationSpec = animationSpec
|
||||
)
|
||||
},
|
||||
popEnterTransition = {
|
||||
slideIntoContainer(
|
||||
End,
|
||||
animationSpec = animationSpec
|
||||
)
|
||||
},
|
||||
popExitTransition = {
|
||||
slideOutOfContainer(
|
||||
End,
|
||||
animationSpec = animationSpec
|
||||
)
|
||||
}
|
||||
) {
|
||||
composable("serverConfig") {
|
||||
ServerConfigScreen()
|
||||
}
|
||||
|
||||
composable("login") {
|
||||
LoginScreen(
|
||||
onLoginSuccess = {
|
||||
navController.navigate("chat") {
|
||||
popUpTo("login") { inclusive = true }
|
||||
}
|
||||
},
|
||||
onNavigateToRegister = { navController.navigate("register") }
|
||||
)
|
||||
}
|
||||
|
||||
composable("register") {
|
||||
RegisterScreen(
|
||||
onRegistered = { navController.navigate("login") }
|
||||
)
|
||||
}
|
||||
|
||||
composable("chat") {
|
||||
MainScreen(
|
||||
onLogout = {
|
||||
navController.navigate("login") {
|
||||
popUpTo("chat") { inclusive = true }
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
composable("chats/publicChat") {
|
||||
PublicChatScreen()
|
||||
}
|
||||
|
||||
composable("about") {
|
||||
AboutScreen()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,11 +3,8 @@ package ru.fromchat.ui
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -18,20 +15,20 @@ fun RowHeader(
|
||||
title: String,
|
||||
subtitle: String
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Icon(
|
||||
Column(horizontalAlignment = androidx.compose.ui.Alignment.CenterHorizontally) {
|
||||
androidx.compose.material3.Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.padding(bottom = 8.dp)
|
||||
.size(30.dp),
|
||||
)
|
||||
Text(
|
||||
androidx.compose.material3.Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
modifier = Modifier.padding(bottom = 8.dp)
|
||||
)
|
||||
Text(
|
||||
androidx.compose.material3.Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(bottom = 16.dp)
|
||||
@@ -0,0 +1,129 @@
|
||||
package ru.fromchat.ui
|
||||
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.ColorScheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import ru.fromchat.core.Settings
|
||||
|
||||
enum class Theme {
|
||||
AsSystem,
|
||||
Light,
|
||||
Dark
|
||||
}
|
||||
|
||||
var dynamicThemeEnabled by mutableStateOf(
|
||||
runCatching { Settings.materialYou }.getOrNull() == true
|
||||
)
|
||||
|
||||
var theme by mutableStateOf(
|
||||
runCatching { Settings.theme }.getOrNull() ?: Theme.AsSystem
|
||||
)
|
||||
|
||||
@Composable
|
||||
expect fun getColorScheme(darkTheme: Boolean, dynamicColor: Boolean): ColorScheme
|
||||
|
||||
@Composable
|
||||
fun FromChatTheme(
|
||||
darkTheme: Boolean = when (theme) {
|
||||
Theme.AsSystem -> isSystemInDarkTheme()
|
||||
Theme.Light -> false
|
||||
Theme.Dark -> true
|
||||
},
|
||||
dynamicColor: Boolean = dynamicThemeEnabled,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
var colorScheme = getColorScheme(darkTheme, dynamicColor)
|
||||
|
||||
val primary by animateColorAsState(colorScheme.primary)
|
||||
val onPrimary by animateColorAsState(colorScheme.onPrimary)
|
||||
val primaryContainer by animateColorAsState(colorScheme.primaryContainer)
|
||||
val onPrimaryContainer by animateColorAsState(colorScheme.onPrimaryContainer)
|
||||
val secondary by animateColorAsState(colorScheme.secondary)
|
||||
val onSecondary by animateColorAsState(colorScheme.onSecondary)
|
||||
val secondaryContainer by animateColorAsState(colorScheme.secondaryContainer)
|
||||
val onSecondaryContainer by animateColorAsState(colorScheme.onSecondaryContainer)
|
||||
val tertiary by animateColorAsState(colorScheme.tertiary)
|
||||
val onTertiary by animateColorAsState(colorScheme.onTertiary)
|
||||
val tertiaryContainer by animateColorAsState(colorScheme.tertiaryContainer)
|
||||
val onTertiaryContainer by animateColorAsState(colorScheme.onTertiaryContainer)
|
||||
val error by animateColorAsState(colorScheme.error)
|
||||
val onError by animateColorAsState(colorScheme.onError)
|
||||
val errorContainer by animateColorAsState(colorScheme.errorContainer)
|
||||
val onErrorContainer by animateColorAsState(colorScheme.onErrorContainer)
|
||||
val background by animateColorAsState(colorScheme.background)
|
||||
val onBackground by animateColorAsState(colorScheme.onBackground)
|
||||
val surface by animateColorAsState(colorScheme.surface)
|
||||
val onSurface by animateColorAsState(colorScheme.onSurface)
|
||||
val surfaceVariant by animateColorAsState(colorScheme.surfaceVariant)
|
||||
val onSurfaceVariant by animateColorAsState(colorScheme.onSurfaceVariant)
|
||||
val outline by animateColorAsState(colorScheme.outline)
|
||||
val outlineVariant by animateColorAsState(colorScheme.outlineVariant)
|
||||
val scrim by animateColorAsState(colorScheme.scrim)
|
||||
val inverseSurface by animateColorAsState(colorScheme.inverseSurface)
|
||||
val inverseOnSurface by animateColorAsState(colorScheme.inverseOnSurface)
|
||||
val inversePrimary by animateColorAsState(colorScheme.inversePrimary)
|
||||
val surfaceDim by animateColorAsState(colorScheme.surfaceDim)
|
||||
val surfaceBright by animateColorAsState(colorScheme.surfaceBright)
|
||||
val surfaceContainerLowest by animateColorAsState(colorScheme.surfaceContainerLowest)
|
||||
val surfaceContainerLow by animateColorAsState(colorScheme.surfaceContainerLow)
|
||||
val surfaceContainer by animateColorAsState(colorScheme.surfaceContainer)
|
||||
val surfaceContainerHigh by animateColorAsState(colorScheme.surfaceContainerHigh)
|
||||
val surfaceContainerHighest by animateColorAsState(colorScheme.surfaceContainerHighest)
|
||||
|
||||
colorScheme = colorScheme.copy(
|
||||
primary = primary,
|
||||
onPrimary = onPrimary,
|
||||
primaryContainer = primaryContainer,
|
||||
onPrimaryContainer = onPrimaryContainer,
|
||||
secondary = secondary,
|
||||
onSecondary = onSecondary,
|
||||
secondaryContainer = secondaryContainer,
|
||||
onSecondaryContainer = onSecondaryContainer,
|
||||
tertiary = tertiary,
|
||||
onTertiary = onTertiary,
|
||||
tertiaryContainer = tertiaryContainer,
|
||||
onTertiaryContainer = onTertiaryContainer,
|
||||
error = error,
|
||||
onError = onError,
|
||||
errorContainer = errorContainer,
|
||||
onErrorContainer = onErrorContainer,
|
||||
background = background,
|
||||
onBackground = onBackground,
|
||||
surface = surface,
|
||||
onSurface = onSurface,
|
||||
surfaceVariant = surfaceVariant,
|
||||
onSurfaceVariant = onSurfaceVariant,
|
||||
outline = outline,
|
||||
outlineVariant = outlineVariant,
|
||||
scrim = scrim,
|
||||
inverseSurface = inverseSurface,
|
||||
inverseOnSurface = inverseOnSurface,
|
||||
inversePrimary = inversePrimary,
|
||||
surfaceDim = surfaceDim,
|
||||
surfaceBright = surfaceBright,
|
||||
surfaceContainerLowest = surfaceContainerLowest,
|
||||
surfaceContainerLow = surfaceContainerLow,
|
||||
surfaceContainer = surfaceContainer,
|
||||
surfaceContainerHigh = surfaceContainerHigh,
|
||||
surfaceContainerHighest = surfaceContainerHighest
|
||||
)
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun isAppInDarkTheme(): Boolean {
|
||||
return when (theme) {
|
||||
Theme.AsSystem -> isSystemInDarkTheme()
|
||||
Theme.Light -> false
|
||||
Theme.Dark -> true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
package ru.fromchat.ui.auth
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeDrawing
|
||||
import androidx.compose.foundation.layout.wrapContentSize
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Login
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.Storage
|
||||
import androidx.compose.material3.Button
|
||||
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.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.pr0gramm3r101.utils.crypto.deriveAuthSecret
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import ru.fromchat.Res
|
||||
import ru.fromchat.api.ApiClient
|
||||
import ru.fromchat.api.LoginRequest
|
||||
import ru.fromchat.api.apiRequest
|
||||
import ru.fromchat.change_server
|
||||
import ru.fromchat.error_unexpected
|
||||
import ru.fromchat.fill_all_fields
|
||||
import ru.fromchat.login
|
||||
import ru.fromchat.login_d
|
||||
import ru.fromchat.more
|
||||
import ru.fromchat.password
|
||||
import ru.fromchat.register_button
|
||||
import ru.fromchat.ui.LocalNavController
|
||||
import ru.fromchat.ui.RowHeader
|
||||
import ru.fromchat.username
|
||||
import ru.fromchat.welcome
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun LoginScreen(
|
||||
onLoginSuccess: () -> Unit,
|
||||
onNavigateToRegister: () -> Unit
|
||||
) {
|
||||
val errorUnexpected = stringResource(Res.string.error_unexpected)
|
||||
val navController = LocalNavController.current
|
||||
|
||||
Scaffold(
|
||||
contentWindowInsets = WindowInsets.safeDrawing,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
actions = {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
|
||||
Box(Modifier.wrapContentSize(Alignment.TopEnd)) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
expanded = true
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.MoreVert,
|
||||
contentDescription = stringResource(Res.string.more)
|
||||
)
|
||||
}
|
||||
|
||||
DropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false } // Закрыть при нажатии вне меню
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(stringResource(Res.string.change_server))
|
||||
},
|
||||
onClick = {
|
||||
expanded = false
|
||||
navController.navigate("serverConfig")
|
||||
},
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
Icons.Filled.Storage,
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
title = {}
|
||||
)
|
||||
}
|
||||
) { innerPadding ->
|
||||
var username by remember { mutableStateOf("") }
|
||||
var password by remember { mutableStateOf("") }
|
||||
var alert by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(16.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
RowHeader(
|
||||
icon = Icons.AutoMirrored.Filled.Login,
|
||||
title = stringResource(Res.string.welcome),
|
||||
subtitle = stringResource(Res.string.login_d)
|
||||
)
|
||||
|
||||
if (alert != null) {
|
||||
Text(text = alert!!, color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = username,
|
||||
onValueChange = { username = it },
|
||||
label = { Text(stringResource(Res.string.username)) },
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = password,
|
||||
onValueChange = { password = it },
|
||||
label = { Text(stringResource(Res.string.password)) },
|
||||
singleLine = true,
|
||||
visualTransformation = PasswordVisualTransformation()
|
||||
)
|
||||
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
val alertErrorFilling = stringResource(Res.string.fill_all_fields)
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
if (username.isBlank() || password.isBlank()) {
|
||||
alert = alertErrorFilling
|
||||
return@Button
|
||||
}
|
||||
|
||||
// Derive auth secret before sending (matches frontend implementation)
|
||||
scope.launch {
|
||||
val derived = deriveAuthSecret(username.trim(), password.trim())
|
||||
|
||||
apiRequest(
|
||||
unexpectedError = errorUnexpected,
|
||||
onError = { message, _ ->
|
||||
alert = message
|
||||
},
|
||||
onSuccess = { onLoginSuccess() }
|
||||
) {
|
||||
ApiClient.login(LoginRequest(username.trim(), derived))
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
Text(stringResource(Res.string.login))
|
||||
}
|
||||
|
||||
Button(onClick = onNavigateToRegister) {
|
||||
Text(stringResource(Res.string.register_button))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
package ru.fromchat.ui.auth
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeDrawing
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.PersonAdd
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.pr0gramm3r101.utils.crypto.deriveAuthSecret
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import ru.fromchat.Res
|
||||
import ru.fromchat.api.ApiClient
|
||||
import ru.fromchat.api.RegisterRequest
|
||||
import ru.fromchat.api.apiRequest
|
||||
import ru.fromchat.back
|
||||
import ru.fromchat.confirm_password
|
||||
import ru.fromchat.display_name
|
||||
import ru.fromchat.display_name_error
|
||||
import ru.fromchat.error_unexpected
|
||||
import ru.fromchat.fill_all_fields
|
||||
import ru.fromchat.password
|
||||
import ru.fromchat.password_length_error
|
||||
import ru.fromchat.passwords_dont_match
|
||||
import ru.fromchat.register
|
||||
import ru.fromchat.register_button
|
||||
import ru.fromchat.register_d
|
||||
import ru.fromchat.ui.LocalNavController
|
||||
import ru.fromchat.ui.RowHeader
|
||||
import ru.fromchat.username
|
||||
import ru.fromchat.username_length_error
|
||||
|
||||
@Composable
|
||||
fun RegisterScreen(
|
||||
onRegistered: () -> Unit
|
||||
) {
|
||||
val errorUnexpected = stringResource(Res.string.error_unexpected)
|
||||
|
||||
Scaffold(contentWindowInsets = WindowInsets.safeDrawing) { innerPadding ->
|
||||
var username by remember { mutableStateOf("") }
|
||||
var displayName by remember { mutableStateOf("") }
|
||||
var password by remember { mutableStateOf("") }
|
||||
var confirmPassword by remember { mutableStateOf("") }
|
||||
var alert by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
val navController = LocalNavController.current
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(16.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
RowHeader(
|
||||
icon = Icons.Filled.PersonAdd,
|
||||
title = stringResource(Res.string.register),
|
||||
subtitle = stringResource(Res.string.register_d)
|
||||
)
|
||||
|
||||
if (alert != null) {
|
||||
Text(text = alert!!, color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = username,
|
||||
onValueChange = { username = it },
|
||||
label = { Text(stringResource(Res.string.username)) },
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = displayName,
|
||||
onValueChange = { displayName = it },
|
||||
label = { Text(stringResource(Res.string.display_name)) },
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = password,
|
||||
onValueChange = { password = it },
|
||||
label = { Text(stringResource(Res.string.password)) },
|
||||
singleLine = true,
|
||||
visualTransformation = PasswordVisualTransformation()
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = confirmPassword,
|
||||
onValueChange = { confirmPassword = it },
|
||||
label = { Text(stringResource(Res.string.confirm_password)) },
|
||||
singleLine = true,
|
||||
visualTransformation = PasswordVisualTransformation()
|
||||
)
|
||||
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
val alertErrorFilling = stringResource(Res.string.fill_all_fields)
|
||||
val alertErrorPasswordLittle = stringResource(Res.string.password_length_error)
|
||||
val alertErrorNameLittle = stringResource(Res.string.username_length_error)
|
||||
val alertErrorPasswordConfrim = stringResource(Res.string.passwords_dont_match)
|
||||
val alertErrorDisplayName = stringResource(Res.string.display_name_error)
|
||||
|
||||
IconButton(
|
||||
onClick = { navController.navigateUp() }
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(Res.string.back)
|
||||
)
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
// Checks
|
||||
if (username.isBlank() || displayName.isBlank() || password.isBlank() || confirmPassword.isBlank()) {
|
||||
alert = alertErrorFilling
|
||||
return@Button
|
||||
}
|
||||
if (password != confirmPassword) {
|
||||
alert = alertErrorPasswordConfrim
|
||||
return@Button
|
||||
}
|
||||
if (username.length !in 3..20) {
|
||||
alert = alertErrorNameLittle
|
||||
return@Button
|
||||
}
|
||||
if (displayName.isBlank() || displayName.length > 64) {
|
||||
alert = alertErrorDisplayName
|
||||
return@Button
|
||||
}
|
||||
if (password.length !in 5..50) {
|
||||
alert = alertErrorPasswordLittle
|
||||
return@Button
|
||||
}
|
||||
|
||||
// Derive auth secret before sending (matches frontend implementation)
|
||||
scope.launch {
|
||||
val derived = deriveAuthSecret(username.trim(), password)
|
||||
|
||||
apiRequest(
|
||||
unexpectedError = errorUnexpected,
|
||||
onError = { message, _ ->
|
||||
alert = message
|
||||
},
|
||||
onSuccess = { onRegistered() }
|
||||
) {
|
||||
ApiClient.register(
|
||||
RegisterRequest(
|
||||
username.trim(),
|
||||
displayName.trim(),
|
||||
derived,
|
||||
derived
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
Text(stringResource(Res.string.register_button))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package ru.fromchat.ui.chat
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.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.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil3.compose.AsyncImage
|
||||
import ru.fromchat.core.config.Config
|
||||
|
||||
@Composable
|
||||
fun Avatar(
|
||||
profilePictureUrl: String?,
|
||||
displayName: String,
|
||||
size: Dp = 32.dp,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var imageLoadFailed by remember { mutableStateOf(false) }
|
||||
|
||||
val gradient = remember(displayName) { generateGradientFromName(displayName) }
|
||||
val initials = remember(displayName) { getInitials(displayName) }
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.size(size)
|
||||
.clip(CircleShape)
|
||||
.background(gradient),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (profilePictureUrl != null && !imageLoadFailed) {
|
||||
val fullUrl = if (profilePictureUrl.startsWith("http")) {
|
||||
profilePictureUrl
|
||||
} else {
|
||||
"${Config.apiBaseUrl}$profilePictureUrl"
|
||||
}
|
||||
|
||||
AsyncImage(
|
||||
model = fullUrl,
|
||||
contentDescription = displayName,
|
||||
modifier = Modifier
|
||||
.size(size)
|
||||
.clip(CircleShape),
|
||||
contentScale = ContentScale.Crop,
|
||||
onError = {
|
||||
imageLoadFailed = true
|
||||
},
|
||||
onSuccess = {
|
||||
imageLoadFailed = false
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Fallback initials
|
||||
if (imageLoadFailed || profilePictureUrl == null) {
|
||||
Text(
|
||||
text = initials,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = Color.White,
|
||||
modifier = Modifier
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
package ru.fromchat.ui.chat
|
||||
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import kotlin.math.abs
|
||||
|
||||
/**
|
||||
* Get gradient brush for own messages
|
||||
*/
|
||||
fun getMessageGradient(isDark: Boolean) = Brush.linearGradient(
|
||||
colors = if (isDark) {
|
||||
listOf(
|
||||
Color(0xFF9333EA),
|
||||
Color(0xFF6366F1),
|
||||
Color(0xFF2F68C5)
|
||||
)
|
||||
} else {
|
||||
listOf(
|
||||
Color(0xFFB794F6),
|
||||
Color(0xFF818CF8),
|
||||
Color(0xFF60A5FA)
|
||||
)
|
||||
},
|
||||
start = Offset(0f, 0f),
|
||||
end = Offset(1000f, 1000f)
|
||||
)
|
||||
|
||||
/**
|
||||
* Generate a consistent gradient from a name for avatar fallback
|
||||
*/
|
||||
fun generateGradientFromName(name: String): Brush {
|
||||
val hash = name.hashCode()
|
||||
val r = abs(hash % 256)
|
||||
val g = abs((hash / 256) % 256)
|
||||
val b = abs((hash / 65536) % 256)
|
||||
|
||||
// Create two colors based on hash for gradient
|
||||
return Brush.linearGradient(
|
||||
colors = listOf(
|
||||
Color(
|
||||
red = (r + 100).coerceIn(0, 255) / 255f,
|
||||
green = (g + 100).coerceIn(0, 255) / 255f,
|
||||
blue = (b + 100).coerceIn(0, 255) / 255f
|
||||
),
|
||||
Color(
|
||||
red = (r + 50).coerceIn(0, 255) / 255f,
|
||||
green = (g + 50).coerceIn(0, 255) / 255f,
|
||||
blue = (b + 50).coerceIn(0, 255) / 255f
|
||||
)
|
||||
),
|
||||
start = Offset(0f, 0f),
|
||||
end = Offset(100f, 100f)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get initials from display name (first 2 words, first letter of each)
|
||||
*/
|
||||
fun getInitials(displayName: String): String {
|
||||
val words = displayName.trim().split("\\s+".toRegex())
|
||||
return when {
|
||||
words.isEmpty() -> "?"
|
||||
words.size == 1 -> {
|
||||
val word = words[0]
|
||||
if (word.length >= 2) {
|
||||
word.take(2).uppercase()
|
||||
} else {
|
||||
word.uppercase() + "?"
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
words.take(2).joinToString("") { it.firstOrNull()?.uppercase() ?: "" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
package ru.fromchat.ui.chat
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
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.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Reply
|
||||
import androidx.compose.material.icons.automirrored.filled.Send
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material3.FilledIconButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.hazeEffect
|
||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||
import dev.chrisbanes.haze.materials.HazeMaterials
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import ru.fromchat.Res
|
||||
import ru.fromchat.api.Message
|
||||
import ru.fromchat.message_placeholder
|
||||
|
||||
@Composable
|
||||
private fun <T> AnimatedPreviewBar(
|
||||
state: T?,
|
||||
content: @Composable (T) -> Unit
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = state != null,
|
||||
enter = fadeIn() + expandVertically(),
|
||||
exit = fadeOut() + shrinkVertically()
|
||||
) {
|
||||
var lastState by remember { mutableStateOf(state) }
|
||||
|
||||
LaunchedEffect(state) {
|
||||
if (state != null) {
|
||||
lastState = state
|
||||
}
|
||||
}
|
||||
|
||||
if (state != null || lastState != null) {
|
||||
content(state ?: lastState!!)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PreviewBar(
|
||||
icon: ImageVector,
|
||||
title: String,
|
||||
subtitle: String,
|
||||
onClose: () -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 12.dp, end = 12.dp, top = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
|
||||
IconButton(onClick = onClose) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = "Close",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalHazeMaterialsApi::class)
|
||||
@Composable
|
||||
fun ChatInput(
|
||||
text: String,
|
||||
onTextChange: (String) -> Unit,
|
||||
onSend: (String) -> Unit,
|
||||
typingHandler: TypingHandler,
|
||||
replyTo: Message? = null,
|
||||
editingMessage: Message? = null,
|
||||
onClearReply: () -> Unit,
|
||||
onClearEdit: () -> Unit,
|
||||
hazeState: HazeState
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
var typingJob by remember { mutableStateOf<kotlinx.coroutines.Job?>(null) }
|
||||
|
||||
// Handle typing indicator
|
||||
LaunchedEffect(text) {
|
||||
if (text.isNotBlank()) {
|
||||
typingJob?.cancel()
|
||||
typingHandler.sendTyping()
|
||||
@Suppress("AssignedValueIsNeverRead")
|
||||
typingJob = scope.launch {
|
||||
delay(3000) // 3 seconds
|
||||
typingHandler.stopTyping()
|
||||
}
|
||||
} else {
|
||||
typingJob?.cancel()
|
||||
typingHandler.stopTyping()
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
Modifier.windowInsetsPadding(WindowInsets.navigationBars)
|
||||
) {
|
||||
// Input field with blur
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(Color.Transparent)
|
||||
.padding(horizontal = 8.dp, vertical = 8.dp)
|
||||
) {
|
||||
val shape = RoundedCornerShape(24.dp)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.border(
|
||||
Dp.Hairline,
|
||||
MaterialTheme.colorScheme.outline.copy(alpha = 0.5f),
|
||||
shape
|
||||
)
|
||||
.clip(shape)
|
||||
.hazeEffect(
|
||||
state = hazeState,
|
||||
style = HazeMaterials.thin()
|
||||
)
|
||||
) {
|
||||
AnimatedPreviewBar(replyTo) { replyTo ->
|
||||
PreviewBar(
|
||||
icon = Icons.AutoMirrored.Filled.Reply,
|
||||
title = "Replying to ${replyTo.username}",
|
||||
subtitle = replyTo.content.take(50) + if (replyTo.content.length > 50) "..." else "",
|
||||
onClose = { onClearReply() }
|
||||
)
|
||||
}
|
||||
|
||||
AnimatedPreviewBar(editingMessage) { message ->
|
||||
PreviewBar(
|
||||
icon = Icons.Filled.Edit,
|
||||
title = "Editing message",
|
||||
subtitle = message.content.take(50) + if (message.content.length > 50) "..." else "",
|
||||
onClose = { onClearEdit() }
|
||||
)
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = text,
|
||||
onValueChange = onTextChange,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.animateContentSize(),
|
||||
placeholder = {
|
||||
Text(
|
||||
text = stringResource(Res.string.message_placeholder),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
|
||||
)
|
||||
},
|
||||
shape = shape,
|
||||
maxLines = 5,
|
||||
singleLine = false,
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedContainerColor = Color.Transparent,
|
||||
unfocusedContainerColor = Color.Transparent,
|
||||
errorContainerColor = Color.Transparent,
|
||||
disabledContainerColor = Color.Transparent,
|
||||
focusedBorderColor = Color.Transparent,
|
||||
errorBorderColor = Color.Transparent,
|
||||
disabledBorderColor = Color.Transparent,
|
||||
unfocusedBorderColor = Color.Transparent
|
||||
),
|
||||
trailingIcon = {
|
||||
val offset = with(LocalDensity.current) { 20.dp.toPx().toInt() }
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = text.isNotBlank(),
|
||||
enter = slideInHorizontally(
|
||||
initialOffsetX = { it + offset },
|
||||
animationSpec = tween(durationMillis = 300)
|
||||
),
|
||||
exit = slideOutHorizontally(
|
||||
targetOffsetX = { it + offset },
|
||||
animationSpec = tween(durationMillis = 200)
|
||||
)
|
||||
) {
|
||||
Box(Modifier.padding(end = 5.dp)) {
|
||||
FilledIconButton(
|
||||
onClick = {
|
||||
onSend(text.trim())
|
||||
onTextChange("")
|
||||
typingHandler.stopTyping()
|
||||
},
|
||||
modifier = Modifier.size(36.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.Send,
|
||||
contentDescription = "Send",
|
||||
tint = MaterialTheme.colorScheme.onPrimary,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
package ru.fromchat.ui.chat
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.Serializable
|
||||
import ru.fromchat.api.Message
|
||||
import ru.fromchat.api.WebSocketMessage
|
||||
import ru.fromchat.core.Logger
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.ExperimentalTime
|
||||
|
||||
/**
|
||||
* State data class for ChatPanel
|
||||
*/
|
||||
@Serializable
|
||||
data class ChatPanelState(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val messages: List<Message> = emptyList(),
|
||||
val isLoading: Boolean = false,
|
||||
val hasMoreMessages: Boolean = false,
|
||||
val isLoadingMore: Boolean = false,
|
||||
val typingUsers: List<TypingUser> = emptyList()
|
||||
)
|
||||
|
||||
/**
|
||||
* Abstract base class for chat panels
|
||||
*/
|
||||
abstract class ChatPanel(
|
||||
protected val id: String,
|
||||
protected val currentUserId: Int?,
|
||||
protected val scope: CoroutineScope
|
||||
) {
|
||||
protected var _state: ChatPanelState = ChatPanelState(
|
||||
id = id,
|
||||
title = ""
|
||||
)
|
||||
|
||||
private val pendingMessages = mutableMapOf<String, Pair<Job, Message>>()
|
||||
private var onStateChange: ((ChatPanelState) -> Unit)? = null
|
||||
|
||||
/**
|
||||
* Set state change callback
|
||||
*/
|
||||
fun setOnStateChange(callback: (ChatPanelState) -> Unit) {
|
||||
onStateChange = callback
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current state
|
||||
*/
|
||||
fun getState(): ChatPanelState = _state.copy()
|
||||
|
||||
/**
|
||||
* Update state
|
||||
*/
|
||||
protected fun updateState(updates: (ChatPanelState) -> ChatPanelState) {
|
||||
_state = updates(_state)
|
||||
// Notify state change - ensure callback runs on main thread for Compose
|
||||
val callback = onStateChange
|
||||
val newState = _state.copy()
|
||||
Logger.d("ChatPanel", "State updated: messages=${newState.messages.size}, callback=${callback != null}")
|
||||
if (callback != null) {
|
||||
scope.launch(Dispatchers.Main) {
|
||||
Logger.d("ChatPanel", "Calling state change callback with ${newState.messages.size} messages")
|
||||
callback(newState)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add message to list
|
||||
*/
|
||||
protected fun addMessage(message: Message) {
|
||||
val messageExists = _state.messages.any { it.id == message.id }
|
||||
if (!messageExists) {
|
||||
Logger.d("ChatPanel", "Adding message: id=${message.id}, content=${message.content.take(50)}")
|
||||
// Add message and sort by timestamp (ISO 8601 strings sort correctly lexicographically)
|
||||
updateState { currentState ->
|
||||
val newMessages = (currentState.messages + message).sortedBy { it.timestamp }
|
||||
Logger.d("ChatPanel", "Messages count after add: ${newMessages.size}")
|
||||
currentState.copy(messages = newMessages)
|
||||
}
|
||||
} else {
|
||||
Logger.d("ChatPanel", "Message already exists: id=${message.id}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update existing message
|
||||
*/
|
||||
protected fun updateMessage(messageId: Int, updates: (Message) -> Message) {
|
||||
updateState { currentState ->
|
||||
currentState.copy(
|
||||
messages = currentState.messages.map { msg ->
|
||||
if (msg.id == messageId) {
|
||||
updates(msg)
|
||||
} else {
|
||||
msg
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove message from list
|
||||
*/
|
||||
protected fun removeMessage(messageId: Int) {
|
||||
updateState { it.copy(messages = it.messages.filter { msg -> msg.id != messageId }) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all messages
|
||||
*/
|
||||
protected fun clearMessages() {
|
||||
updateState { it.copy(messages = emptyList()) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Set loading state
|
||||
*/
|
||||
protected fun setLoading(loading: Boolean) {
|
||||
updateState { it.copy(isLoading = loading) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Set has more messages flag
|
||||
*/
|
||||
protected fun setHasMoreMessages(hasMore: Boolean) {
|
||||
updateState { it.copy(hasMoreMessages = hasMore) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Set loading more state
|
||||
*/
|
||||
protected fun setLoadingMore(loading: Boolean) {
|
||||
updateState { it.copy(isLoadingMore = loading) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle message confirmation (replace temp message with confirmed)
|
||||
*/
|
||||
fun handleMessageConfirmed(tempId: String, confirmedMessage: Message) {
|
||||
val pending = pendingMessages.remove(tempId)
|
||||
pending?.first?.cancel()
|
||||
|
||||
// Replace temporary message with confirmed one
|
||||
updateState { currentState ->
|
||||
currentState.copy(
|
||||
messages = currentState.messages.map { msg ->
|
||||
// Check if this is the temp message (negative ID)
|
||||
if (msg.id < 0) {
|
||||
// Try to match by content or other criteria
|
||||
// For now, we'll replace based on tempId stored in pendingMessages
|
||||
confirmedMessage
|
||||
} else {
|
||||
msg
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry failed message
|
||||
*/
|
||||
@OptIn(ExperimentalTime::class)
|
||||
suspend fun retryMessage(messageId: Int) {
|
||||
val message = _state.messages.find { it.id == messageId } ?: return
|
||||
|
||||
// Create new temp ID for retry
|
||||
val tempId = "temp_${Clock.System.now().toEpochMilliseconds()}_${(0..999999).random()}"
|
||||
|
||||
// Create temp message for retry
|
||||
val tempMessage = message.copy(id = -1)
|
||||
|
||||
// Update message to sending state
|
||||
updateState { currentState ->
|
||||
currentState.copy(
|
||||
messages = currentState.messages.map { msg ->
|
||||
if (msg.id == messageId) {
|
||||
tempMessage
|
||||
} else {
|
||||
msg
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Set up timeout
|
||||
val timeoutJob = scope.launch {
|
||||
delay(10000) // 10 seconds
|
||||
handleMessageTimeout(tempId)
|
||||
}
|
||||
|
||||
pendingMessages[tempId] = timeoutJob to tempMessage
|
||||
|
||||
// Retry sending
|
||||
try {
|
||||
// Extract content from message
|
||||
sendMessage(message.content, message.reply_to?.id, message.client_message_id)
|
||||
} catch (e: Exception) {
|
||||
timeoutJob.cancel()
|
||||
pendingMessages.remove(tempId)
|
||||
// Mark as failed
|
||||
updateMessage(-1) { it.copy() }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle message timeout
|
||||
*/
|
||||
private fun handleMessageTimeout(tempId: String) {
|
||||
val pending = pendingMessages.remove(tempId)
|
||||
pending?.first?.cancel()
|
||||
|
||||
// Mark message as failed
|
||||
updateState { currentState ->
|
||||
currentState.copy(
|
||||
messages = currentState.messages.map { msg ->
|
||||
if (msg.id < 0) {
|
||||
// Mark as failed - we'll need to add a status field to Message
|
||||
// For now, just keep it
|
||||
msg
|
||||
} else {
|
||||
msg
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete message immediately from UI
|
||||
*/
|
||||
protected fun deleteMessageImmediately(messageId: Int) {
|
||||
removeMessage(messageId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Send message with immediate display (optimistic update)
|
||||
*/
|
||||
@OptIn(ExperimentalTime::class)
|
||||
suspend fun sendMessageWithImmediateDisplay(content: String, replyToId: Int?) {
|
||||
if (content.isBlank()) return
|
||||
|
||||
// Create temporary message for immediate display
|
||||
val tempId = "temp_${Clock.System.now().toEpochMilliseconds()}_${(0..999999).random()}"
|
||||
val tempMessage = Message(
|
||||
id = -1, // Temporary negative ID
|
||||
user_id = currentUserId ?: -1,
|
||||
content = content.trim(),
|
||||
timestamp = Clock.System.now().toString(),
|
||||
is_read = false,
|
||||
is_edited = false,
|
||||
username = "You",
|
||||
reply_to = replyToId?.let { replyId ->
|
||||
_state.messages.find { it.id == replyId }
|
||||
}
|
||||
)
|
||||
|
||||
// Add message immediately
|
||||
addMessage(tempMessage)
|
||||
|
||||
// Set up timeout for failure
|
||||
val timeoutJob = scope.launch {
|
||||
delay(10000) // 10 seconds timeout
|
||||
handleMessageTimeout(tempId)
|
||||
}
|
||||
|
||||
// Store pending message
|
||||
pendingMessages[tempId] = timeoutJob to tempMessage
|
||||
|
||||
// Actually send the message
|
||||
try {
|
||||
sendMessage(content, replyToId, tempId)
|
||||
// Message sent successfully - will be updated when WebSocket confirms
|
||||
} catch (error: Exception) {
|
||||
// Remove the temporary message from display
|
||||
removeMessage(-1)
|
||||
pendingMessages.remove(tempId)
|
||||
timeoutJob.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up pending messages
|
||||
*/
|
||||
fun destroy() {
|
||||
pendingMessages.values.forEach { (job, _) ->
|
||||
job.cancel()
|
||||
}
|
||||
pendingMessages.clear()
|
||||
}
|
||||
|
||||
// Abstract methods to implement
|
||||
abstract suspend fun sendMessage(content: String, replyToId: Int?, clientMessageId: String?)
|
||||
abstract suspend fun loadMessages()
|
||||
abstract suspend fun loadMoreMessages()
|
||||
abstract suspend fun handleWebSocketMessage(message: WebSocketMessage)
|
||||
abstract suspend fun handleEditMessage(messageId: Int, content: String)
|
||||
abstract suspend fun handleDeleteMessage(messageId: Int)
|
||||
|
||||
// Abstract UI control methods
|
||||
abstract fun showCallButton(): Boolean
|
||||
abstract fun getTypingHandler(): TypingHandler
|
||||
}
|
||||
|
||||
@@ -0,0 +1,386 @@
|
||||
package ru.fromchat.ui.chat
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.ime
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Call
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
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.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.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.positionInRoot
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.chrisbanes.haze.HazeProgressive
|
||||
import dev.chrisbanes.haze.hazeEffect
|
||||
import dev.chrisbanes.haze.hazeSource
|
||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||
import dev.chrisbanes.haze.materials.HazeMaterials
|
||||
import dev.chrisbanes.haze.rememberHazeState
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.json.decodeFromJsonElement
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import ru.fromchat.Res
|
||||
import ru.fromchat.api.ApiClient
|
||||
import ru.fromchat.api.Message
|
||||
import ru.fromchat.api.WebSocketManager
|
||||
import ru.fromchat.api.WebSocketMessage
|
||||
import ru.fromchat.api.WebSocketUpdatesData
|
||||
import ru.fromchat.back
|
||||
import ru.fromchat.core.Logger
|
||||
import ru.fromchat.ui.LocalNavController
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
|
||||
@Composable
|
||||
fun ChatScreen(
|
||||
panel: ChatPanel,
|
||||
currentUserId: Int?,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var panelState by remember(panel) { mutableStateOf(panel.getState()) }
|
||||
|
||||
// Observe state changes
|
||||
LaunchedEffect(panel) {
|
||||
panel.setOnStateChange { newState ->
|
||||
Logger.d("ChatScreen", "State change callback received: messages=${newState.messages.size}, typingUsers=${newState.typingUsers.map { it.username }}")
|
||||
// Force state update to trigger recomposition
|
||||
panelState = newState.copy() // Ensure new instance
|
||||
Logger.d("ChatScreen", "panelState updated: messages=${panelState.messages.size}, typingUsers=${panelState.typingUsers.map { it.username }}")
|
||||
}
|
||||
// Initial state
|
||||
panelState = panel.getState()
|
||||
}
|
||||
|
||||
// Debug: Log state changes
|
||||
LaunchedEffect(panelState.messages.size) {
|
||||
Logger.d("ChatScreen", "Messages count changed: ${panelState.messages.size}")
|
||||
}
|
||||
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||
val listState = rememberLazyListState()
|
||||
val scope = rememberCoroutineScope()
|
||||
val navController = LocalNavController.current
|
||||
val hazeState = rememberHazeState(blurEnabled = true)
|
||||
|
||||
val currentTypingUsers = panelState.typingUsers // Directly use from panelState
|
||||
LaunchedEffect(currentTypingUsers) {
|
||||
Logger.d("ChatScreen", "currentTypingUsers updated (from panelState): ${currentTypingUsers.map { it.username }}")
|
||||
}
|
||||
|
||||
// UI state
|
||||
var inputText by rememberSaveable { mutableStateOf("") }
|
||||
var replyTo by rememberSaveable { mutableStateOf<Message?>(null) }
|
||||
var editingMessage by rememberSaveable { mutableStateOf<Message?>(null) }
|
||||
var contextMenuState by remember {
|
||||
mutableStateOf(
|
||||
ContextMenuState(
|
||||
isOpen = false,
|
||||
message = null,
|
||||
position = IntOffset(0, 0)
|
||||
)
|
||||
)
|
||||
}
|
||||
// Collect WebSocket messages
|
||||
LaunchedEffect(Unit) {
|
||||
WebSocketManager.messages.collect { message ->
|
||||
Logger.d("ChatScreen", "Received WebSocket message: type=${message.type}, data=${message.data != null}")
|
||||
when (message.type) {
|
||||
"updates" -> {
|
||||
// Handle batched updates
|
||||
Logger.d("ChatScreen", "Processing updates message")
|
||||
val data = message.data
|
||||
if (data == null) {
|
||||
Logger.w("ChatScreen", "Updates message has no data, skipping")
|
||||
return@collect
|
||||
}
|
||||
Logger.d("ChatScreen", "Updates message has data, parsing...")
|
||||
val json = ApiClient.json
|
||||
try {
|
||||
Logger.d("ChatScreen", "Parsing updates message")
|
||||
val updatesMessage = json.decodeFromJsonElement<WebSocketUpdatesData>(data)
|
||||
Logger.d("ChatScreen", "Updates message parsed: ${updatesMessage.updates.size} updates")
|
||||
// Process each update in the batch
|
||||
updatesMessage.updates.forEach { update ->
|
||||
Logger.d("ChatScreen", "Processing update: type=${update.type}, data=${update.data != null}")
|
||||
val wsMessage = WebSocketMessage(
|
||||
type = update.type,
|
||||
data = update.data
|
||||
)
|
||||
when (update.type) {
|
||||
"newMessage", "messageEdited", "messageDeleted", "typing", "stopTyping", "statusUpdate", "suspended", "account_deleted" -> {
|
||||
Logger.d("ChatScreen", "Launching handleWebSocketMessage for ${update.type}")
|
||||
scope.launch {
|
||||
try {
|
||||
panel.handleWebSocketMessage(wsMessage)
|
||||
} catch (e: Exception) {
|
||||
Logger.e("ChatScreen", "Error handling WebSocket message: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.e("ChatScreen", "Error parsing updates message: ${e.message}", e)
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
"newMessage", "messageEdited", "messageDeleted" -> {
|
||||
scope.launch {
|
||||
panel.handleWebSocketMessage(message)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
Logger.d("ChatScreen", "Unhandled top-level WebSocket message type: ${message.type}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Scroll to bottom when new messages arrive
|
||||
LaunchedEffect(panelState.messages.size) {
|
||||
if (panelState.messages.isNotEmpty()) {
|
||||
scope.launch {
|
||||
listState.animateScrollToItem(panelState.messages.size - 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = panelState.title,
|
||||
style = MaterialTheme.typography.titleLarge
|
||||
)
|
||||
AnimatedContent(
|
||||
targetState = currentTypingUsers.isNotEmpty(),
|
||||
transitionSpec = {
|
||||
fadeIn() togetherWith fadeOut()
|
||||
},
|
||||
label = "typing_status"
|
||||
) { hasTyping ->
|
||||
if (hasTyping) {
|
||||
TypingIndicator(
|
||||
typingUsers = currentTypingUsers.map { it.username },
|
||||
modifier = Modifier.padding(top = 2.dp)
|
||||
)
|
||||
} else {
|
||||
// Empty space to maintain height
|
||||
Box(modifier = Modifier.height(0.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { navController.navigateUp() }) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(Res.string.back)
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
if (panel.showCallButton()) {
|
||||
IconButton(onClick = { /* TODO: Handle call */ }) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Call,
|
||||
contentDescription = "Call"
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
scrollBehavior = scrollBehavior,
|
||||
modifier = Modifier.hazeEffect(
|
||||
state = hazeState,
|
||||
style = HazeMaterials.thin()
|
||||
),
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = Color.Transparent,
|
||||
scrolledContainerColor = Color.Transparent
|
||||
)
|
||||
)
|
||||
},
|
||||
bottomBar = {
|
||||
Column( // New Column to hold ChatInput below the LazyColumn
|
||||
modifier = Modifier
|
||||
.windowInsetsPadding(WindowInsets.ime)
|
||||
.fillMaxWidth()
|
||||
.hazeEffect(
|
||||
state = hazeState,
|
||||
style = HazeMaterials.thin()
|
||||
) {
|
||||
progressive = HazeProgressive.verticalGradient(
|
||||
startIntensity = 0f,
|
||||
endIntensity = 1f
|
||||
)
|
||||
}
|
||||
) {
|
||||
ChatInput(
|
||||
text = inputText,
|
||||
onTextChange = { inputText = it },
|
||||
onSend = { text ->
|
||||
if (editingMessage != null) {
|
||||
scope.launch {
|
||||
panel.handleEditMessage(editingMessage!!.id, text)
|
||||
editingMessage = null
|
||||
}
|
||||
} else {
|
||||
scope.launch {
|
||||
panel.sendMessageWithImmediateDisplay(text, replyTo?.id)
|
||||
replyTo = null
|
||||
}
|
||||
}
|
||||
inputText = ""
|
||||
},
|
||||
typingHandler = panel.getTypingHandler(),
|
||||
replyTo = replyTo,
|
||||
editingMessage = editingMessage,
|
||||
onClearReply = { replyTo = null },
|
||||
onClearEdit = {
|
||||
editingMessage = null
|
||||
inputText = ""
|
||||
},
|
||||
hazeState = hazeState
|
||||
)
|
||||
}
|
||||
}
|
||||
) { innerPadding ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures {
|
||||
// Close context menu on outside tap
|
||||
if (contextMenuState.isOpen) {
|
||||
contextMenuState = contextMenuState.copy(isOpen = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
if (panelState.isLoading) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxSize(), // Fill the entire space of the Box
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp, alignment = Alignment.Bottom)
|
||||
) {
|
||||
item { Spacer(Modifier.height(innerPadding.calculateTopPadding())) } // Spacer for TopAppBar
|
||||
|
||||
items(
|
||||
items = panelState.messages,
|
||||
key = { it.id }
|
||||
) { message ->
|
||||
val isAuthor = message.user_id == currentUserId
|
||||
var messagePosition by remember { mutableStateOf(IntOffset(0, 0)) }
|
||||
var tapOffset by remember { mutableStateOf(Offset(0f, 0f)) }
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.hazeSource(hazeState)
|
||||
.onGloballyPositioned { coordinates ->
|
||||
messagePosition = IntOffset(
|
||||
coordinates.positionInRoot().x.toInt(),
|
||||
coordinates.positionInRoot().y.toInt()
|
||||
)
|
||||
}
|
||||
) {
|
||||
MessageItem(
|
||||
message = message,
|
||||
isAuthor = isAuthor,
|
||||
onLongPress = {
|
||||
contextMenuState = ContextMenuState(
|
||||
isOpen = true,
|
||||
message = message,
|
||||
position = IntOffset(
|
||||
messagePosition.x + tapOffset.x.toInt(),
|
||||
messagePosition.y + tapOffset.y.toInt()
|
||||
)
|
||||
)
|
||||
},
|
||||
onTapPosition = { offset ->
|
||||
tapOffset = offset
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
item { Spacer(Modifier.height(innerPadding.calculateBottomPadding())) } // Spacer for chat input
|
||||
}
|
||||
}
|
||||
|
||||
// Context menu
|
||||
@Suppress("AssignedValueIsNeverRead")
|
||||
MessageContextMenu(
|
||||
state = contextMenuState,
|
||||
isAuthor = contextMenuState.message?.user_id == currentUserId,
|
||||
onDismiss = { contextMenuState = contextMenuState.copy(isOpen = false) },
|
||||
onReply = { message ->
|
||||
replyTo = message
|
||||
if (editingMessage != null) {
|
||||
editingMessage = null
|
||||
inputText = ""
|
||||
}
|
||||
},
|
||||
onEdit = { message ->
|
||||
editingMessage = message
|
||||
inputText = message.content
|
||||
replyTo = null
|
||||
},
|
||||
onDelete = { message ->
|
||||
scope.launch {
|
||||
panel.handleDeleteMessage(message.id)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
package ru.fromchat.ui.chat
|
||||
|
||||
import androidx.compose.animation.core.animate
|
||||
import androidx.compose.animation.core.tween
|
||||
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.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Reply
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
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.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
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.shadow
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.TransformOrigin
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.positionInRoot
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Popup
|
||||
import androidx.compose.ui.window.PopupProperties
|
||||
import ru.fromchat.api.Message
|
||||
|
||||
data class ContextMenuState(
|
||||
val isOpen: Boolean = false,
|
||||
val message: Message? = null,
|
||||
val position: IntOffset = IntOffset(0, 0)
|
||||
)
|
||||
|
||||
@Suppress("AssignedValueIsNeverRead")
|
||||
@Composable
|
||||
fun MessageContextMenu(
|
||||
state: ContextMenuState,
|
||||
isAuthor: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
onReply: (Message) -> Unit,
|
||||
onEdit: (Message) -> Unit,
|
||||
onDelete: (Message) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var shouldShowPopup by remember(state.message) {
|
||||
mutableStateOf(state.isOpen && state.message != null)
|
||||
}
|
||||
val animationProgress = remember { mutableFloatStateOf(0f) }
|
||||
|
||||
LaunchedEffect(state.isOpen) {
|
||||
if (state.isOpen) {
|
||||
// Enter animation
|
||||
animate(
|
||||
initialValue = 0f,
|
||||
targetValue = 1f,
|
||||
animationSpec = tween(200)
|
||||
) { value, _ ->
|
||||
animationProgress.floatValue = value
|
||||
}
|
||||
} else {
|
||||
// Exit animation
|
||||
animate(
|
||||
initialValue = 1f,
|
||||
targetValue = 0f,
|
||||
animationSpec = tween(150)
|
||||
) { value, _ ->
|
||||
animationProgress.floatValue = value
|
||||
}
|
||||
kotlinx.coroutines.delay(150)
|
||||
shouldShowPopup = false
|
||||
}
|
||||
}
|
||||
|
||||
// Show popup when opening
|
||||
LaunchedEffect(state.isOpen, state.message) {
|
||||
if (state.isOpen && state.message != null) {
|
||||
shouldShowPopup = true
|
||||
animationProgress.floatValue = 0f
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldShowPopup && state.message != null) {
|
||||
var popupSize by remember { mutableStateOf(Offset(0f, 0f)) }
|
||||
var popupPosition by remember { mutableStateOf(Offset(0f, 0f)) }
|
||||
|
||||
// Calculate transform origin based on click position relative to popup
|
||||
val transformOriginX = if (popupSize.x > 0f) {
|
||||
val clickXInPopup = state.position.x - popupPosition.x.toInt()
|
||||
(clickXInPopup / popupSize.x).coerceIn(0f, 1f)
|
||||
} else 0f
|
||||
val transformOriginY = if (popupSize.y > 0f) {
|
||||
val clickYInPopup = state.position.y - popupPosition.y.toInt()
|
||||
(clickYInPopup / popupSize.y).coerceIn(0f, 1f)
|
||||
} else 0f
|
||||
|
||||
val scale = animationProgress.floatValue * 0.2f + 0.8f
|
||||
val alpha = animationProgress.floatValue
|
||||
|
||||
Popup(
|
||||
onDismissRequest = onDismiss,
|
||||
alignment = Alignment.TopStart,
|
||||
offset = state.position,
|
||||
properties = PopupProperties(
|
||||
dismissOnBackPress = true,
|
||||
dismissOnClickOutside = true
|
||||
)
|
||||
) {
|
||||
Surface(
|
||||
modifier = modifier
|
||||
.width(160.dp)
|
||||
.onGloballyPositioned { coordinates ->
|
||||
popupSize = Offset(
|
||||
coordinates.size.width.toFloat(),
|
||||
coordinates.size.height.toFloat()
|
||||
)
|
||||
popupPosition = coordinates.positionInRoot()
|
||||
}
|
||||
.graphicsLayer(
|
||||
scaleX = scale,
|
||||
scaleY = scale,
|
||||
alpha = alpha,
|
||||
transformOrigin = TransformOrigin(transformOriginX, transformOriginY)
|
||||
)
|
||||
.shadow(8.dp, RoundedCornerShape(8.dp)),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHighest
|
||||
) {
|
||||
Column {
|
||||
// Reply button (always shown)
|
||||
ContextMenuItem(
|
||||
icon = Icons.AutoMirrored.Filled.Reply,
|
||||
text = "Reply",
|
||||
onClick = {
|
||||
onReply(state.message)
|
||||
onDismiss()
|
||||
}
|
||||
)
|
||||
|
||||
// Edit button (only for own messages)
|
||||
if (isAuthor) {
|
||||
ContextMenuItem(
|
||||
icon = Icons.Default.Edit,
|
||||
text = "Edit",
|
||||
onClick = {
|
||||
onEdit(state.message)
|
||||
onDismiss()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Delete button (only for own messages)
|
||||
if (isAuthor) {
|
||||
ContextMenuItem(
|
||||
icon = Icons.Default.Delete,
|
||||
text = "Delete",
|
||||
onClick = {
|
||||
onDelete(state.message)
|
||||
onDismiss()
|
||||
},
|
||||
isError = true
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ContextMenuItem(
|
||||
icon: ImageVector,
|
||||
text: String,
|
||||
onClick: () -> Unit,
|
||||
isError: Boolean = false,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val textColor = if (isError) {
|
||||
MaterialTheme.colorScheme.error
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
}
|
||||
val iconColor = if (isError) {
|
||||
MaterialTheme.colorScheme.error
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
contentAlignment = Alignment.CenterStart
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = text,
|
||||
tint = iconColor,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = textColor
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
package ru.fromchat.ui.chat
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
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.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
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.draw.shadow
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import ru.fromchat.api.Message
|
||||
import kotlin.time.ExperimentalTime
|
||||
import kotlin.time.Instant
|
||||
|
||||
@OptIn(ExperimentalTime::class)
|
||||
@Composable
|
||||
fun MessageItem(
|
||||
message: Message,
|
||||
isAuthor: Boolean,
|
||||
onLongPress: () -> Unit,
|
||||
onTapPosition: (Offset) -> Unit = {},
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = true,
|
||||
enter = fadeIn(animationSpec = tween(300)) + slideInVertically(
|
||||
initialOffsetY = { 20 },
|
||||
animationSpec = tween(300)
|
||||
),
|
||||
exit = fadeOut(animationSpec = tween(200)) + slideOutVertically(
|
||||
targetOffsetY = { -10 },
|
||||
animationSpec = tween(200)
|
||||
),
|
||||
modifier = modifier
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures(
|
||||
onLongPress = { offset ->
|
||||
onTapPosition(offset)
|
||||
onLongPress()
|
||||
}
|
||||
)
|
||||
},
|
||||
horizontalArrangement = if (isAuthor) Arrangement.End else Arrangement.Start,
|
||||
verticalAlignment = Alignment.Bottom
|
||||
) {
|
||||
if (!isAuthor) {
|
||||
// Avatar at bottom
|
||||
Avatar(
|
||||
profilePictureUrl = message.profile_picture,
|
||||
displayName = message.username,
|
||||
size = 32.dp
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f, fill = false)
|
||||
.widthIn(max = 280.dp),
|
||||
horizontalAlignment = if (isAuthor) Alignment.End else Alignment.Start
|
||||
) {
|
||||
// Reply preview
|
||||
message.reply_to?.let { replyTo ->
|
||||
ReplyPreview(
|
||||
replyTo = replyTo,
|
||||
modifier = Modifier.padding(bottom = 4.dp)
|
||||
)
|
||||
}
|
||||
|
||||
// Message bubble
|
||||
val isDark = isSystemInDarkTheme()
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(
|
||||
RoundedCornerShape(
|
||||
topStart = 20.dp,
|
||||
topEnd = 20.dp,
|
||||
bottomStart = if (isAuthor) 20.dp else 8.dp,
|
||||
bottomEnd = if (isAuthor) 8.dp else 20.dp
|
||||
)
|
||||
)
|
||||
.then(
|
||||
if (isAuthor) {
|
||||
Modifier.shadow(
|
||||
elevation = 8.dp,
|
||||
shape = RoundedCornerShape(
|
||||
topStart = 20.dp,
|
||||
topEnd = 20.dp,
|
||||
bottomStart = 20.dp,
|
||||
bottomEnd = 8.dp
|
||||
),
|
||||
spotColor = if (isDark) Color(0x66000000) else Color(0x33000000)
|
||||
)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
.background(
|
||||
brush = if (isAuthor) {
|
||||
getMessageGradient(isDark)
|
||||
} else {
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
MaterialTheme.colorScheme.surfaceContainerHighest
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp)
|
||||
) {
|
||||
Column {
|
||||
// Username inside bubble (for received messages)
|
||||
if (!isAuthor) {
|
||||
Text(
|
||||
text = message.username,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(bottom = 4.dp)
|
||||
)
|
||||
}
|
||||
|
||||
// Message content
|
||||
Text(
|
||||
text = message.content,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (isAuthor) {
|
||||
Color.White
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
}
|
||||
)
|
||||
|
||||
// Timestamp and edited indicator
|
||||
Row(
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = formatTime(message.timestamp),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontSize = 11.sp,
|
||||
color = if (isAuthor) {
|
||||
Color.White.copy(alpha = 0.7f)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
|
||||
}
|
||||
)
|
||||
if (message.is_edited) {
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
text = "(edited)",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontSize = 11.sp,
|
||||
color = if (isAuthor) {
|
||||
Color.White.copy(alpha = 0.7f)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReplyPreview(
|
||||
replyTo: Message,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||
.padding(horizontal = 8.dp, vertical = 6.dp)
|
||||
) {
|
||||
Column {
|
||||
Text(
|
||||
text = replyTo.username,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontSize = 11.sp
|
||||
)
|
||||
Text(
|
||||
text = replyTo.content.take(50) + if (replyTo.content.length > 50) "..." else "",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontSize = 12.sp,
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ExperimentalTime
|
||||
private fun formatTime(timestamp: String): String {
|
||||
return try {
|
||||
val instant = Instant.parse(timestamp)
|
||||
val localDateTime = instant.toLocalDateTime(TimeZone.currentSystemDefault())
|
||||
val hour = localDateTime.hour.toString().padStart(2, '0')
|
||||
val minute = localDateTime.minute.toString().padStart(2, '0')
|
||||
"$hour:$minute"
|
||||
} catch (e: Exception) {
|
||||
// Fallback: try parsing without timezone if it fails
|
||||
try {
|
||||
val parts = timestamp.split("T")
|
||||
if (parts.size == 2) {
|
||||
val timePart = parts[1].split(".")[0]
|
||||
if (timePart.length >= 5) {
|
||||
timePart.take(5) // Return HH:mm
|
||||
} else {
|
||||
""
|
||||
}
|
||||
} else {
|
||||
""
|
||||
}
|
||||
} catch (e2: Exception) {
|
||||
""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
package ru.fromchat.ui.chat
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import ru.fromchat.api.ApiClient
|
||||
import ru.fromchat.api.Message
|
||||
import ru.fromchat.api.MessageDeletedData
|
||||
import ru.fromchat.api.ReactionUpdateData
|
||||
import ru.fromchat.api.TypingUpdateData
|
||||
import ru.fromchat.api.WebSocketMessage
|
||||
import ru.fromchat.api.WebSocketUpdatesData
|
||||
import ru.fromchat.core.Logger
|
||||
|
||||
class PublicChatPanel(
|
||||
chatName: String,
|
||||
currentUserId: Int?,
|
||||
scope: CoroutineScope
|
||||
) : ChatPanel(
|
||||
id = "public-$chatName",
|
||||
currentUserId = currentUserId,
|
||||
scope = scope
|
||||
) {
|
||||
private val typingHandler = PublicChatTypingHandler(scope)
|
||||
private var messagesLoaded = false
|
||||
|
||||
init {
|
||||
updateState { it.copy(title = chatName) }
|
||||
// Observe typing users from the handler and update panel state
|
||||
scope.launch {
|
||||
typingHandler.typingUsers.collect { users ->
|
||||
Logger.d("PublicChatPanel", "Typing users updated in handler: ${users.map { it.username }}")
|
||||
updateState { it.copy(typingUsers = users) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleReactionUpdate(reactionUpdate: ReactionUpdateData) {
|
||||
updateMessage(reactionUpdate.message_id) { message ->
|
||||
message.copy(reactions = reactionUpdate.reactions)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun sendMessage(content: String, replyToId: Int?, clientMessageId: String?) {
|
||||
ApiClient.sendMessage(content, replyToId, clientMessageId)
|
||||
}
|
||||
|
||||
override suspend fun loadMessages() {
|
||||
if (messagesLoaded) return
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
val response = ApiClient.getMessages(limit = 50)
|
||||
if (response.messages.isNotEmpty()) {
|
||||
clearMessages()
|
||||
response.messages.forEach { message ->
|
||||
addMessage(message)
|
||||
}
|
||||
}
|
||||
setHasMoreMessages(false) // TODO: Implement has_more from API
|
||||
messagesLoaded = true
|
||||
} catch (e: Exception) {
|
||||
// Handle error
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun loadMoreMessages() {
|
||||
if (!_state.hasMoreMessages || _state.isLoadingMore) return
|
||||
|
||||
val messages = _state.messages
|
||||
if (messages.isEmpty()) return
|
||||
|
||||
val oldestMessage = messages.first()
|
||||
setLoadingMore(true)
|
||||
try {
|
||||
val response = ApiClient.getMessages(limit = 50, beforeId = oldestMessage.id)
|
||||
if (response.messages.isNotEmpty()) {
|
||||
// Prepend older messages (they come in reverse chronological order)
|
||||
updateState { currentState ->
|
||||
currentState.copy(
|
||||
messages = response.messages.reversed() + currentState.messages
|
||||
)
|
||||
}
|
||||
}
|
||||
setHasMoreMessages(false) // TODO: Implement has_more from API
|
||||
} catch (e: Exception) {
|
||||
// Handle error
|
||||
} finally {
|
||||
setLoadingMore(false)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleSingleUpdate(updateMessage: WebSocketMessage) {
|
||||
val json = ApiClient.json
|
||||
when (updateMessage.type) {
|
||||
"newMessage" -> {
|
||||
val data = updateMessage.data ?: return
|
||||
val newMsg = json.decodeFromJsonElement(Message.serializer(), data)
|
||||
Logger.d("PublicChatPanel", "New message received: id=${newMsg.id}, content=${newMsg.content.take(50)}")
|
||||
|
||||
if (newMsg.client_message_id != null && newMsg.user_id == currentUserId) {
|
||||
handleMessageConfirmed(newMsg.client_message_id, newMsg)
|
||||
} else {
|
||||
addMessage(newMsg)
|
||||
}
|
||||
}
|
||||
"messageEdited" -> {
|
||||
val data = updateMessage.data ?: return
|
||||
val editedMsg = json.decodeFromJsonElement(Message.serializer(), data)
|
||||
updateMessage(editedMsg.id) { editedMsg }
|
||||
}
|
||||
"messageDeleted" -> {
|
||||
val data = updateMessage.data ?: return
|
||||
val deletedData = json.decodeFromJsonElement(MessageDeletedData.serializer(), data)
|
||||
removeMessage(deletedData.message_id)
|
||||
}
|
||||
"reactionUpdate" -> {
|
||||
val data = updateMessage.data ?: return
|
||||
val reactionUpdate = json.decodeFromJsonElement(ReactionUpdateData.serializer(), data)
|
||||
handleReactionUpdate(reactionUpdate)
|
||||
}
|
||||
"typing" -> {
|
||||
val data = updateMessage.data ?: return
|
||||
val typingData = json.decodeFromJsonElement(TypingUpdateData.serializer(), data)
|
||||
Logger.d("PublicChatPanel", "Received typing event for user: ${typingData.username}")
|
||||
typingHandler.handleTypingEvent(typingData.userId, typingData.username)
|
||||
}
|
||||
"stopTyping" -> {
|
||||
val data = updateMessage.data ?: return
|
||||
val typingData = json.decodeFromJsonElement(TypingUpdateData.serializer(), data)
|
||||
Logger.d("PublicChatPanel", "Received stopTyping event for user: ${typingData.username}")
|
||||
typingHandler.handleStopTypingEvent(typingData.userId)
|
||||
}
|
||||
"statusUpdate" -> {
|
||||
// Handled in ChatScreen or by global WebSocketManager listeners
|
||||
}
|
||||
"suspended" -> {
|
||||
// Handled by global WebSocketManager listeners or shown as a toast
|
||||
}
|
||||
"account_deleted" -> {
|
||||
// Handled by global WebSocketManager listeners or shown as a toast
|
||||
}
|
||||
else -> {
|
||||
Logger.w("PublicChatPanel", "Unhandled WebSocket update type: ${updateMessage.type}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun handleWebSocketMessage(message: WebSocketMessage) {
|
||||
Logger.d("PublicChatPanel", "Handling raw WebSocket message: type=${message.type}")
|
||||
if (message.type == "updates") {
|
||||
val json = ApiClient.json
|
||||
val data = message.data ?: return
|
||||
val updatesData = json.decodeFromJsonElement(WebSocketUpdatesData.serializer(), data)
|
||||
Logger.d("PublicChatPanel", "Received ${updatesData.updates.size} batched updates (seq: ${updatesData.seq})")
|
||||
updatesData.updates.forEach { update ->
|
||||
handleSingleUpdate(update)
|
||||
}
|
||||
} else {
|
||||
// Fallback for non-batched messages (legacy or direct signals)
|
||||
handleSingleUpdate(message)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun handleEditMessage(messageId: Int, content: String) {
|
||||
ApiClient.editMessage(messageId, content)
|
||||
}
|
||||
|
||||
override suspend fun handleDeleteMessage(messageId: Int) {
|
||||
// Remove immediately from UI
|
||||
deleteMessageImmediately(messageId)
|
||||
|
||||
// Send delete request
|
||||
ApiClient.deleteMessage(messageId)
|
||||
}
|
||||
|
||||
override fun showCallButton(): Boolean = false
|
||||
|
||||
override fun getTypingHandler(): TypingHandler = typingHandler
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package ru.fromchat.ui.chat
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import ru.fromchat.api.ApiClient
|
||||
|
||||
@Composable
|
||||
fun PublicChatScreen() {
|
||||
val scope = rememberCoroutineScope()
|
||||
val currentUserId = ApiClient.user?.id
|
||||
|
||||
// Create panel instance
|
||||
val panel = remember {
|
||||
PublicChatPanel(
|
||||
chatName = "General Chat",
|
||||
currentUserId = currentUserId,
|
||||
scope = scope
|
||||
)
|
||||
}
|
||||
|
||||
// Load messages on first appear
|
||||
LaunchedEffect(Unit) {
|
||||
scope.launch {
|
||||
panel.loadMessages()
|
||||
}
|
||||
}
|
||||
|
||||
// Render with ChatScreen
|
||||
ChatScreen(
|
||||
panel = panel,
|
||||
currentUserId = currentUserId
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package ru.fromchat.ui.chat
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
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 kotlinx.serialization.Serializable
|
||||
import ru.fromchat.api.ApiClient
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
/**
|
||||
* Interface for handling typing indicators
|
||||
*/
|
||||
interface TypingHandler {
|
||||
fun sendTyping()
|
||||
fun stopTyping()
|
||||
fun handleTypingEvent(userId: Int, username: String)
|
||||
fun handleStopTypingEvent(userId: Int)
|
||||
val typingUsers: StateFlow<List<TypingUser>>
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class TypingUser(
|
||||
val userId: Int,
|
||||
val username: String
|
||||
)
|
||||
|
||||
/**
|
||||
* Typing handler for public chat using WebSocket
|
||||
*/
|
||||
class PublicChatTypingHandler(
|
||||
private val scope: CoroutineScope
|
||||
) : TypingHandler {
|
||||
private var stopTypingJob: Job? = null
|
||||
private val _typingUsers = MutableStateFlow<List<TypingUser>>(emptyList())
|
||||
override val typingUsers = _typingUsers.asStateFlow()
|
||||
|
||||
override fun sendTyping() {
|
||||
scope.launch {
|
||||
try {
|
||||
ApiClient.sendTyping()
|
||||
} catch (e: Exception) {
|
||||
// Ignore errors
|
||||
}
|
||||
}
|
||||
|
||||
// Cancel existing stop typing job
|
||||
stopTypingJob?.cancel()
|
||||
|
||||
// Schedule stop typing after delay
|
||||
stopTypingJob = scope.launch {
|
||||
delay(3.seconds) // 3 seconds
|
||||
stopTyping()
|
||||
}
|
||||
}
|
||||
|
||||
override fun stopTyping() {
|
||||
stopTypingJob?.cancel()
|
||||
stopTypingJob = null
|
||||
scope.launch {
|
||||
try {
|
||||
ApiClient.sendStopTyping()
|
||||
} catch (e: Exception) {
|
||||
// Ignore errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun handleTypingEvent(userId: Int, username: String) {
|
||||
_typingUsers.update { currentUsers ->
|
||||
if (currentUsers.none { it.userId == userId }) {
|
||||
currentUsers + TypingUser(userId, username)
|
||||
} else {
|
||||
currentUsers
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun handleStopTypingEvent(userId: Int) {
|
||||
_typingUsers.update { currentUsers ->
|
||||
currentUsers.filter { it.userId != userId }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
package ru.fromchat.ui.chat
|
||||
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.keyframes
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import ru.fromchat.Res
|
||||
import ru.fromchat.typing_many
|
||||
import ru.fromchat.typing_single
|
||||
import ru.fromchat.typing_two
|
||||
|
||||
@Composable
|
||||
fun TypingIndicator(
|
||||
typingUsers: List<String>,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
if (typingUsers.isEmpty()) return
|
||||
|
||||
Row(
|
||||
modifier = modifier,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
TypingDots()
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = formatTypingText(typingUsers),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun formatTypingText(typingUsers: List<String>): String {
|
||||
return when (typingUsers.size) {
|
||||
0 -> ""
|
||||
1 -> stringResource(Res.string.typing_single, typingUsers[0])
|
||||
2 -> stringResource(Res.string.typing_two, typingUsers[0], typingUsers[1])
|
||||
else -> stringResource(
|
||||
Res.string.typing_many,
|
||||
typingUsers[0],
|
||||
typingUsers[1],
|
||||
typingUsers.size - 2
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TypingDots() {
|
||||
val infiniteTransition = rememberInfiniteTransition(label = "typing_dots")
|
||||
|
||||
val dot1Alpha by infiniteTransition.animateFloat(
|
||||
initialValue = 0.3f,
|
||||
targetValue = 1f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = keyframes {
|
||||
durationMillis = 1400
|
||||
0.3f at 0
|
||||
1f at 200
|
||||
0.3f at 400
|
||||
}
|
||||
),
|
||||
label = "dot1"
|
||||
)
|
||||
|
||||
val dot2Alpha by infiniteTransition.animateFloat(
|
||||
initialValue = 0.3f,
|
||||
targetValue = 1f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = keyframes {
|
||||
durationMillis = 1400
|
||||
0.3f at 200
|
||||
1f at 400
|
||||
0.3f at 600
|
||||
}
|
||||
),
|
||||
label = "dot2"
|
||||
)
|
||||
|
||||
val dot3Alpha by infiniteTransition.animateFloat(
|
||||
initialValue = 0.3f,
|
||||
targetValue = 1f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = keyframes {
|
||||
durationMillis = 1400
|
||||
0.3f at 400
|
||||
1f at 600
|
||||
0.3f at 800
|
||||
}
|
||||
),
|
||||
label = "dot3"
|
||||
)
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Dot(alpha = dot1Alpha)
|
||||
Spacer(modifier = Modifier.width(2.dp))
|
||||
Dot(alpha = dot2Alpha)
|
||||
Spacer(modifier = Modifier.width(2.dp))
|
||||
Dot(alpha = dot3Alpha)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Dot(alpha: Float) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.width(4.dp)
|
||||
.height(4.dp)
|
||||
.alpha(alpha),
|
||||
shape = CircleShape,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package ru.fromchat.ui.main
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MediumTopAppBar
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import ru.fromchat.Res
|
||||
import ru.fromchat.chat_last_mesaage
|
||||
import ru.fromchat.chats
|
||||
import ru.fromchat.public_chat
|
||||
import ru.fromchat.ui.LocalNavController
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ChatsTab() {
|
||||
val navController = LocalNavController.current
|
||||
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
|
||||
|
||||
Scaffold(
|
||||
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
topBar = {
|
||||
MediumTopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = stringResource(Res.string.chats),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
},
|
||||
scrollBehavior = scrollBehavior
|
||||
)
|
||||
}
|
||||
) { innerPadding ->
|
||||
LazyColumn(contentPadding = innerPadding) {
|
||||
item {
|
||||
ListItem(
|
||||
headlineContent = { Text(stringResource(Res.string.public_chat)) },
|
||||
supportingContent = { Text(stringResource(Res.string.chat_last_mesaage)) },
|
||||
modifier = Modifier.clickable {
|
||||
navController.navigate("chats/publicChat")
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package ru.fromchat.ui.main
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeDrawing
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Chat
|
||||
import androidx.compose.material.icons.filled.Contacts
|
||||
import androidx.compose.material.icons.filled.Mail
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.NavigationBar
|
||||
import androidx.compose.material3.NavigationBarItem
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import ru.fromchat.Res
|
||||
import ru.fromchat.chats
|
||||
import ru.fromchat.coming_soon
|
||||
import ru.fromchat.contacts
|
||||
import ru.fromchat.dms
|
||||
import ru.fromchat.settings
|
||||
import ru.fromchat.utils.exclude
|
||||
|
||||
@Suppress("AssignedValueIsNeverRead")
|
||||
@Composable
|
||||
fun MainScreen(onLogout: () -> Unit = {}) {
|
||||
var selectedTab by rememberSaveable { mutableStateOf("chats") }
|
||||
|
||||
Scaffold(
|
||||
bottomBar = {
|
||||
NavigationBar {
|
||||
NavigationBarItem(
|
||||
selected = selectedTab == "chats",
|
||||
onClick = { selectedTab = "chats" },
|
||||
label = { Text(stringResource(Res.string.chats)) },
|
||||
icon = { Icon(Icons.AutoMirrored.Filled.Chat, contentDescription = null) }
|
||||
)
|
||||
NavigationBarItem(
|
||||
selected = selectedTab == "contacts",
|
||||
onClick = { selectedTab = "contacts" },
|
||||
label = { Text(stringResource(Res.string.contacts)) },
|
||||
icon = { Icon(Icons.Filled.Contacts, contentDescription = null) }
|
||||
)
|
||||
NavigationBarItem(
|
||||
selected = selectedTab == "dms",
|
||||
onClick = { selectedTab = "dms" },
|
||||
label = { Text(stringResource(Res.string.dms)) },
|
||||
icon = { Icon(Icons.Filled.Mail, contentDescription = null) }
|
||||
)
|
||||
NavigationBarItem(
|
||||
selected = selectedTab == "settings",
|
||||
onClick = { selectedTab = "settings" },
|
||||
label = { Text(stringResource(Res.string.settings)) },
|
||||
icon = { Icon(Icons.Filled.Settings, contentDescription = null) }
|
||||
)
|
||||
}
|
||||
},
|
||||
contentWindowInsets = WindowInsets.safeDrawing.exclude(WindowInsetsSides.Top),
|
||||
modifier = Modifier.imePadding()
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
) {
|
||||
when (selectedTab) {
|
||||
"chats" -> ChatsTab()
|
||||
"contacts" -> {
|
||||
Text(stringResource(Res.string.coming_soon))
|
||||
}
|
||||
"dms" -> {
|
||||
Text(stringResource(Res.string.coming_soon))
|
||||
}
|
||||
"settings" -> {
|
||||
SettingsTab(onLogout = onLogout)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
package ru.fromchat.ui.main
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Logout
|
||||
import androidx.compose.material.icons.filled.Brush
|
||||
import androidx.compose.material.icons.filled.DarkMode
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.LightMode
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material.icons.filled.Storage
|
||||
import androidx.compose.material.icons.filled.Wallpaper
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.MediumTopAppBar
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.rememberTopAppBarState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.pr0gramm3r101.components.Category
|
||||
import com.pr0gramm3r101.components.ListItem
|
||||
import com.pr0gramm3r101.components.SwitchListItem
|
||||
import com.pr0gramm3r101.utils.materialYouAvailable
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import ru.fromchat.Res
|
||||
import ru.fromchat.about
|
||||
import ru.fromchat.api.ApiClient
|
||||
import ru.fromchat.api.WebSocketManager
|
||||
import ru.fromchat.as_system
|
||||
import ru.fromchat.change_server
|
||||
import ru.fromchat.change_server_d
|
||||
import ru.fromchat.core.Settings
|
||||
import ru.fromchat.dark
|
||||
import ru.fromchat.light
|
||||
import ru.fromchat.logout
|
||||
import ru.fromchat.materialYou
|
||||
import ru.fromchat.materialYou_d
|
||||
import ru.fromchat.settings
|
||||
import ru.fromchat.theme
|
||||
import ru.fromchat.ui.LocalNavController
|
||||
import ru.fromchat.ui.Theme
|
||||
import ru.fromchat.ui.dynamicThemeEnabled
|
||||
import ru.fromchat.ui.theme
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun SettingsTab(
|
||||
onLogout: () -> Unit
|
||||
) {
|
||||
TabBase {
|
||||
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
|
||||
val navController = LocalNavController.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
Scaffold(
|
||||
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
topBar = {
|
||||
MediumTopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = stringResource(Res.string.settings),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = { navController.navigate("about") }) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Info,
|
||||
contentDescription = stringResource(Res.string.about)
|
||||
)
|
||||
}
|
||||
},
|
||||
scrollBehavior = scrollBehavior
|
||||
)
|
||||
},
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
Modifier
|
||||
.padding(innerPadding)
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
var materialYouSwitch by remember {
|
||||
mutableStateOf(
|
||||
Settings.materialYou && materialYouAvailable
|
||||
)
|
||||
}
|
||||
|
||||
Category(Modifier.padding(top = 16.dp)) {
|
||||
// Material You
|
||||
SwitchListItem(
|
||||
headline = stringResource(Res.string.materialYou),
|
||||
supportingText = stringResource(Res.string.materialYou_d),
|
||||
enabled = materialYouAvailable,
|
||||
checked = materialYouSwitch,
|
||||
onCheckedChange = {
|
||||
materialYouSwitch = it
|
||||
Settings.materialYou = it
|
||||
dynamicThemeEnabled = it
|
||||
},
|
||||
divider = true,
|
||||
dividerColor = MaterialTheme.colorScheme.surface,
|
||||
dividerThickness = 2.dp,
|
||||
leadingContent = {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Wallpaper,
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
// Theme
|
||||
ListItem(
|
||||
headline = stringResource(Res.string.theme),
|
||||
leadingContent = {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Brush,
|
||||
contentDescription = null
|
||||
)
|
||||
},
|
||||
bottomContent = {
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
var selectedIndex by remember { mutableIntStateOf(Settings.theme.ordinal) }
|
||||
val options = listOf(
|
||||
stringResource(Res.string.as_system),
|
||||
stringResource(Res.string.light),
|
||||
stringResource(Res.string.dark)
|
||||
)
|
||||
options.forEachIndexed { index, label ->
|
||||
FilterChip(
|
||||
onClick = {
|
||||
selectedIndex = index
|
||||
Settings.theme = Theme.entries[index]
|
||||
theme = Theme.entries[index]
|
||||
},
|
||||
selected = index == selectedIndex,
|
||||
leadingIcon = {
|
||||
if (index == 0) {
|
||||
Spacer(Modifier.width(16.dp))
|
||||
}
|
||||
when (index) {
|
||||
0 -> Icon(Icons.Filled.Settings, null)
|
||||
1 -> Icon(Icons.Filled.LightMode, null)
|
||||
2 -> Icon(Icons.Filled.DarkMode, null)
|
||||
}
|
||||
},
|
||||
label = {
|
||||
Text(
|
||||
text = label,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Server Configuration
|
||||
Category(Modifier.padding(top = 16.dp)) {
|
||||
ListItem(
|
||||
headline = stringResource(Res.string.change_server),
|
||||
supportingText = stringResource(Res.string.change_server_d),
|
||||
onClick = {
|
||||
// Logout and navigate to server config when server changes
|
||||
scope.launch {
|
||||
navController.navigate("serverConfig")
|
||||
}
|
||||
},
|
||||
divider = true,
|
||||
dividerColor = MaterialTheme.colorScheme.surface,
|
||||
dividerThickness = 2.dp,
|
||||
leadingContent = {
|
||||
Icon(Icons.Filled.Storage, null)
|
||||
}
|
||||
)
|
||||
|
||||
ListItem(
|
||||
headline = stringResource(Res.string.logout),
|
||||
leadingContent = {
|
||||
Icon(Icons.AutoMirrored.Filled.Logout, null)
|
||||
},
|
||||
onClick = {
|
||||
scope.launch {
|
||||
// Logout
|
||||
try {
|
||||
ApiClient.token?.let { token ->
|
||||
ApiClient.logout(token)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// Ignore logout errors
|
||||
}
|
||||
|
||||
// Clear API client state
|
||||
ApiClient.token = null
|
||||
ApiClient.user = null
|
||||
|
||||
// Shutdown WebSocket
|
||||
WebSocketManager.shutdown()
|
||||
|
||||
// Navigate back to auth
|
||||
onLogout()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package ru.fromchat.ui.main
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import com.pr0gramm3r101.utils.resetFocus
|
||||
|
||||
@Composable
|
||||
inline fun TabBase(
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
val focusManager = LocalFocusManager.current
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.clickable(
|
||||
interactionSource = interactionSource,
|
||||
indication = null
|
||||
) {
|
||||
resetFocus(keyboardController, focusManager)
|
||||
}
|
||||
.then(modifier)
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
package ru.fromchat.ui.setup
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.ime
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.rememberTopAppBarState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.pr0gramm3r101.utils.navigateAndWipeBackStack
|
||||
import com.pr0gramm3r101.utils.storage.ServerConfigData
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import ru.fromchat.Res
|
||||
import ru.fromchat.api.ApiClient
|
||||
import ru.fromchat.api.WebSocketManager
|
||||
import ru.fromchat.back
|
||||
import ru.fromchat.core.config.Config
|
||||
import ru.fromchat.https_enabled
|
||||
import ru.fromchat.save_continue
|
||||
import ru.fromchat.server_config_subtitle
|
||||
import ru.fromchat.server_config_title
|
||||
import ru.fromchat.server_url_label
|
||||
import ru.fromchat.ui.LocalNavController
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ServerConfigScreen() {
|
||||
val navController = LocalNavController.current
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
||||
|
||||
// Load existing config if available
|
||||
var serverUrl by remember { mutableStateOf("fromchat.ru") }
|
||||
var httpsEnabled by remember { mutableStateOf(true) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
Config.serverConfig.value?.let {
|
||||
serverUrl = it.serverUrl
|
||||
httpsEnabled = it.httpsEnabled
|
||||
}
|
||||
}
|
||||
|
||||
var isLoading by remember { mutableStateOf(false) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
Scaffold(
|
||||
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {},
|
||||
navigationIcon = {
|
||||
if (navController.currentBackStackEntry != null) {
|
||||
IconButton(onClick = navController::navigateUp) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(Res.string.back)
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
scrollBehavior = scrollBehavior
|
||||
)
|
||||
}
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
.windowInsetsPadding(WindowInsets.ime)
|
||||
.padding(horizontal = 24.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(Res.string.server_config_title),
|
||||
style = MaterialTheme.typography.headlineMedium
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(Res.string.server_config_subtitle),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = serverUrl,
|
||||
onValueChange = { serverUrl = it },
|
||||
label = { Text(stringResource(Res.string.server_url_label)) },
|
||||
placeholder = { Text("fromchat.ru") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Checkbox(
|
||||
checked = httpsEnabled,
|
||||
onCheckedChange = { httpsEnabled = it }
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(stringResource(Res.string.https_enabled))
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
isLoading = true
|
||||
scope.launch {
|
||||
Config.updateServerConfig(ServerConfigData(serverUrl, httpsEnabled))
|
||||
|
||||
// Ensure we're logged out when server config changes
|
||||
runCatching {
|
||||
ApiClient.token?.let { token ->
|
||||
ApiClient.logout(token)
|
||||
}
|
||||
}
|
||||
|
||||
// Clear API client state
|
||||
ApiClient.token = null
|
||||
ApiClient.user = null
|
||||
|
||||
// Shutdown WebSocket (will reconnect on login)
|
||||
WebSocketManager.shutdown()
|
||||
|
||||
// Navigate to login and wipe entire back stack
|
||||
navController.navigateAndWipeBackStack("login")
|
||||
}
|
||||
},
|
||||
enabled = !isLoading && serverUrl.isNotBlank(),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(stringResource(Res.string.save_continue))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package ru.fromchat.utils
|
||||
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.exclude
|
||||
import androidx.compose.foundation.layout.only
|
||||
|
||||
@Suppress("NOTHING_TO_INLINE")
|
||||
inline fun WindowInsets.exclude(sides: WindowInsetsSides) = exclude(this.only(sides))
|
||||
@@ -1,18 +1,17 @@
|
||||
@file:Suppress("NOTHING_TO_INLINE")
|
||||
package ru.fromchat.utils
|
||||
|
||||
import android.util.Log
|
||||
import io.ktor.client.HttpClientConfig
|
||||
import io.ktor.client.plugins.ClientRequestException
|
||||
import io.ktor.client.plugins.DefaultRequest
|
||||
import io.ktor.client.plugins.ResponseException
|
||||
import io.ktor.client.plugins.ServerResponseException
|
||||
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
||||
import io.ktor.client.plugins.logging.DEFAULT
|
||||
import io.ktor.client.plugins.logging.LogLevel
|
||||
import io.ktor.client.plugins.logging.Logger
|
||||
import io.ktor.client.plugins.logging.Logging
|
||||
import io.ktor.client.plugins.logging.LoggingConfig
|
||||
import io.ktor.client.plugins.logging.SIMPLE
|
||||
import io.ktor.client.statement.HttpResponse
|
||||
import io.ktor.client.statement.bodyAsText
|
||||
import io.ktor.serialization.Configuration
|
||||
@@ -69,34 +68,21 @@ inline fun HttpClientConfig<*>.logging(
|
||||
* Installs [Logging] in a Ktor client config with default settings (all logs).
|
||||
*/
|
||||
inline fun HttpClientConfig<*>.logging() = logging {
|
||||
logger = Logger.DEFAULT
|
||||
logger = Logger.SIMPLE
|
||||
level = LogLevel.ALL
|
||||
}
|
||||
|
||||
/**
|
||||
* Throws if the HTTP response status code is an error (>=400).
|
||||
* @return The [HttpResponse] if successful.
|
||||
* @throws ClientRequestException
|
||||
* @throws ServerResponseException
|
||||
* @throws ResponseException on error.
|
||||
* @throws ClientRequestException on 4xx error code.
|
||||
* @throws ServerResponseException on 5xx error code.
|
||||
* @throws ResponseException on unknown error code.
|
||||
*/
|
||||
suspend fun HttpResponse.failOnError(): HttpResponse {
|
||||
Log.d("failOnError", "Checking the response code...")
|
||||
val statusCode = status.value
|
||||
suspend fun body() = bodyAsText()
|
||||
|
||||
Log.d("failOnError", "code: $statusCode")
|
||||
|
||||
when (statusCode) {
|
||||
in 400..499 -> throw ClientRequestException(this, body())
|
||||
in 500..599 -> throw ServerResponseException(this, body())
|
||||
}
|
||||
|
||||
if (statusCode >= 600) {
|
||||
throw ResponseException(this, body())
|
||||
}
|
||||
|
||||
Log.d("failOnError", "Success")
|
||||
|
||||
return this
|
||||
}
|
||||
suspend fun HttpResponse.failOnError() =
|
||||
when (status.value) {
|
||||
in 100..399 -> this
|
||||
in 400..499 -> throw ClientRequestException(this, bodyAsText())
|
||||
in 500..599 -> throw ServerResponseException(this, bodyAsText())
|
||||
else -> throw ResponseException(this, bodyAsText())
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package ru.fromchat
|
||||
|
||||
import ru.fromchat.ui.App
|
||||
import androidx.compose.ui.window.ComposeUIViewController
|
||||
|
||||
fun MainViewController() = ComposeUIViewController { App() }
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package ru.fromchat.api
|
||||
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.HttpClientConfig
|
||||
import io.ktor.client.engine.darwin.Darwin
|
||||
import io.ktor.client.engine.darwin.DarwinClientEngineConfig
|
||||
|
||||
actual fun createPlatformHttpClient(
|
||||
block: HttpClientConfig<*>.() -> Unit
|
||||
): HttpClient {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
return HttpClient(Darwin, block as HttpClientConfig<DarwinClientEngineConfig>.() -> Unit)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package ru.fromchat.core
|
||||
|
||||
import platform.Foundation.NSLog
|
||||
|
||||
actual object Logger {
|
||||
actual fun d(tag: String, message: String, throwable: Throwable?) {
|
||||
NSLog("DEBUG: [%s] %s %s", tag, message, throwable?.message ?: "")
|
||||
}
|
||||
|
||||
actual fun i(tag: String, message: String, throwable: Throwable?) {
|
||||
NSLog("INFO: [%s] %s %s", tag, message, throwable?.message ?: "")
|
||||
}
|
||||
|
||||
actual fun w(tag: String, message: String, throwable: Throwable?) {
|
||||
NSLog("WARN: [%s] %s %s", tag, message, throwable?.message ?: "")
|
||||
}
|
||||
|
||||
actual fun e(tag: String, message: String, throwable: Throwable?) {
|
||||
NSLog("ERROR: [%s] %s %s", tag, message, throwable?.message ?: "")
|
||||
}
|
||||
}
|
||||
|
||||