36 Commits

181 changed files with 9955 additions and 2351 deletions
+25
View File
@@ -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.
+7
View File
@@ -17,3 +17,10 @@ build
.externalNativeBuild
.cxx
local.properties
app/android/keys
releases
release
*.xcuserstate
xcuserdata
Generated
+1
View File
@@ -0,0 +1 @@
FromChat
+26
View File
@@ -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>
+8
View File
@@ -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>
+8
View File
@@ -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>
+123
View File
@@ -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>
+5
View File
@@ -0,0 +1,5 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
</state>
</component>
+6
View File
@@ -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>
+12
View File
@@ -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>
+8
View File
@@ -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>
-833
View File
@@ -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" />
+4
View File
@@ -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
View File
@@ -1 +0,0 @@
/build
+124
View File
@@ -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"))
}
+21
View File
@@ -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>
Binary file not shown.

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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

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>
-74
View File
@@ -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)
}
+3
View File
@@ -0,0 +1,3 @@
TEAM_ID=82MWK23YNM
BUNDLE_ID=ru.fromchat.app
APP_NAME=FromChat
+408
View File
@@ -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
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
+18
View File
@@ -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
}
}
+55
View File
@@ -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
}
}
+11
View File
@@ -0,0 +1,11 @@
import SwiftUI
@main
struct iOSApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
+103
View File
@@ -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 &amp; 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 ?: "")
}
}

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