Compare commits
2 Commits
7664433064
...
a0e9244d6a
| Author | SHA1 | Date | |
|---|---|---|---|
| a0e9244d6a | |||
| 24fe3ef543 |
@@ -0,0 +1,13 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="DeviceTable">
|
||||||
|
<option name="columnSorters">
|
||||||
|
<list>
|
||||||
|
<ColumnSorterState>
|
||||||
|
<option name="column" value="Name" />
|
||||||
|
<option name="order" value="ASCENDING" />
|
||||||
|
</ColumnSorterState>
|
||||||
|
</list>
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
*.iml
|
||||||
|
.gradle
|
||||||
|
/local.properties
|
||||||
|
/.idea/caches
|
||||||
|
/.idea/libraries
|
||||||
|
/.idea/modules.xml
|
||||||
|
/.idea/workspace.xml
|
||||||
|
/.idea/navEditor.xml
|
||||||
|
/.idea/assetWizardSettings.xml
|
||||||
|
.DS_Store
|
||||||
|
/build
|
||||||
|
/captures
|
||||||
|
.externalNativeBuild
|
||||||
|
.cxx
|
||||||
|
local.properties
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# Default ignored files
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Chatapp
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="AndroidProjectSystem">
|
||||||
|
<option name="providerId" value="com.android.tools.idea.GradleProjectSystem" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="AppInsightsSettings">
|
||||||
|
<option name="selectedTabId" value="Android vitals" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
<component name="ProjectCodeStyleConfiguration">
|
||||||
|
<code_scheme name="Project" version="173">
|
||||||
|
<JetCodeStyleSettings>
|
||||||
|
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
|
||||||
|
</JetCodeStyleSettings>
|
||||||
|
<codeStyleSettings language="XML">
|
||||||
|
<option name="FORCE_REARRANGE_MODE" value="1" />
|
||||||
|
<indentOptions>
|
||||||
|
<option name="CONTINUATION_INDENT_SIZE" value="4" />
|
||||||
|
</indentOptions>
|
||||||
|
<arrangement>
|
||||||
|
<rules>
|
||||||
|
<section>
|
||||||
|
<rule>
|
||||||
|
<match>
|
||||||
|
<AND>
|
||||||
|
<NAME>xmlns:android</NAME>
|
||||||
|
<XML_ATTRIBUTE />
|
||||||
|
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
||||||
|
</AND>
|
||||||
|
</match>
|
||||||
|
</rule>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<rule>
|
||||||
|
<match>
|
||||||
|
<AND>
|
||||||
|
<NAME>xmlns:.*</NAME>
|
||||||
|
<XML_ATTRIBUTE />
|
||||||
|
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
||||||
|
</AND>
|
||||||
|
</match>
|
||||||
|
<order>BY_NAME</order>
|
||||||
|
</rule>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<rule>
|
||||||
|
<match>
|
||||||
|
<AND>
|
||||||
|
<NAME>.*:id</NAME>
|
||||||
|
<XML_ATTRIBUTE />
|
||||||
|
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
|
||||||
|
</AND>
|
||||||
|
</match>
|
||||||
|
</rule>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<rule>
|
||||||
|
<match>
|
||||||
|
<AND>
|
||||||
|
<NAME>.*:name</NAME>
|
||||||
|
<XML_ATTRIBUTE />
|
||||||
|
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
|
||||||
|
</AND>
|
||||||
|
</match>
|
||||||
|
</rule>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<rule>
|
||||||
|
<match>
|
||||||
|
<AND>
|
||||||
|
<NAME>name</NAME>
|
||||||
|
<XML_ATTRIBUTE />
|
||||||
|
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
||||||
|
</AND>
|
||||||
|
</match>
|
||||||
|
</rule>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<rule>
|
||||||
|
<match>
|
||||||
|
<AND>
|
||||||
|
<NAME>style</NAME>
|
||||||
|
<XML_ATTRIBUTE />
|
||||||
|
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
||||||
|
</AND>
|
||||||
|
</match>
|
||||||
|
</rule>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<rule>
|
||||||
|
<match>
|
||||||
|
<AND>
|
||||||
|
<NAME>.*</NAME>
|
||||||
|
<XML_ATTRIBUTE />
|
||||||
|
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
||||||
|
</AND>
|
||||||
|
</match>
|
||||||
|
<order>BY_NAME</order>
|
||||||
|
</rule>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<rule>
|
||||||
|
<match>
|
||||||
|
<AND>
|
||||||
|
<NAME>.*</NAME>
|
||||||
|
<XML_ATTRIBUTE />
|
||||||
|
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
|
||||||
|
</AND>
|
||||||
|
</match>
|
||||||
|
<order>ANDROID_ATTRIBUTE_ORDER</order>
|
||||||
|
</rule>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<rule>
|
||||||
|
<match>
|
||||||
|
<AND>
|
||||||
|
<NAME>.*</NAME>
|
||||||
|
<XML_ATTRIBUTE />
|
||||||
|
<XML_NAMESPACE>.*</XML_NAMESPACE>
|
||||||
|
</AND>
|
||||||
|
</match>
|
||||||
|
<order>BY_NAME</order>
|
||||||
|
</rule>
|
||||||
|
</section>
|
||||||
|
</rules>
|
||||||
|
</arrangement>
|
||||||
|
</codeStyleSettings>
|
||||||
|
<codeStyleSettings language="kotlin">
|
||||||
|
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
|
||||||
|
</codeStyleSettings>
|
||||||
|
</code_scheme>
|
||||||
|
</component>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<component name="ProjectCodeStyleConfiguration">
|
||||||
|
<state>
|
||||||
|
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
|
||||||
|
</state>
|
||||||
|
</component>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="CompilerConfiguration">
|
||||||
|
<bytecodeTargetLevel target="21" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="deploymentTargetSelector">
|
||||||
|
<selectionStates>
|
||||||
|
<SelectionState runConfigName="app">
|
||||||
|
<option name="selectionMode" value="DROPDOWN" />
|
||||||
|
</SelectionState>
|
||||||
|
<SelectionState runConfigName="MainActivity">
|
||||||
|
<option name="selectionMode" value="DROPDOWN" />
|
||||||
|
</SelectionState>
|
||||||
|
</selectionStates>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="DeviceTable">
|
||||||
|
<option name="columnSorters">
|
||||||
|
<list>
|
||||||
|
<ColumnSorterState>
|
||||||
|
<option name="column" value="Name" />
|
||||||
|
<option name="order" value="ASCENDING" />
|
||||||
|
</ColumnSorterState>
|
||||||
|
</list>
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="GradleMigrationSettings" migrationVersion="1" />
|
||||||
|
<component name="GradleSettings">
|
||||||
|
<option name="linkedExternalProjectsSettings">
|
||||||
|
<GradleProjectSettings>
|
||||||
|
<option name="testRunner" value="CHOOSE_PER_TEST" />
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
||||||
|
<option name="modules">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$" />
|
||||||
|
<option value="$PROJECT_DIR$/app" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</GradleProjectSettings>
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
<component name="InspectionProjectProfileManager">
|
||||||
|
<profile version="1.0">
|
||||||
|
<option name="myName" value="Project Default" />
|
||||||
|
<inspection_tool class="ComposePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
|
||||||
|
<option name="composableFile" value="true" />
|
||||||
|
<option name="previewFile" value="true" />
|
||||||
|
</inspection_tool>
|
||||||
|
<inspection_tool class="ComposePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
|
||||||
|
<option name="composableFile" value="true" />
|
||||||
|
<option name="previewFile" value="true" />
|
||||||
|
</inspection_tool>
|
||||||
|
<inspection_tool class="ComposePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
|
||||||
|
<option name="composableFile" value="true" />
|
||||||
|
<option name="previewFile" value="true" />
|
||||||
|
</inspection_tool>
|
||||||
|
<inspection_tool class="ComposePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
|
||||||
|
<option name="composableFile" value="true" />
|
||||||
|
<option name="previewFile" value="true" />
|
||||||
|
</inspection_tool>
|
||||||
|
<inspection_tool class="GlancePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
|
||||||
|
<option name="composableFile" value="true" />
|
||||||
|
</inspection_tool>
|
||||||
|
<inspection_tool class="GlancePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
|
||||||
|
<option name="composableFile" value="true" />
|
||||||
|
</inspection_tool>
|
||||||
|
<inspection_tool class="GlancePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
|
||||||
|
<option name="composableFile" value="true" />
|
||||||
|
</inspection_tool>
|
||||||
|
<inspection_tool class="GlancePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
|
||||||
|
<option name="composableFile" value="true" />
|
||||||
|
</inspection_tool>
|
||||||
|
<inspection_tool class="PreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true">
|
||||||
|
<option name="composableFile" value="true" />
|
||||||
|
<option name="previewFile" value="true" />
|
||||||
|
</inspection_tool>
|
||||||
|
<inspection_tool class="PreviewApiLevelMustBeValid" enabled="true" level="ERROR" enabled_by_default="true">
|
||||||
|
<option name="composableFile" value="true" />
|
||||||
|
<option name="previewFile" value="true" />
|
||||||
|
</inspection_tool>
|
||||||
|
<inspection_tool class="PreviewDeviceShouldUseNewSpec" enabled="true" level="WEAK WARNING" enabled_by_default="true">
|
||||||
|
<option name="composableFile" value="true" />
|
||||||
|
<option name="previewFile" value="true" />
|
||||||
|
</inspection_tool>
|
||||||
|
<inspection_tool class="PreviewFontScaleMustBeGreaterThanZero" enabled="true" level="ERROR" enabled_by_default="true">
|
||||||
|
<option name="composableFile" value="true" />
|
||||||
|
<option name="previewFile" value="true" />
|
||||||
|
</inspection_tool>
|
||||||
|
<inspection_tool class="PreviewMultipleParameterProviders" enabled="true" level="ERROR" enabled_by_default="true">
|
||||||
|
<option name="composableFile" value="true" />
|
||||||
|
<option name="previewFile" value="true" />
|
||||||
|
</inspection_tool>
|
||||||
|
<inspection_tool class="PreviewParameterProviderOnFirstParameter" enabled="true" level="ERROR" enabled_by_default="true">
|
||||||
|
<option name="composableFile" value="true" />
|
||||||
|
<option name="previewFile" value="true" />
|
||||||
|
</inspection_tool>
|
||||||
|
<inspection_tool class="PreviewPickerAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
|
||||||
|
<option name="composableFile" value="true" />
|
||||||
|
<option name="previewFile" value="true" />
|
||||||
|
</inspection_tool>
|
||||||
|
</profile>
|
||||||
|
</component>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<project version="4">
|
||||||
|
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||||
|
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
|
||||||
|
<output url="file://$PROJECT_DIR$/build/classes" />
|
||||||
|
</component>
|
||||||
|
<component name="ProjectType">
|
||||||
|
<option name="id" value="Android" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="RunConfigurationProducerService">
|
||||||
|
<option name="ignoredProducers">
|
||||||
|
<set>
|
||||||
|
<option value="com.intellij.execution.junit.AbstractAllInDirectoryConfigurationProducer" />
|
||||||
|
<option value="com.intellij.execution.junit.AllInPackageConfigurationProducer" />
|
||||||
|
<option value="com.intellij.execution.junit.PatternConfigurationProducer" />
|
||||||
|
<option value="com.intellij.execution.junit.TestInClassConfigurationProducer" />
|
||||||
|
<option value="com.intellij.execution.junit.UniqueIdConfigurationProducer" />
|
||||||
|
<option value="com.intellij.execution.junit.testDiscovery.JUnitTestDiscoveryConfigurationProducer" />
|
||||||
|
<option value="org.jetbrains.kotlin.idea.junit.KotlinJUnitRunConfigurationProducer" />
|
||||||
|
<option value="org.jetbrains.kotlin.idea.junit.KotlinPatternConfigurationProducer" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="StudioBotProjectSettings">
|
||||||
|
<option name="shareContext" value="OptedIn" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="$PROJECT_DIR$/.." vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/build
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
plugins {
|
||||||
|
alias(libs.plugins.android.application)
|
||||||
|
alias(libs.plugins.kotlin.compose)
|
||||||
|
alias(libs.plugins.kotlin.serialization)
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "dev.zxq5.chatapp.android"
|
||||||
|
compileSdk = 35
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId = "dev.zxq5.chatapp.android"
|
||||||
|
minSdk = 26
|
||||||
|
targetSdk = 35
|
||||||
|
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 {
|
||||||
|
// Ktor client
|
||||||
|
implementation(libs.ktor.client.android)
|
||||||
|
implementation(libs.ktor.client.content.negotiation)
|
||||||
|
implementation(libs.ktor.serialization.kotlinx.json)
|
||||||
|
implementation(libs.ktor.client.auth) // Auth plugin
|
||||||
|
// Kotlinx Serialization
|
||||||
|
implementation(libs.kotlinx.serialization.json)
|
||||||
|
|
||||||
|
// Coroutines
|
||||||
|
implementation(libs.kotlinx.coroutines.android)
|
||||||
|
|
||||||
|
// ViewModel
|
||||||
|
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||||
|
|
||||||
|
// Encrypted storage for session cookie/token
|
||||||
|
implementation(libs.androidx.security.crypto)
|
||||||
|
|
||||||
|
|
||||||
|
implementation(libs.androidx.core.ktx)
|
||||||
|
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||||
|
implementation(libs.androidx.activity.compose)
|
||||||
|
implementation(platform(libs.androidx.compose.bom))
|
||||||
|
implementation(libs.androidx.compose.ui)
|
||||||
|
implementation(libs.androidx.compose.ui.graphics)
|
||||||
|
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||||
|
implementation(libs.androidx.compose.material3)
|
||||||
|
implementation(libs.androidx.compose.foundation.layout)
|
||||||
|
implementation(libs.androidx.compose.material.icons.extended)
|
||||||
|
|
||||||
|
testImplementation(libs.junit)
|
||||||
|
androidTestImplementation(libs.androidx.junit)
|
||||||
|
androidTestImplementation(libs.androidx.espresso.core)
|
||||||
|
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||||
|
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
|
||||||
|
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||||
|
debugImplementation(libs.androidx.compose.ui.test.manifest)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package dev.zxq5.chatapp.android
|
||||||
|
|
||||||
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
import org.junit.Assert.*
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Instrumented test, which will execute on an Android device.
|
||||||
|
*
|
||||||
|
* See [testing documentation](http://d.android.com/tools/testing).
|
||||||
|
*/
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class ExampleInstrumentedTest {
|
||||||
|
@Test
|
||||||
|
fun useAppContext() {
|
||||||
|
// Context of the app under test.
|
||||||
|
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
|
||||||
|
assertEquals("dev.zxq5.chatapp.android", appContext.packageName)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:name=".ChatApplication"
|
||||||
|
android:allowBackup="true"
|
||||||
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
|
android:fullBackupContent="@xml/backup_rules"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:theme="@style/Theme.Chatapp"
|
||||||
|
android:usesCleartextTraffic="true">
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:theme="@style/Theme.Chatapp">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
</application>
|
||||||
|
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package dev.zxq5.chatapp.android
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import dev.zxq5.chatapp.android.api.ApiClient
|
||||||
|
|
||||||
|
class ChatApplication : Application() {
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
ApiClient.init(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package dev.zxq5.chatapp.android
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import dev.zxq5.chatapp.android.model.ChatViewModel
|
||||||
|
import dev.zxq5.chatapp.android.model.LoginState
|
||||||
|
import dev.zxq5.chatapp.android.model.MainScreen
|
||||||
|
import dev.zxq5.chatapp.android.ui.components.AuthScreen
|
||||||
|
import dev.zxq5.chatapp.android.ui.components.ChatScreen
|
||||||
|
import dev.zxq5.chatapp.android.ui.components.SettingsScreen
|
||||||
|
import dev.zxq5.chatapp.android.ui.theme.ChatappTheme
|
||||||
|
|
||||||
|
class MainActivity : ComponentActivity() {
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
enableEdgeToEdge()
|
||||||
|
setContent {
|
||||||
|
ChatappTheme {
|
||||||
|
val viewModel: ChatViewModel = viewModel()
|
||||||
|
val loginState by viewModel.loginState.collectAsState()
|
||||||
|
val currentScreen by viewModel.currentScreen.collectAsState()
|
||||||
|
|
||||||
|
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
|
||||||
|
androidx.compose.foundation.layout.Box(modifier = Modifier.padding(innerPadding)) {
|
||||||
|
if (loginState is LoginState.Success) {
|
||||||
|
when (currentScreen) {
|
||||||
|
MainScreen.CHAT -> ChatScreen(viewModel = viewModel)
|
||||||
|
MainScreen.SETTINGS -> SettingsScreen(viewModel = viewModel)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
AuthScreen(
|
||||||
|
viewModel = viewModel,
|
||||||
|
onSuccess = { }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,361 @@
|
|||||||
|
package dev.zxq5.chatapp.android.api
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
|
import dev.zxq5.chatapp.android.core.data.TokenStore.getScopeFromToken
|
||||||
|
import dev.zxq5.chatapp.android.model.LoginRequest
|
||||||
|
import dev.zxq5.chatapp.android.model.LoginResponse
|
||||||
|
import dev.zxq5.chatapp.android.model.Message
|
||||||
|
import dev.zxq5.chatapp.android.model.SendMessage
|
||||||
|
import dev.zxq5.chatapp.android.model.SignupRequest
|
||||||
|
import io.ktor.client.HttpClient
|
||||||
|
import io.ktor.client.call.body
|
||||||
|
import io.ktor.client.engine.android.Android
|
||||||
|
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
||||||
|
import io.ktor.client.request.get
|
||||||
|
import io.ktor.client.request.post
|
||||||
|
import io.ktor.client.request.delete
|
||||||
|
import io.ktor.client.request.prepareGet
|
||||||
|
import io.ktor.client.request.setBody
|
||||||
|
import io.ktor.client.statement.bodyAsChannel
|
||||||
|
import io.ktor.http.ContentType
|
||||||
|
import io.ktor.http.contentType
|
||||||
|
import io.ktor.http.isSuccess
|
||||||
|
import io.ktor.serialization.kotlinx.json.json
|
||||||
|
import io.ktor.utils.io.readUTF8Line
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.flow
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
|
||||||
|
import io.ktor.client.plugins.auth.Auth
|
||||||
|
import io.ktor.client.plugins.auth.providers.BearerTokens
|
||||||
|
import io.ktor.client.plugins.auth.providers.bearer
|
||||||
|
import io.ktor.http.encodedPath
|
||||||
|
import dev.zxq5.chatapp.android.core.BASE_URL
|
||||||
|
import dev.zxq5.chatapp.android.core.data.TokenStore
|
||||||
|
import dev.zxq5.chatapp.android.core.data.TokenStore.getUserIdFromToken
|
||||||
|
import dev.zxq5.chatapp.android.core.error.ApiResult
|
||||||
|
import kotlinx.serialization.KSerializer
|
||||||
|
import kotlinx.serialization.descriptors.PrimitiveKind
|
||||||
|
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
|
||||||
|
import kotlinx.serialization.descriptors.SerialDescriptor
|
||||||
|
import kotlinx.serialization.encoding.Decoder
|
||||||
|
import kotlinx.serialization.encoding.Encoder
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class QrResponse(val qr_code: String)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class TOTPSixDigitCode(val code: String)
|
||||||
|
|
||||||
|
@Serializable(with = TotpStatusSerializer::class)
|
||||||
|
enum class TotpStatus {
|
||||||
|
ENABLED, DISABLED;
|
||||||
|
|
||||||
|
val isEnabled: Boolean get() = this == ENABLED
|
||||||
|
}
|
||||||
|
|
||||||
|
object TotpStatusSerializer : KSerializer<TotpStatus> {
|
||||||
|
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("TotpStatus", PrimitiveKind.STRING)
|
||||||
|
override fun serialize(encoder: Encoder, value: TotpStatus) = encoder.encodeString(value.name.lowercase())
|
||||||
|
override fun deserialize(decoder: Decoder): TotpStatus =
|
||||||
|
TotpStatus.valueOf(decoder.decodeString().uppercase())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class TotpStatusResponse(val status: TotpStatus)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class PasswordChangeRequest(val old_password: String, val new_password: String)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class DisplayNameRequest(val display_name: String?)
|
||||||
|
|
||||||
|
object ApiClient {
|
||||||
|
private lateinit var appContext: Context
|
||||||
|
|
||||||
|
fun init(context: Context) {
|
||||||
|
appContext = context.applicationContext
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hasToken(): Boolean {
|
||||||
|
return TokenStore.get(appContext) != null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getTokenScope(): String? {
|
||||||
|
val token = TokenStore.get(appContext) ?: return null
|
||||||
|
val scope = getScopeFromToken(token)
|
||||||
|
Log.d("Chat", "Current token scope: $scope")
|
||||||
|
return scope
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getStoredUserId(): Int? {
|
||||||
|
return TokenStore.getUserId(appContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun is2faEnabledLocal(): Boolean {
|
||||||
|
return TokenStore.is2faEnabled(appContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun set2faEnabledLocal(enabled: Boolean) {
|
||||||
|
TokenStore.save2faEnabled(appContext, enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var _http: HttpClient? = null
|
||||||
|
val http: HttpClient
|
||||||
|
get() = synchronized(this) {
|
||||||
|
_http ?: createClient().also { _http = it }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createClient(): HttpClient {
|
||||||
|
return HttpClient(Android) {
|
||||||
|
install(ContentNegotiation) {
|
||||||
|
json(Json { ignoreUnknownKeys = true })
|
||||||
|
}
|
||||||
|
install(Auth) {
|
||||||
|
bearer {
|
||||||
|
loadTokens {
|
||||||
|
val token = TokenStore.get(appContext) ?: return@loadTokens null
|
||||||
|
Log.d("Chat", "Auth plugin loading token: ${getScopeFromToken(token)}")
|
||||||
|
BearerTokens(token, "")
|
||||||
|
}
|
||||||
|
sendWithoutRequest { request ->
|
||||||
|
val path = request.url.encodedPath
|
||||||
|
!path.endsWith("/login") && !path.endsWith("/signup")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resetClient() {
|
||||||
|
Log.d("Chat", "Resetting HttpClient to refresh tokens")
|
||||||
|
_http?.close()
|
||||||
|
_http = null
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun login(username: String, password: String): ApiResult<LoginResponse> {
|
||||||
|
return try {
|
||||||
|
val response = http.post("${BASE_URL}/api/login") {
|
||||||
|
contentType(ContentType.Application.Json)
|
||||||
|
setBody(LoginRequest(username, password))
|
||||||
|
}
|
||||||
|
if (response.status.isSuccess()) {
|
||||||
|
val body = response.body<LoginResponse>()
|
||||||
|
Log.d("Chat", "Login token scope: ${getScopeFromToken(body.token)}")
|
||||||
|
TokenStore.save(appContext, body.token)
|
||||||
|
resetClient()
|
||||||
|
ApiResult.Success(body)
|
||||||
|
} else {
|
||||||
|
ApiResult.HttpError(
|
||||||
|
status = response.status.value,
|
||||||
|
message = when (response.status.value) {
|
||||||
|
401 -> "Invalid username or password"
|
||||||
|
403 -> "Account suspended"
|
||||||
|
429 -> "Too many attempts, please wait"
|
||||||
|
else -> "Login failed (${response.status.value})"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("Chat", "Login network error", e)
|
||||||
|
ApiResult.NetworkError(e.localizedMessage ?: "Network error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun signup(username: String, email: String, password: String, token: String): ApiResult<LoginResponse> {
|
||||||
|
return try {
|
||||||
|
val response = http.post("${BASE_URL}/api/signup") {
|
||||||
|
contentType(ContentType.Application.Json)
|
||||||
|
setBody(SignupRequest(username = username, email = email, password = password, access_token = token))
|
||||||
|
}
|
||||||
|
if (response.status.isSuccess()) {
|
||||||
|
val body = response.body<LoginResponse>()
|
||||||
|
TokenStore.save(appContext, body.token)
|
||||||
|
resetClient()
|
||||||
|
ApiResult.Success(body)
|
||||||
|
} else {
|
||||||
|
ApiResult.HttpError(
|
||||||
|
status = response.status.value,
|
||||||
|
message = when (response.status.value) {
|
||||||
|
401 -> "Invalid access token"
|
||||||
|
else -> "Signup failed (${response.status.value})"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("Chat", "Signup error", e)
|
||||||
|
ApiResult.NetworkError(e.localizedMessage ?: "Network error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun logout() {
|
||||||
|
TokenStore.clear(appContext)
|
||||||
|
resetClient()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun sendMessage(channelId: Int, text: String) {
|
||||||
|
val userId = TokenStore.getUserId(appContext) ?: return
|
||||||
|
http.post("${BASE_URL}/api/chat/$channelId") {
|
||||||
|
contentType(ContentType.Application.Json)
|
||||||
|
setBody(SendMessage(
|
||||||
|
user_id = userId,
|
||||||
|
text = text,
|
||||||
|
timestamp = System.currentTimeMillis()
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun messageStream(channelId: Int): Flow<Message> = flow {
|
||||||
|
http.prepareGet("${BASE_URL}/api/events/$channelId").execute { response ->
|
||||||
|
val channel = response.bodyAsChannel()
|
||||||
|
while (!channel.isClosedForRead) {
|
||||||
|
val line = channel.readUTF8Line(256) ?: break
|
||||||
|
if (line.startsWith("data:")) {
|
||||||
|
val json = line.removePrefix("data:").trim()
|
||||||
|
runCatching { Json.decodeFromString<Message>(json) }
|
||||||
|
.onSuccess { emit(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getTotpQr(): QrResponse? {
|
||||||
|
return try {
|
||||||
|
http.get("${BASE_URL}/api/totp.jpg").body<QrResponse>()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("Chat", "Error fetching TOTP QR", e)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun confirmTotp(code: String): Boolean {
|
||||||
|
return try {
|
||||||
|
val response = http.post("${BASE_URL}/api/totp") {
|
||||||
|
contentType(ContentType.Application.Json)
|
||||||
|
setBody(TOTPSixDigitCode(code))
|
||||||
|
}
|
||||||
|
if (response.status.isSuccess()) {
|
||||||
|
// If confirming TOTP returns a new token (e.g. from partial to full), we should save it
|
||||||
|
// Assuming confirm might return a LoginResponse if it upgrades the session
|
||||||
|
runCatching {
|
||||||
|
val body = response.body<LoginResponse>()
|
||||||
|
TokenStore.save(appContext, body.token)
|
||||||
|
resetClient()
|
||||||
|
}
|
||||||
|
set2faEnabledLocal(true)
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("Chat", "Error confirming TOTP", e)
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun verifyTotpLogin(code: String): ApiResult<LoginResponse> {
|
||||||
|
return try {
|
||||||
|
val response = http.post("${BASE_URL}/api/totp/verify") {
|
||||||
|
contentType(ContentType.Application.Json)
|
||||||
|
setBody(TOTPSixDigitCode(code))
|
||||||
|
}
|
||||||
|
if (response.status.isSuccess()) {
|
||||||
|
val body = response.body<LoginResponse>()
|
||||||
|
|
||||||
|
val tok = body.token;
|
||||||
|
Log.d("Chat", "UID ${getUserIdFromToken(tok)}");
|
||||||
|
Log.d("Chat", "Token ${getScopeFromToken(tok)}");
|
||||||
|
|
||||||
|
TokenStore.save(appContext, body.token)
|
||||||
|
resetClient()
|
||||||
|
ApiResult.Success(body)
|
||||||
|
} else {
|
||||||
|
val errorText = try { response.body<String>() } catch (e: Exception) { "Unknown error" }
|
||||||
|
Log.e("Chat", "TOTP verify failed: ${response.status.value} - $errorText")
|
||||||
|
ApiResult.HttpError(
|
||||||
|
status = response.status.value,
|
||||||
|
message = when (response.status.value) {
|
||||||
|
401 -> "Incorrect code, please try again"
|
||||||
|
403 -> "Session expired, please log in again"
|
||||||
|
429 -> "Too many attempts, please wait"
|
||||||
|
else -> "Verification failed (${response.status.value})"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("Chat", "TOTP verify network error", e)
|
||||||
|
ApiResult.NetworkError(e.localizedMessage ?: "Network error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getTotpStatus(): Boolean {
|
||||||
|
return try {
|
||||||
|
val response = http.get("${BASE_URL}/api/totp/status")
|
||||||
|
if (response.status.isSuccess()) {
|
||||||
|
val status = response.body<TotpStatus>()
|
||||||
|
val enabled = status.isEnabled
|
||||||
|
set2faEnabledLocal(enabled)
|
||||||
|
enabled
|
||||||
|
} else {
|
||||||
|
is2faEnabledLocal()
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("Chat", "Error getting TOTP status", e)
|
||||||
|
is2faEnabledLocal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun disableTotp(): ApiResult<LoginResponse> {
|
||||||
|
return try {
|
||||||
|
val response = http.delete("${BASE_URL}/api/totp")
|
||||||
|
if (response.status.isSuccess()) {
|
||||||
|
val body = response.body<LoginResponse>()
|
||||||
|
TokenStore.save(appContext, body.token)
|
||||||
|
set2faEnabledLocal(false)
|
||||||
|
resetClient()
|
||||||
|
ApiResult.Success(body)
|
||||||
|
} else {
|
||||||
|
ApiResult.HttpError(response.status.value, "Failed to disable TOTP")
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("Chat", "Error disabling TOTP", e)
|
||||||
|
ApiResult.NetworkError(e.localizedMessage ?: "Network error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun changePassword(old: String, new: String): ApiResult<Unit> {
|
||||||
|
return try {
|
||||||
|
val response = http.post("${BASE_URL}/api/settings/password") {
|
||||||
|
contentType(ContentType.Application.Json)
|
||||||
|
setBody(PasswordChangeRequest(old, new))
|
||||||
|
}
|
||||||
|
if (response.status.isSuccess()) {
|
||||||
|
ApiResult.Success(Unit)
|
||||||
|
} else {
|
||||||
|
ApiResult.HttpError(
|
||||||
|
response.status.value,
|
||||||
|
if (response.status.value == 401) "Old password is wrong" else "Password change failed"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("Chat", "Error changing password", e)
|
||||||
|
ApiResult.NetworkError(e.localizedMessage ?: "Network error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun updateDisplayName(name: String?): Boolean {
|
||||||
|
return try {
|
||||||
|
val response = http.post("${BASE_URL}/api/settings/display_name") {
|
||||||
|
contentType(ContentType.Application.Json)
|
||||||
|
setBody(DisplayNameRequest(name))
|
||||||
|
}
|
||||||
|
response.status.isSuccess()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("Chat", "Error updating display name", e)
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
package dev.zxq5.chatapp.android.core
|
||||||
|
|
||||||
|
const val BASE_URL = "http://zxq5-x1:8000"
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package dev.zxq5.chatapp.android.core.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import android.util.Base64
|
||||||
|
import androidx.core.content.edit
|
||||||
|
import androidx.security.crypto.EncryptedSharedPreferences
|
||||||
|
import androidx.security.crypto.MasterKey
|
||||||
|
import org.json.JSONObject
|
||||||
|
|
||||||
|
// In your ApiClient or a dedicated TokenStore
|
||||||
|
object TokenStore {
|
||||||
|
private const val KEY = "auth_token"
|
||||||
|
private const val TWOFA_KEY = "twofa_enabled"
|
||||||
|
|
||||||
|
private fun prefs(context: Context): SharedPreferences {
|
||||||
|
return EncryptedSharedPreferences.create(
|
||||||
|
context,
|
||||||
|
"secure_prefs",
|
||||||
|
MasterKey.Builder(context)
|
||||||
|
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||||
|
.build(),
|
||||||
|
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||||
|
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun save(context: Context, token: String) =
|
||||||
|
prefs(context).edit { putString(KEY, token) }
|
||||||
|
|
||||||
|
fun get(context: Context): String? =
|
||||||
|
prefs(context).getString(KEY, null)
|
||||||
|
|
||||||
|
fun save2faEnabled(context: Context, enabled: Boolean) =
|
||||||
|
prefs(context).edit { putBoolean(TWOFA_KEY, enabled) }
|
||||||
|
|
||||||
|
fun is2faEnabled(context: Context): Boolean =
|
||||||
|
prefs(context).getBoolean(TWOFA_KEY, false)
|
||||||
|
|
||||||
|
fun clear(context: Context) =
|
||||||
|
prefs(context).edit { remove(KEY).remove(TWOFA_KEY) }
|
||||||
|
|
||||||
|
fun getUserId(context: Context): Int? {
|
||||||
|
val token = get(context) ?: return null
|
||||||
|
return getUserIdFromToken(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getUserIdFromToken(token: String): Int? {
|
||||||
|
return try {
|
||||||
|
val payload = token.split(".")[1]
|
||||||
|
// base64url needs padding restored
|
||||||
|
val padded = payload + "==".take((4 - payload.length % 4) % 4)
|
||||||
|
val jsonString = String(Base64.decode(padded, Base64.URL_SAFE))
|
||||||
|
val json = JSONObject(jsonString)
|
||||||
|
|
||||||
|
// Handle both standard 'sub' and custom 'user_id'
|
||||||
|
when {
|
||||||
|
json.has("sub") -> json.getInt("sub")
|
||||||
|
json.has("user_id") -> json.getInt("user_id")
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getScopeFromToken(token: String): String? {
|
||||||
|
return try {
|
||||||
|
val payload = token.split(".")[1]
|
||||||
|
val padded = payload + "==".take((4 - payload.length % 4) % 4)
|
||||||
|
val jsonString = String(Base64.decode(padded, Base64.URL_SAFE))
|
||||||
|
val json = JSONObject(jsonString)
|
||||||
|
if (json.has("scope")) json.getString("scope") else null
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package dev.zxq5.chatapp.android.core.error
|
||||||
|
|
||||||
|
sealed class ApiResult<out T> {
|
||||||
|
data class Success<T>(val data: T) : ApiResult<T>()
|
||||||
|
data class HttpError(val status: Int, val message: String) : ApiResult<Nothing>()
|
||||||
|
data class NetworkError(val message: String) : ApiResult<Nothing>()
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package dev.zxq5.chatapp.android.data.repository
|
||||||
|
|
||||||
|
import dev.zxq5.chatapp.android.api.ApiClient
|
||||||
|
import dev.zxq5.chatapp.android.core.data.TokenStore
|
||||||
|
import dev.zxq5.chatapp.android.core.error.ApiResult
|
||||||
|
//
|
||||||
|
//class AuthRepository(
|
||||||
|
// private val apiClient: ApiClient,
|
||||||
|
// private val tokenStore: TokenStore,
|
||||||
|
//) {
|
||||||
|
//
|
||||||
|
// suspend fun login(username: String, password: String): LoginResult {
|
||||||
|
//// return when(val result = apiClient.login(username, password)) {
|
||||||
|
//// is ApiResult.Success -> {
|
||||||
|
//// tokenStore.save(context = context, result.data.token);
|
||||||
|
//// }
|
||||||
|
//// }
|
||||||
|
// }
|
||||||
|
//}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
sealed class LoginResult {
|
||||||
|
object Success : LoginResult()
|
||||||
|
object TotpRequired : LoginResult() // step 1 outcome → go to totp screen
|
||||||
|
data class TotpError(val message: String) : LoginResult() // step 2 failure → stay on totp screen, show error
|
||||||
|
data class Error(val message: String) : LoginResult() // general failure → show on login form
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class AuthState {
|
||||||
|
object Authenticated : AuthState()
|
||||||
|
object AwaitingTotp : AuthState()
|
||||||
|
object Unauthenticated : AuthState()
|
||||||
|
}
|
||||||
@@ -0,0 +1,325 @@
|
|||||||
|
package dev.zxq5.chatapp.android.model
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import dev.zxq5.chatapp.android.api.ApiClient
|
||||||
|
import dev.zxq5.chatapp.android.core.error.ApiResult
|
||||||
|
import dev.zxq5.chatapp.android.api.QrResponse
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.catch
|
||||||
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
enum class AuthMode {
|
||||||
|
LOGIN, SIGNUP
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class MainScreen {
|
||||||
|
CHAT, SETTINGS
|
||||||
|
}
|
||||||
|
|
||||||
|
class ChatViewModel : ViewModel() {
|
||||||
|
|
||||||
|
private val _messages = MutableStateFlow<List<Message>>(emptyList())
|
||||||
|
val messages: StateFlow<List<Message>> = _messages
|
||||||
|
|
||||||
|
private val _channelId = MutableStateFlow<Int?>(null)
|
||||||
|
val channelId: StateFlow<Int?> = _channelId
|
||||||
|
|
||||||
|
private val _currentUserId = MutableStateFlow<Int?>(null)
|
||||||
|
val currentUserId: StateFlow<Int?> = _currentUserId
|
||||||
|
|
||||||
|
private val _currentScreen = MutableStateFlow(MainScreen.CHAT)
|
||||||
|
val currentScreen: StateFlow<MainScreen> = _currentScreen
|
||||||
|
|
||||||
|
val loginState = MutableStateFlow<LoginState>(LoginState.Idle)
|
||||||
|
|
||||||
|
// Tracks whether the user is viewing the Login or Signup screen
|
||||||
|
val authMode = MutableStateFlow(AuthMode.LOGIN)
|
||||||
|
|
||||||
|
// 2FA state
|
||||||
|
private val _totpQr = MutableStateFlow<QrResponse?>(null)
|
||||||
|
val totpQr: StateFlow<QrResponse?> = _totpQr
|
||||||
|
|
||||||
|
private val _is2faEnabled = MutableStateFlow(false)
|
||||||
|
val is2faEnabled: StateFlow<Boolean> = _is2faEnabled
|
||||||
|
|
||||||
|
private val _totpError = MutableStateFlow<String?>(null)
|
||||||
|
val totpError: StateFlow<String?> = _totpError
|
||||||
|
|
||||||
|
// Settings state
|
||||||
|
private val _settingsError = MutableStateFlow<String?>(null)
|
||||||
|
val settingsError: StateFlow<String?> = _settingsError
|
||||||
|
|
||||||
|
private val _settingsSuccess = MutableStateFlow<String?>(null)
|
||||||
|
val settingsSuccess: StateFlow<String?> = _settingsSuccess
|
||||||
|
|
||||||
|
fun clearSettingsMessages() {
|
||||||
|
_settingsError.value = null
|
||||||
|
_settingsSuccess.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearTotpError() {
|
||||||
|
_totpError.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private var streamJob: Job? = null
|
||||||
|
|
||||||
|
init {
|
||||||
|
initAuth(ApiClient.hasToken())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun initAuth(hasToken: Boolean) {
|
||||||
|
if (hasToken) {
|
||||||
|
val scope = ApiClient.getTokenScope()
|
||||||
|
if (scope == TokenScope.TOTP_PENDING) {
|
||||||
|
loginState.value = LoginState.TwoFactorRequired
|
||||||
|
} else if (scope == TokenScope.FULL) {
|
||||||
|
loginState.value = LoginState.Success
|
||||||
|
_currentUserId.value = ApiClient.getStoredUserId()
|
||||||
|
_is2faEnabled.value = ApiClient.is2faEnabledLocal()
|
||||||
|
fetchTotpStatus()
|
||||||
|
observeChannel()
|
||||||
|
} else {
|
||||||
|
loginState.value = LoginState.Error("Unknown token scope: $scope")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
loginState.value = LoginState.Idle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun observeChannel() {
|
||||||
|
// restart stream whenever channel changes
|
||||||
|
viewModelScope.launch {
|
||||||
|
_channelId.filterNotNull().collect { id ->
|
||||||
|
streamJob?.cancel()
|
||||||
|
_messages.value = emptyList()
|
||||||
|
streamJob = launch {
|
||||||
|
ApiClient.messageStream(id)
|
||||||
|
.catch { e ->
|
||||||
|
Log.e("Chat", "Stream error", e)
|
||||||
|
}
|
||||||
|
.collect { message ->
|
||||||
|
_messages.update { it + message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun navigateTo(screen: MainScreen) {
|
||||||
|
_currentScreen.value = screen
|
||||||
|
if (screen == MainScreen.SETTINGS) {
|
||||||
|
fetchTotpStatus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun switchChannel(id: Int?) {
|
||||||
|
_channelId.value = id
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setAuthMode(mode: AuthMode) {
|
||||||
|
authMode.value = mode
|
||||||
|
// Clear errors when switching modes
|
||||||
|
if (loginState.value is LoginState.Error || loginState.value is LoginState.TwoFactorRequired) {
|
||||||
|
loginState.value = LoginState.Idle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendMessage(text: String) {
|
||||||
|
val currentId = _channelId.value ?: return
|
||||||
|
viewModelScope.launch {
|
||||||
|
runCatching {
|
||||||
|
ApiClient.sendMessage(
|
||||||
|
channelId = currentId,
|
||||||
|
text = text
|
||||||
|
)
|
||||||
|
}.onFailure { e ->
|
||||||
|
Log.e("Chat", "Send message error", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun login(username: String, password: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
loginState.value = LoginState.Loading
|
||||||
|
when (val result = ApiClient.login(username, password)) {
|
||||||
|
is ApiResult.Success -> {
|
||||||
|
when (val scope = ApiClient.getTokenScope()) {
|
||||||
|
TokenScope.FULL -> {
|
||||||
|
_currentUserId.value = ApiClient.getStoredUserId()
|
||||||
|
_is2faEnabled.value = ApiClient.is2faEnabledLocal()
|
||||||
|
loginState.value = LoginState.Success
|
||||||
|
fetchTotpStatus()
|
||||||
|
observeChannel()
|
||||||
|
}
|
||||||
|
TokenScope.TOTP_PENDING -> {
|
||||||
|
loginState.value = LoginState.TwoFactorRequired
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
loginState.value = LoginState.Error("Unknown token scope: $scope")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is ApiResult.HttpError -> {
|
||||||
|
loginState.value = LoginState.Error(result.message)
|
||||||
|
}
|
||||||
|
is ApiResult.NetworkError -> {
|
||||||
|
loginState.value = LoginState.Error("Could not reach server: ${result.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun verifyLogin2fa(code: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
loginState.value = LoginState.Loading
|
||||||
|
when (val result = ApiClient.verifyTotpLogin(code)) {
|
||||||
|
is ApiResult.Success -> {
|
||||||
|
val scope = ApiClient.getTokenScope()
|
||||||
|
if (scope == TokenScope.FULL) {
|
||||||
|
_currentUserId.value = ApiClient.getStoredUserId()
|
||||||
|
_is2faEnabled.value = ApiClient.is2faEnabledLocal()
|
||||||
|
loginState.value = LoginState.Success
|
||||||
|
fetchTotpStatus()
|
||||||
|
observeChannel()
|
||||||
|
} else {
|
||||||
|
// token came back but scope is wrong — shouldn't happen
|
||||||
|
loginState.value = LoginState.Error("Unexpected token scope after verification")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is ApiResult.HttpError -> {
|
||||||
|
// stay on TOTP screen but show the error
|
||||||
|
loginState.value = LoginState.TwoFactorRequired
|
||||||
|
// use a separate error signal so we don't lose the TOTP state
|
||||||
|
_totpError.value = result.message
|
||||||
|
}
|
||||||
|
is ApiResult.NetworkError -> {
|
||||||
|
loginState.value = LoginState.TwoFactorRequired
|
||||||
|
_totpError.value = "Could not reach server: ${result.message}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun signup(username: String, email: String, password: String, accessToken: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
loginState.value = LoginState.Loading
|
||||||
|
try {
|
||||||
|
|
||||||
|
when (val result = ApiClient.signup(username, email, password, accessToken)) {
|
||||||
|
is ApiResult.Success -> {
|
||||||
|
_currentUserId.value = ApiClient.getStoredUserId()
|
||||||
|
_is2faEnabled.value = ApiClient.is2faEnabledLocal()
|
||||||
|
loginState.value = LoginState.Success
|
||||||
|
observeChannel()
|
||||||
|
}
|
||||||
|
is ApiResult.HttpError -> {
|
||||||
|
loginState.value = LoginState.Error(result.message)
|
||||||
|
}
|
||||||
|
is ApiResult.NetworkError -> {
|
||||||
|
loginState.value = LoginState.Error("Could not reach server: ${result.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("Chat", "Signup error", e)
|
||||||
|
loginState.value = LoginState.Error("Signup failed: ${e.localizedMessage}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun logout() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
ApiClient.logout()
|
||||||
|
_currentUserId.value = null
|
||||||
|
_is2faEnabled.value = false
|
||||||
|
loginState.value = LoginState.Idle
|
||||||
|
_messages.value = emptyList()
|
||||||
|
_channelId.value = null
|
||||||
|
_currentScreen.value = MainScreen.CHAT
|
||||||
|
streamJob?.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun fetchTotpQr() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_totpQr.value = ApiClient.getTotpQr()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun confirmTotp(code: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val success = ApiClient.confirmTotp(code)
|
||||||
|
if (success) {
|
||||||
|
_is2faEnabled.value = true
|
||||||
|
ApiClient.set2faEnabledLocal(true)
|
||||||
|
_totpQr.value = null
|
||||||
|
} else {
|
||||||
|
_totpError.value = "Invalid verification code"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun fetchTotpStatus() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_is2faEnabled.value = ApiClient.getTotpStatus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun disableTotp() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
when (val result = ApiClient.disableTotp()) {
|
||||||
|
is ApiResult.Success -> {
|
||||||
|
_is2faEnabled.value = false
|
||||||
|
_settingsSuccess.value = "2FA disabled successfully"
|
||||||
|
}
|
||||||
|
is ApiResult.HttpError -> {
|
||||||
|
_settingsError.value = result.message
|
||||||
|
}
|
||||||
|
is ApiResult.NetworkError -> {
|
||||||
|
_settingsError.value = "Network error: ${result.message}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun changePassword(old: String, new: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_settingsError.value = null
|
||||||
|
_settingsSuccess.value = null
|
||||||
|
when (val result = ApiClient.changePassword(old, new)) {
|
||||||
|
is ApiResult.Success -> {
|
||||||
|
_settingsSuccess.value = "Password updated"
|
||||||
|
}
|
||||||
|
is ApiResult.HttpError -> {
|
||||||
|
_settingsError.value = result.message
|
||||||
|
}
|
||||||
|
is ApiResult.NetworkError -> {
|
||||||
|
_settingsError.value = "Network error: ${result.message}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateDisplayName(name: String?) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_settingsError.value = null
|
||||||
|
_settingsSuccess.value = null
|
||||||
|
val success = ApiClient.updateDisplayName(name)
|
||||||
|
if (success) {
|
||||||
|
_settingsSuccess.value = "Display name updated"
|
||||||
|
} else {
|
||||||
|
_settingsError.value = "Failed to update display name"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
object TokenScope {
|
||||||
|
const val FULL = "full";
|
||||||
|
const val TOTP_PENDING = "totp_pending";
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package dev.zxq5.chatapp.android.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class LoginRequest(
|
||||||
|
val username: String,
|
||||||
|
val password: String
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package dev.zxq5.chatapp.android.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class LoginResponse(val token: String)
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package dev.zxq5.chatapp.android.model
|
||||||
|
|
||||||
|
sealed class LoginState {
|
||||||
|
object Idle : LoginState()
|
||||||
|
object Loading : LoginState()
|
||||||
|
object Success : LoginState()
|
||||||
|
object TwoFactorRequired : LoginState()
|
||||||
|
data class Error(val message: String) : LoginState()
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package dev.zxq5.chatapp.android.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Message(
|
||||||
|
val user_id: Int,
|
||||||
|
val display_name: String,
|
||||||
|
val text: String,
|
||||||
|
val timestamp: Long
|
||||||
|
)
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package dev.zxq5.chatapp.android.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class SendMessage(
|
||||||
|
val user_id: Int,
|
||||||
|
val text: String,
|
||||||
|
val timestamp: Long
|
||||||
|
)
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package dev.zxq5.chatapp.android.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class SignupRequest(
|
||||||
|
val username: String,
|
||||||
|
val password: String,
|
||||||
|
val email: String,
|
||||||
|
|
||||||
|
@SerialName("access_token")
|
||||||
|
val access_token: String
|
||||||
|
)
|
||||||
@@ -0,0 +1,435 @@
|
|||||||
|
package dev.zxq5.chatapp.android.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.IntrinsicSize
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.BasicTextField
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ExitToApp
|
||||||
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.material.icons.filled.Send
|
||||||
|
import androidx.compose.material.icons.filled.Settings
|
||||||
|
import androidx.compose.material.icons.outlined.ChatBubbleOutline
|
||||||
|
import androidx.compose.material.icons.outlined.Settings
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.NavigationBar
|
||||||
|
import androidx.compose.material3.NavigationBarItem
|
||||||
|
import androidx.compose.material3.NavigationBarItemDefaults
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import dev.zxq5.chatapp.android.model.ChatViewModel
|
||||||
|
import dev.zxq5.chatapp.android.model.MainScreen
|
||||||
|
import dev.zxq5.chatapp.android.model.Message
|
||||||
|
import java.text.DateFormat
|
||||||
|
import java.util.Date
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ChatScreen(viewModel: ChatViewModel) {
|
||||||
|
val selectedChannelId by viewModel.channelId.collectAsState()
|
||||||
|
|
||||||
|
if (selectedChannelId == null) {
|
||||||
|
ChannelListScreen(
|
||||||
|
viewModel = viewModel,
|
||||||
|
onChannelSelect = { viewModel.switchChannel(it) }
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
MessageScreen(
|
||||||
|
channelId = selectedChannelId!!,
|
||||||
|
viewModel = viewModel,
|
||||||
|
onBack = { viewModel.switchChannel(null) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun ChannelListScreen(viewModel: ChatViewModel, onChannelSelect: (Int) -> Unit) {
|
||||||
|
Scaffold(
|
||||||
|
containerColor = MaterialTheme.colorScheme.background,
|
||||||
|
topBar = {
|
||||||
|
Column {
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
"contacts",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
|
||||||
|
modifier = Modifier.padding(horizontal = 20.dp)
|
||||||
|
)
|
||||||
|
TopAppBar(
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
"messages",
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
},
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = Color.Transparent,
|
||||||
|
titleContentColor = MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"5 channels · end-to-end encrypted",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f),
|
||||||
|
modifier = Modifier.padding(horizontal = 20.dp, vertical = 2.dp)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(MaterialTheme.colorScheme.secondary.copy(alpha = 0.2f))
|
||||||
|
.padding(horizontal = 20.dp, vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(6.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(MaterialTheme.colorScheme.primary)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(10.dp))
|
||||||
|
Text(
|
||||||
|
"global · walkie talkie",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
Surface(
|
||||||
|
color = Color.Transparent,
|
||||||
|
border = border(0.5.dp, MaterialTheme.colorScheme.outlineVariant),
|
||||||
|
shape = RoundedCornerShape(4.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"hold to talk",
|
||||||
|
modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
bottomBar = { BottomDock(viewModel) }
|
||||||
|
) { padding ->
|
||||||
|
LazyColumn(modifier = Modifier.padding(padding).fillMaxSize()) {
|
||||||
|
items(10) { i ->
|
||||||
|
val id = i + 1
|
||||||
|
ChannelItem(id = id, onClick = { onChannelSelect(id) })
|
||||||
|
HorizontalDivider(
|
||||||
|
modifier = Modifier.padding(horizontal = 20.dp),
|
||||||
|
thickness = 0.5.dp,
|
||||||
|
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.2f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun BottomDock(viewModel: ChatViewModel) {
|
||||||
|
val currentScreen by viewModel.currentScreen.collectAsState()
|
||||||
|
|
||||||
|
NavigationBar(
|
||||||
|
containerColor = MaterialTheme.colorScheme.background,
|
||||||
|
tonalElevation = 0.dp,
|
||||||
|
modifier = Modifier.border(0.5.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.2f), RoundedCornerShape(topStart = 0.dp, topEnd = 0.dp))
|
||||||
|
) {
|
||||||
|
NavigationBarItem(
|
||||||
|
selected = currentScreen == MainScreen.CHAT,
|
||||||
|
onClick = { viewModel.navigateTo(MainScreen.CHAT) },
|
||||||
|
icon = { Icon(Icons.Outlined.ChatBubbleOutline, contentDescription = "Chat") },
|
||||||
|
label = { Text("chat", style = MaterialTheme.typography.labelSmall) },
|
||||||
|
colors = NavigationBarItemDefaults.colors(
|
||||||
|
selectedIconColor = MaterialTheme.colorScheme.primary,
|
||||||
|
unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
|
||||||
|
indicatorColor = Color.Transparent
|
||||||
|
)
|
||||||
|
)
|
||||||
|
NavigationBarItem(
|
||||||
|
selected = currentScreen == MainScreen.SETTINGS,
|
||||||
|
onClick = { viewModel.navigateTo(MainScreen.SETTINGS) },
|
||||||
|
icon = { Icon(Icons.Outlined.Settings, contentDescription = "Settings") },
|
||||||
|
label = { Text("settings", style = MaterialTheme.typography.labelSmall) },
|
||||||
|
colors = NavigationBarItemDefaults.colors(
|
||||||
|
selectedIconColor = MaterialTheme.colorScheme.primary,
|
||||||
|
unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
|
||||||
|
indicatorColor = Color.Transparent
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ChannelItem(id: Int, onClick: () -> Unit) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
.padding(horizontal = 20.dp, vertical = 12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(36.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.border(0.5.dp, MaterialTheme.colorScheme.outlineVariant, CircleShape),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"C$id",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = "channel $id",
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "tap to join",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
"14:22",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun MessageScreen(channelId: Int, viewModel: ChatViewModel, onBack: () -> Unit) {
|
||||||
|
val messages by viewModel.messages.collectAsState()
|
||||||
|
val currentUserId by viewModel.currentUserId.collectAsState()
|
||||||
|
var input by remember { mutableStateOf("") }
|
||||||
|
val listState = rememberLazyListState()
|
||||||
|
|
||||||
|
LaunchedEffect(messages.size) {
|
||||||
|
if (messages.isNotEmpty()) {
|
||||||
|
listState.animateScrollToItem(messages.size - 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
containerColor = MaterialTheme.colorScheme.background,
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = {
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
"channel $channelId",
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"online",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(
|
||||||
|
Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
|
contentDescription = "Back",
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = Color.Transparent
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { padding ->
|
||||||
|
Column(modifier = Modifier.padding(padding).fillMaxSize()) {
|
||||||
|
LazyColumn(
|
||||||
|
state = listState,
|
||||||
|
modifier = Modifier.weight(1f).padding(horizontal = 16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||||
|
) {
|
||||||
|
items(messages) { message ->
|
||||||
|
MessageBubble(message, currentUserId)
|
||||||
|
}
|
||||||
|
item { Spacer(Modifier.height(10.dp)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(IntrinsicSize.Min)
|
||||||
|
.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
IconButton(
|
||||||
|
onClick = { /* add action */ },
|
||||||
|
modifier = Modifier
|
||||||
|
.size(36.dp)
|
||||||
|
.border(0.5.dp, MaterialTheme.colorScheme.outlineVariant, CircleShape)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Add,
|
||||||
|
contentDescription = "Add",
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.fillMaxHeight()
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), RoundedCornerShape(20.dp))
|
||||||
|
.border(0.5.dp, MaterialTheme.colorScheme.outlineVariant, RoundedCornerShape(20.dp))
|
||||||
|
.padding(horizontal = 14.dp),
|
||||||
|
contentAlignment = Alignment.CenterStart
|
||||||
|
) {
|
||||||
|
if (input.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
"message",
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
BasicTextField(
|
||||||
|
value = input,
|
||||||
|
onValueChange = { input = it },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
textStyle = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.onSurface),
|
||||||
|
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
|
||||||
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
|
||||||
|
keyboardActions = KeyboardActions(onSend = {
|
||||||
|
if (input.isNotBlank()) {
|
||||||
|
viewModel.sendMessage(input)
|
||||||
|
input = ""
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.isNotBlank()) {
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
IconButton(
|
||||||
|
onClick = {
|
||||||
|
viewModel.sendMessage(input)
|
||||||
|
input = ""
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.size(36.dp)
|
||||||
|
.background(MaterialTheme.colorScheme.primary, CircleShape)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Send,
|
||||||
|
contentDescription = "Send",
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onPrimary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MessageBubble(message: Message, currentUserId: Int?) {
|
||||||
|
val time = remember(message.timestamp) {
|
||||||
|
DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(message.timestamp)).lowercase()
|
||||||
|
}
|
||||||
|
|
||||||
|
val isMe = currentUserId != null && message.user_id == currentUserId
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalAlignment = if (isMe) Alignment.End else Alignment.Start
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
color = if (isMe) MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.2f),
|
||||||
|
shape = RoundedCornerShape(
|
||||||
|
topStart = 14.dp,
|
||||||
|
topEnd = 14.dp,
|
||||||
|
bottomStart = if (isMe) 14.dp else 4.dp,
|
||||||
|
bottomEnd = if (isMe) 4.dp else 14.dp
|
||||||
|
),
|
||||||
|
border = border(0.5.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f))
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(horizontal = 11.dp, vertical = 8.dp)) {
|
||||||
|
if (!isMe) {
|
||||||
|
Text(
|
||||||
|
message.display_name.lowercase(),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.7f),
|
||||||
|
modifier = Modifier.padding(bottom = 2.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = message.text,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.9f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = time,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f),
|
||||||
|
modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun border(width: androidx.compose.ui.unit.Dp, color: Color) =
|
||||||
|
androidx.compose.foundation.BorderStroke(width, color)
|
||||||
@@ -0,0 +1,318 @@
|
|||||||
|
package dev.zxq5.chatapp.android.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
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.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
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.ButtonDefaults
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
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.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import dev.zxq5.chatapp.android.model.AuthMode
|
||||||
|
import dev.zxq5.chatapp.android.model.ChatViewModel
|
||||||
|
import dev.zxq5.chatapp.android.model.LoginState
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AuthScreen(viewModel: ChatViewModel, onSuccess: () -> Unit) {
|
||||||
|
val loginState by viewModel.loginState.collectAsState()
|
||||||
|
val authMode by viewModel.authMode.collectAsState()
|
||||||
|
val totpError by viewModel.totpError.collectAsState()
|
||||||
|
|
||||||
|
var username by remember { mutableStateOf("") }
|
||||||
|
var email by remember { mutableStateOf("") }
|
||||||
|
var password by remember { mutableStateOf("") }
|
||||||
|
var confirmPassword by remember { mutableStateOf("") }
|
||||||
|
var accessToken by remember { mutableStateOf("") }
|
||||||
|
var localError by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
LaunchedEffect(loginState) {
|
||||||
|
if (loginState is LoginState.Success) onSuccess()
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(authMode) {
|
||||||
|
localError = null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loginState is LoginState.TwoFactorRequired ||
|
||||||
|
(loginState is LoginState.Loading && totpError != null)) {
|
||||||
|
TwoFactorLoginScreen(
|
||||||
|
onVerify = { code -> viewModel.verifyLogin2fa(code) },
|
||||||
|
onBack = {
|
||||||
|
viewModel.clearTotpError()
|
||||||
|
viewModel.setAuthMode(AuthMode.LOGIN)
|
||||||
|
},
|
||||||
|
isLoading = loginState is LoginState.Loading,
|
||||||
|
error = totpError
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(MaterialTheme.colorScheme.background)
|
||||||
|
.padding(24.dp)
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Spacer(Modifier.height(40.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "messenger",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(bottom = 8.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = if (authMode == AuthMode.LOGIN) "welcome back" else "create account",
|
||||||
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
modifier = Modifier.padding(bottom = 48.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
TextField(
|
||||||
|
value = username,
|
||||||
|
onValueChange = { username = it },
|
||||||
|
label = "username"
|
||||||
|
)
|
||||||
|
|
||||||
|
if (authMode == AuthMode.SIGNUP) {
|
||||||
|
TextField(
|
||||||
|
value = email,
|
||||||
|
onValueChange = { email = it },
|
||||||
|
label = "email"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
TextField(
|
||||||
|
value = password,
|
||||||
|
onValueChange = { password = it },
|
||||||
|
label = "password",
|
||||||
|
isPassword = true
|
||||||
|
)
|
||||||
|
|
||||||
|
if (authMode == AuthMode.SIGNUP) {
|
||||||
|
TextField(
|
||||||
|
value = confirmPassword,
|
||||||
|
onValueChange = { confirmPassword = it },
|
||||||
|
label = "confirm password",
|
||||||
|
isPassword = true
|
||||||
|
)
|
||||||
|
TextField(
|
||||||
|
value = accessToken,
|
||||||
|
onValueChange = { accessToken = it },
|
||||||
|
label = "access token"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(32.dp))
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
localError = null
|
||||||
|
if (authMode == AuthMode.LOGIN) {
|
||||||
|
if (username.isBlank() || password.isBlank()) {
|
||||||
|
localError = "fill all fields"
|
||||||
|
return@Button
|
||||||
|
}
|
||||||
|
viewModel.login(username, password)
|
||||||
|
} else {
|
||||||
|
if (username.isBlank() || email.isBlank() || password.isBlank() || accessToken.isBlank()) {
|
||||||
|
localError = "fill all fields"
|
||||||
|
return@Button
|
||||||
|
}
|
||||||
|
if (password != confirmPassword) {
|
||||||
|
localError = "passwords mismatch"
|
||||||
|
return@Button
|
||||||
|
}
|
||||||
|
viewModel.signup(username, email, password, accessToken)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled = loginState !is LoginState.Loading,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(8.dp),
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.primary,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
disabledContainerColor = MaterialTheme.colorScheme.secondary
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
if (loginState is LoginState.Loading) {
|
||||||
|
CircularProgressIndicator(modifier = Modifier.size(16.dp), color = MaterialTheme.colorScheme.onPrimary, strokeWidth = 2.dp)
|
||||||
|
} else {
|
||||||
|
Text(
|
||||||
|
if (authMode == AuthMode.LOGIN) "login" else "sign up",
|
||||||
|
style = MaterialTheme.typography.bodyLarge
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val displayError = localError ?: (loginState as? LoginState.Error)?.message
|
||||||
|
if (displayError != null) {
|
||||||
|
Text(
|
||||||
|
text = displayError.lowercase(),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = Color.Red,
|
||||||
|
modifier = Modifier.padding(top = 16.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
viewModel.setAuthMode(if (authMode == AuthMode.LOGIN) AuthMode.SIGNUP else AuthMode.LOGIN)
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
if (authMode == AuthMode.LOGIN) "no account? sign up"
|
||||||
|
else "have account? login",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun TwoFactorLoginScreen(
|
||||||
|
onVerify: (String) -> Unit,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
isLoading: Boolean,
|
||||||
|
error: String?
|
||||||
|
) {
|
||||||
|
var code by remember { mutableStateOf("") }
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(MaterialTheme.colorScheme.background)
|
||||||
|
.padding(24.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(
|
||||||
|
Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
|
contentDescription = "Back",
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
"security verification",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(80.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
"two-factor auth",
|
||||||
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
"enter the 6-digit code from your app",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
|
||||||
|
modifier = Modifier.padding(top = 8.dp, bottom = 48.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = code,
|
||||||
|
onValueChange = { if (it.length <= 6) code = it },
|
||||||
|
placeholder = { Text("000000", color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f)) },
|
||||||
|
modifier = Modifier.width(200.dp),
|
||||||
|
textStyle = MaterialTheme.typography.headlineMedium.copy(
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
letterSpacing = 8.sp
|
||||||
|
),
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||||
|
shape = RoundedCornerShape(8.dp),
|
||||||
|
colors = OutlinedTextFieldDefaults.colors(
|
||||||
|
focusedBorderColor = MaterialTheme.colorScheme.primary,
|
||||||
|
unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant,
|
||||||
|
focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.1f),
|
||||||
|
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.1f)
|
||||||
|
),
|
||||||
|
singleLine = true
|
||||||
|
)
|
||||||
|
|
||||||
|
if (error != null) {
|
||||||
|
Text(
|
||||||
|
text = error.lowercase(),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = Color.Red,
|
||||||
|
modifier = Modifier.padding(top = 12.dp)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Spacer(Modifier.height(12.dp)) // keep layout stable when no error
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(36.dp))
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = { if (code.length == 6) onVerify(code) },
|
||||||
|
enabled = code.length == 6 && !isLoading,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(8.dp),
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.primary,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onPrimary
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
if (isLoading) {
|
||||||
|
CircularProgressIndicator(modifier = Modifier.size(16.dp), color = MaterialTheme.colorScheme.onPrimary, strokeWidth = 2.dp)
|
||||||
|
} else {
|
||||||
|
Text("verify", style = MaterialTheme.typography.bodyLarge)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,368 @@
|
|||||||
|
package dev.zxq5.chatapp.android.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
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.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
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.Check
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
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.Scaffold
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
|
import androidx.compose.material3.SwitchDefaults
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import dev.zxq5.chatapp.android.model.ChatViewModel
|
||||||
|
import dev.zxq5.chatapp.android.model.MainScreen
|
||||||
|
import android.util.Base64
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun SettingsScreen(viewModel: ChatViewModel) {
|
||||||
|
val is2faEnabled by viewModel.is2faEnabled.collectAsState()
|
||||||
|
val totpQr by viewModel.totpQr.collectAsState()
|
||||||
|
val settingsError by viewModel.settingsError.collectAsState()
|
||||||
|
val settingsSuccess by viewModel.settingsSuccess.collectAsState()
|
||||||
|
|
||||||
|
var show2faSetup by remember { mutableStateOf(false) }
|
||||||
|
var displayName by remember { mutableStateOf("") }
|
||||||
|
var oldPassword by remember { mutableStateOf("") }
|
||||||
|
var newPassword by remember { mutableStateOf("") }
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
viewModel.clearSettingsMessages()
|
||||||
|
viewModel.fetchTotpStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
containerColor = MaterialTheme.colorScheme.background,
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
"settings",
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
},
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = { viewModel.navigateTo(MainScreen.CHAT) }) {
|
||||||
|
Icon(
|
||||||
|
Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
|
contentDescription = "Back",
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = Color.Transparent
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { padding ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(padding)
|
||||||
|
.fillMaxSize()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(20.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(24.dp)
|
||||||
|
) {
|
||||||
|
if (settingsError != null) {
|
||||||
|
Text(settingsError!!, color = Color.Red, style = MaterialTheme.typography.bodySmall)
|
||||||
|
}
|
||||||
|
if (settingsSuccess != null) {
|
||||||
|
Text(settingsSuccess!!, color = Color.Green, style = MaterialTheme.typography.bodySmall)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Profile Section
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
"profile",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
|
||||||
|
modifier = Modifier.padding(bottom = 12.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = displayName,
|
||||||
|
onValueChange = { displayName = it },
|
||||||
|
label = { Text("display name") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(8.dp),
|
||||||
|
trailingIcon = {
|
||||||
|
IconButton(onClick = { viewModel.updateDisplayName(displayName.ifBlank { null }) }) {
|
||||||
|
Icon(Icons.Default.Check, "Save")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.2f))
|
||||||
|
|
||||||
|
// Security Section
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
"account security",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
|
||||||
|
modifier = Modifier.padding(bottom = 12.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
"two-factor authentication",
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
if (is2faEnabled) "enabled" else "disabled",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = if (is2faEnabled) Color.Green.copy(alpha = 0.7f) else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!is2faEnabled) {
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
show2faSetup = true
|
||||||
|
viewModel.fetchTotpQr()
|
||||||
|
},
|
||||||
|
shape = RoundedCornerShape(8.dp),
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f),
|
||||||
|
contentColor = MaterialTheme.colorScheme.onSurface
|
||||||
|
),
|
||||||
|
modifier = Modifier.height(32.dp)
|
||||||
|
) {
|
||||||
|
Text("setup", style = MaterialTheme.typography.labelSmall)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Button(
|
||||||
|
onClick = { viewModel.disableTotp() },
|
||||||
|
shape = RoundedCornerShape(8.dp),
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = Color.Red.copy(alpha = 0.1f),
|
||||||
|
contentColor = Color.Red
|
||||||
|
),
|
||||||
|
modifier = Modifier.height(32.dp)
|
||||||
|
) {
|
||||||
|
Text("disable", style = MaterialTheme.typography.labelSmall)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (show2faSetup && !is2faEnabled) {
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
TwoFactorSetup(
|
||||||
|
qrCodeBase64 = totpQr?.qr_code,
|
||||||
|
onConfirm = { code ->
|
||||||
|
viewModel.confirmTotp(code)
|
||||||
|
},
|
||||||
|
onCancel = { show2faSetup = false }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
|
Text("change password", style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(bottom = 8.dp))
|
||||||
|
OutlinedTextField(
|
||||||
|
value = oldPassword,
|
||||||
|
onValueChange = { oldPassword = it },
|
||||||
|
label = { Text("old password") },
|
||||||
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(8.dp)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
OutlinedTextField(
|
||||||
|
value = newPassword,
|
||||||
|
onValueChange = { newPassword = it },
|
||||||
|
label = { Text("new password") },
|
||||||
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(8.dp)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
viewModel.changePassword(oldPassword, newPassword)
|
||||||
|
oldPassword = ""
|
||||||
|
newPassword = ""
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(8.dp),
|
||||||
|
enabled = oldPassword.isNotEmpty() && newPassword.isNotEmpty()
|
||||||
|
) {
|
||||||
|
Text("update password")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.2f))
|
||||||
|
|
||||||
|
// Application Section
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
"application",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
|
||||||
|
modifier = Modifier.padding(bottom = 12.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = { viewModel.logout() },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(8.dp),
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = Color.Red.copy(alpha = 0.1f),
|
||||||
|
contentColor = Color.Red
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Text("logout", style = MaterialTheme.typography.bodyLarge)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun TwoFactorSetup(
|
||||||
|
qrCodeBase64: String?,
|
||||||
|
onConfirm: (String) -> Unit,
|
||||||
|
onCancel: () -> Unit
|
||||||
|
) {
|
||||||
|
var code by remember { mutableStateOf("") }
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.border(0.5.dp, MaterialTheme.colorScheme.outlineVariant, RoundedCornerShape(12.dp))
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.1f), RoundedCornerShape(12.dp))
|
||||||
|
.padding(20.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"scan qr code",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
|
if (qrCodeBase64 != null) {
|
||||||
|
val bitmap = remember(qrCodeBase64) {
|
||||||
|
val base64Data = qrCodeBase64.substringAfter("base64,")
|
||||||
|
val decodedString = Base64.decode(base64Data, Base64.DEFAULT)
|
||||||
|
BitmapFactory.decodeByteArray(decodedString, 0, decodedString.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
bitmap?.let {
|
||||||
|
Image(
|
||||||
|
bitmap = it.asImageBitmap(),
|
||||||
|
contentDescription = "QR Code",
|
||||||
|
modifier = Modifier
|
||||||
|
.size(180.dp)
|
||||||
|
.clip(RoundedCornerShape(8.dp))
|
||||||
|
.background(Color.White) // QR codes usually need white background
|
||||||
|
.padding(8.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.size(180.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = code,
|
||||||
|
onValueChange = { if (it.length <= 6) code = it },
|
||||||
|
placeholder = { Text("000000", color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f)) },
|
||||||
|
modifier = Modifier.width(150.dp),
|
||||||
|
textStyle = MaterialTheme.typography.headlineMedium.copy(
|
||||||
|
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
||||||
|
letterSpacing = 4.sp
|
||||||
|
),
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||||
|
shape = RoundedCornerShape(8.dp),
|
||||||
|
colors = OutlinedTextFieldDefaults.colors(
|
||||||
|
focusedBorderColor = MaterialTheme.colorScheme.primary,
|
||||||
|
unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
Button(
|
||||||
|
onClick = onCancel,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
shape = RoundedCornerShape(8.dp),
|
||||||
|
colors = ButtonDefaults.buttonColors(containerColor = Color.Transparent, contentColor = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
) {
|
||||||
|
Text("cancel", style = MaterialTheme.typography.labelSmall)
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = { if (code.length == 6) onConfirm(code) },
|
||||||
|
enabled = code.length == 6,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
shape = RoundedCornerShape(8.dp)
|
||||||
|
) {
|
||||||
|
Text("confirm", style = MaterialTheme.typography.labelSmall)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package dev.zxq5.chatapp.android.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
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.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun TextField(
|
||||||
|
value: String,
|
||||||
|
onValueChange: (String) -> Unit,
|
||||||
|
label: String,
|
||||||
|
isPassword: Boolean = false
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = value,
|
||||||
|
onValueChange = onValueChange,
|
||||||
|
placeholder = {
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
singleLine = true,
|
||||||
|
textStyle = MaterialTheme.typography.bodyLarge,
|
||||||
|
visualTransformation = if (isPassword) PasswordVisualTransformation() else androidx.compose.ui.text.input.VisualTransformation.None,
|
||||||
|
shape = RoundedCornerShape(8.dp),
|
||||||
|
colors = OutlinedTextFieldDefaults.colors(
|
||||||
|
focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f),
|
||||||
|
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f),
|
||||||
|
focusedBorderColor = MaterialTheme.colorScheme.outlineVariant,
|
||||||
|
unfocusedBorderColor = MaterialTheme.colorScheme.outline,
|
||||||
|
focusedTextColor = MaterialTheme.colorScheme.onSurface,
|
||||||
|
unfocusedTextColor = MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package dev.zxq5.chatapp.android.ui.theme
|
||||||
|
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
|
val Black = Color(0xFF0A0A0A)
|
||||||
|
val DarkGrey = Color(0xFF0D0D0D)
|
||||||
|
val Grey = Color(0xFF141414)
|
||||||
|
val LightGrey = Color(0xFF1E1E1E)
|
||||||
|
val White = Color(0xFFE8E8E8)
|
||||||
|
|
||||||
|
val TextPrimary = Color(0xFFE8E8E8)
|
||||||
|
val TextSecondary = Color(0xFF888888)
|
||||||
|
val TextTertiary = Color(0xFF555555)
|
||||||
|
val TextMuted = Color(0xFF333333)
|
||||||
|
|
||||||
|
val Border = Color(0xFF1A1A1A)
|
||||||
|
val BorderLight = Color(0xFF222222)
|
||||||
|
|
||||||
|
val Red = Color(0xFFFF0000)
|
||||||
|
val Surface = Color(0xFF111111)
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package dev.zxq5.chatapp.android.ui.theme
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.darkColorScheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.SideEffect
|
||||||
|
import androidx.compose.ui.graphics.toArgb
|
||||||
|
import androidx.compose.ui.platform.LocalView
|
||||||
|
import androidx.core.view.WindowCompat
|
||||||
|
|
||||||
|
private val DarkColorScheme = darkColorScheme(
|
||||||
|
primary = White,
|
||||||
|
onPrimary = Black,
|
||||||
|
secondary = LightGrey,
|
||||||
|
onSecondary = White,
|
||||||
|
tertiary = Grey,
|
||||||
|
background = Black,
|
||||||
|
onBackground = White,
|
||||||
|
surface = Black,
|
||||||
|
onSurface = White,
|
||||||
|
surfaceVariant = Grey,
|
||||||
|
onSurfaceVariant = TextSecondary,
|
||||||
|
outline = Border,
|
||||||
|
outlineVariant = BorderLight
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ChatappTheme(
|
||||||
|
darkTheme: Boolean = true, // Force dark theme for Nothing style
|
||||||
|
content: @Composable () -> Unit
|
||||||
|
) {
|
||||||
|
val colorScheme = DarkColorScheme
|
||||||
|
val view = LocalView.current
|
||||||
|
if (!view.isInEditMode) {
|
||||||
|
SideEffect {
|
||||||
|
val window = (view.context as Activity).window
|
||||||
|
window.statusBarColor = Black.toArgb()
|
||||||
|
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MaterialTheme(
|
||||||
|
colorScheme = colorScheme,
|
||||||
|
typography = Typography,
|
||||||
|
content = content
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package dev.zxq5.chatapp.android.ui.theme
|
||||||
|
|
||||||
|
import androidx.compose.material3.Typography
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
|
||||||
|
val Typography = Typography(
|
||||||
|
bodyLarge = TextStyle(
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
lineHeight = 20.sp,
|
||||||
|
letterSpacing = 0.02.sp
|
||||||
|
),
|
||||||
|
titleLarge = TextStyle(
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
fontSize = 20.sp,
|
||||||
|
lineHeight = 28.sp,
|
||||||
|
letterSpacing = (-0.02).sp
|
||||||
|
),
|
||||||
|
labelSmall = TextStyle(
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
fontSize = 10.sp,
|
||||||
|
lineHeight = 14.sp,
|
||||||
|
letterSpacing = 0.05.sp
|
||||||
|
),
|
||||||
|
headlineMedium = TextStyle(
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
fontSize = 22.sp,
|
||||||
|
letterSpacing = (-0.02).sp
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
<path
|
||||||
|
android:fillColor="#3DDC84"
|
||||||
|
android:pathData="M0,0h108v108h-108z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M9,0L9,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,0L19,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M29,0L29,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M39,0L39,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M49,0L49,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M59,0L59,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M69,0L69,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M79,0L79,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M89,0L89,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M99,0L99,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,9L108,9"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,19L108,19"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,29L108,29"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,39L108,39"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,49L108,49"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,59L108,59"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,69L108,69"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,79L108,79"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,89L108,89"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,99L108,99"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,29L89,29"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,39L89,39"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,49L89,49"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,59L89,59"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,69L89,69"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,79L89,79"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M29,19L29,89"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M39,19L39,89"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M49,19L49,89"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M59,19L59,89"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M69,19L69,89"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M79,19L79,89"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:aapt="http://schemas.android.com/aapt"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
|
||||||
|
<aapt:attr name="android:fillColor">
|
||||||
|
<gradient
|
||||||
|
android:endX="85.84757"
|
||||||
|
android:endY="92.4963"
|
||||||
|
android:startX="42.9492"
|
||||||
|
android:startY="49.59793"
|
||||||
|
android:type="linear">
|
||||||
|
<item
|
||||||
|
android:color="#44000000"
|
||||||
|
android:offset="0.0" />
|
||||||
|
<item
|
||||||
|
android:color="#00000000"
|
||||||
|
android:offset="1.0" />
|
||||||
|
</gradient>
|
||||||
|
</aapt:attr>
|
||||||
|
</path>
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:fillType="nonZero"
|
||||||
|
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
|
||||||
|
android:strokeWidth="1"
|
||||||
|
android:strokeColor="#00000000" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@drawable/ic_launcher_background" />
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
</adaptive-icon>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@drawable/ic_launcher_background" />
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
</adaptive-icon>
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 982 B |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
@@ -0,0 +1,765 @@
|
|||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: 'Courier New', monospace; }
|
||||||
|
|
||||||
|
.phones {
|
||||||
|
display: flex;
|
||||||
|
gap: 24px;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phone {
|
||||||
|
width: 280px;
|
||||||
|
background: #0a0a0a;
|
||||||
|
border-radius: 36px;
|
||||||
|
border: 1px solid #222;
|
||||||
|
overflow: hidden;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phone-inner {
|
||||||
|
padding: 0;
|
||||||
|
height: 560px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-bar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 20px 4px;
|
||||||
|
font-size: 10px;
|
||||||
|
color: #555;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.screen-label {
|
||||||
|
font-size: 9px;
|
||||||
|
letter-spacing: 0.15em;
|
||||||
|
color: #333;
|
||||||
|
text-transform: uppercase;
|
||||||
|
text-align: center;
|
||||||
|
padding: 0 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── SCREEN 1: contacts list ── */
|
||||||
|
.contacts-header {
|
||||||
|
padding: 8px 20px 12px;
|
||||||
|
border-bottom: 0.5px solid #1a1a1a;
|
||||||
|
}
|
||||||
|
.contacts-title {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 400;
|
||||||
|
color: #e8e8e8;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
.contacts-sub {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #444;
|
||||||
|
margin-top: 2px;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-bottom: 0.5px solid #161616;
|
||||||
|
}
|
||||||
|
.search-box {
|
||||||
|
flex: 1;
|
||||||
|
background: #111;
|
||||||
|
border: 0.5px solid #222;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 7px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #444;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 11px 20px;
|
||||||
|
border-bottom: 0.5px solid #111;
|
||||||
|
cursor: pointer;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.contact-item:hover { background: #0f0f0f; }
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 0.5px solid #2a2a2a;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #666;
|
||||||
|
flex-shrink: 0;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-info { flex: 1; min-width: 0; }
|
||||||
|
.contact-name {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #d0d0d0;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.contact-preview {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #3a3a3a;
|
||||||
|
margin-top: 2px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.contact-time { font-size: 10px; color: #333; letter-spacing: 0.03em; }
|
||||||
|
.unread-dot {
|
||||||
|
width: 6px; height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #e8e8e8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.walkie-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 20px;
|
||||||
|
background: #0d0d0d;
|
||||||
|
border-bottom: 0.5px solid #1a1a1a;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.walkie-indicator {
|
||||||
|
width: 6px; height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #3a3a3a;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.walkie-indicator.active { background: #e8e8e8; }
|
||||||
|
.walkie-label { font-size: 10px; color: #444; letter-spacing: 0.08em; flex: 1; }
|
||||||
|
.walkie-btn {
|
||||||
|
font-size: 10px;
|
||||||
|
color: #555;
|
||||||
|
border: 0.5px solid #2a2a2a;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
cursor: pointer;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-nav {
|
||||||
|
margin-top: auto;
|
||||||
|
display: flex;
|
||||||
|
border-top: 0.5px solid #161616;
|
||||||
|
padding: 10px 0 14px;
|
||||||
|
}
|
||||||
|
.nav-item {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.nav-icon {
|
||||||
|
width: 20px; height: 20px;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
.nav-label { font-size: 9px; color: #333; letter-spacing: 0.06em; }
|
||||||
|
.nav-item.active .nav-label { color: #e8e8e8; }
|
||||||
|
|
||||||
|
/* ── SCREEN 2: chat ── */
|
||||||
|
.chat-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 16px 10px;
|
||||||
|
border-bottom: 0.5px solid #1a1a1a;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.back-btn {
|
||||||
|
font-size: 16px;
|
||||||
|
color: #555;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 2px 4px;
|
||||||
|
}
|
||||||
|
.chat-header-info { flex: 1; }
|
||||||
|
.chat-name { font-size: 14px; color: #d8d8d8; letter-spacing: 0.02em; }
|
||||||
|
.chat-status { font-size: 10px; color: #3a3a3a; letter-spacing: 0.04em; margin-top: 1px; }
|
||||||
|
.chat-actions { display: flex; gap: 14px; }
|
||||||
|
.action-icon { font-size: 14px; color: #3a3a3a; cursor: pointer; }
|
||||||
|
|
||||||
|
.chat-body {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 12px 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-row { display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
.msg-row.me { align-items: flex-end; }
|
||||||
|
.msg-row.them { align-items: flex-start; }
|
||||||
|
|
||||||
|
.msg-bubble {
|
||||||
|
max-width: 75%;
|
||||||
|
padding: 8px 11px;
|
||||||
|
border-radius: 14px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.45;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
.msg-row.them .msg-bubble {
|
||||||
|
background: #141414;
|
||||||
|
color: #c0c0c0;
|
||||||
|
border-bottom-left-radius: 4px;
|
||||||
|
border: 0.5px solid #1e1e1e;
|
||||||
|
}
|
||||||
|
.msg-row.me .msg-bubble {
|
||||||
|
background: #1e1e1e;
|
||||||
|
color: #d0d0d0;
|
||||||
|
border-bottom-right-radius: 4px;
|
||||||
|
border: 0.5px solid #2a2a2a;
|
||||||
|
}
|
||||||
|
.msg-time { font-size: 9px; color: #2e2e2e; letter-spacing: 0.04em; padding: 0 4px; }
|
||||||
|
|
||||||
|
.reaction-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
.reaction-pill {
|
||||||
|
background: #111;
|
||||||
|
border: 0.5px solid #222;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 2px 7px;
|
||||||
|
font-size: 10px;
|
||||||
|
color: #555;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.poll-card {
|
||||||
|
background: #111;
|
||||||
|
border: 0.5px solid #1e1e1e;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
max-width: 80%;
|
||||||
|
}
|
||||||
|
.poll-q { font-size: 11px; color: #888; letter-spacing: 0.04em; margin-bottom: 8px; }
|
||||||
|
.poll-option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.poll-bar-bg {
|
||||||
|
flex: 1;
|
||||||
|
height: 3px;
|
||||||
|
background: #1a1a1a;
|
||||||
|
border-radius: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.poll-bar-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: #e8e8e8;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
.poll-opt-label { font-size: 10px; color: #555; width: 52px; letter-spacing: 0.03em; flex-shrink: 0; }
|
||||||
|
.poll-pct { font-size: 10px; color: #333; width: 24px; text-align: right; flex-shrink: 0; }
|
||||||
|
|
||||||
|
.voice-msg {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
background: #141414;
|
||||||
|
border: 0.5px solid #1e1e1e;
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
max-width: 72%;
|
||||||
|
}
|
||||||
|
.play-btn {
|
||||||
|
width: 22px; height: 22px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 0.5px solid #333;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.play-tri {
|
||||||
|
width: 0; height: 0;
|
||||||
|
border-style: solid;
|
||||||
|
border-width: 4px 0 4px 7px;
|
||||||
|
border-color: transparent transparent transparent #888;
|
||||||
|
margin-left: 1px;
|
||||||
|
}
|
||||||
|
.waveform {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.wave-bar {
|
||||||
|
width: 2px;
|
||||||
|
border-radius: 1px;
|
||||||
|
background: #2a2a2a;
|
||||||
|
}
|
||||||
|
.voice-dur { font-size: 10px; color: #333; letter-spacing: 0.04em; }
|
||||||
|
|
||||||
|
.chat-input-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 14px 12px;
|
||||||
|
border-top: 0.5px solid #161616;
|
||||||
|
}
|
||||||
|
.chat-input {
|
||||||
|
flex: 1;
|
||||||
|
background: #111;
|
||||||
|
border: 0.5px solid #1e1e1e;
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #888;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
.input-action {
|
||||||
|
width: 30px; height: 30px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 0.5px solid #1e1e1e;
|
||||||
|
background: transparent;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── SCREEN 3: space ── */
|
||||||
|
.space-header {
|
||||||
|
padding: 8px 20px 10px;
|
||||||
|
border-bottom: 0.5px solid #1a1a1a;
|
||||||
|
}
|
||||||
|
.space-eyebrow { font-size: 9px; color: #333; letter-spacing: 0.1em; margin-bottom: 4px; }
|
||||||
|
.space-title { font-size: 18px; color: #d8d8d8; letter-spacing: -0.01em; }
|
||||||
|
.space-members { font-size: 10px; color: #333; letter-spacing: 0.04em; margin-top: 2px; }
|
||||||
|
|
||||||
|
.space-tabs {
|
||||||
|
display: flex;
|
||||||
|
border-bottom: 0.5px solid #161616;
|
||||||
|
padding: 0 20px;
|
||||||
|
}
|
||||||
|
.space-tab {
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: #333;
|
||||||
|
padding: 8px 0;
|
||||||
|
margin-right: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-bottom: 1px solid transparent;
|
||||||
|
}
|
||||||
|
.space-tab.active {
|
||||||
|
color: #e8e8e8;
|
||||||
|
border-bottom-color: #e8e8e8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.space-body { flex: 1; overflow: hidden; padding: 12px 20px; display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
|
||||||
|
.file-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 9px 10px;
|
||||||
|
background: #0d0d0d;
|
||||||
|
border: 0.5px solid #181818;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.file-icon {
|
||||||
|
width: 28px; height: 28px;
|
||||||
|
background: #141414;
|
||||||
|
border: 0.5px solid #222;
|
||||||
|
border-radius: 5px;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 10px;
|
||||||
|
color: #555;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.file-info { flex: 1; min-width: 0; }
|
||||||
|
.file-name { font-size: 12px; color: #b0b0b0; letter-spacing: 0.02em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.file-meta { font-size: 10px; color: #333; letter-spacing: 0.03em; margin-top: 2px; }
|
||||||
|
.file-size { font-size: 10px; color: #2e2e2e; flex-shrink: 0; }
|
||||||
|
|
||||||
|
.doc-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 9px 10px;
|
||||||
|
background: #0d0d0d;
|
||||||
|
border: 0.5px solid #181818;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.doc-preview {
|
||||||
|
width: 28px; height: 34px;
|
||||||
|
background: #111;
|
||||||
|
border: 0.5px solid #222;
|
||||||
|
border-radius: 3px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
padding: 4px 3px;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
.doc-line { height: 2px; background: #2a2a2a; border-radius: 1px; }
|
||||||
|
.doc-line.short { width: 60%; }
|
||||||
|
|
||||||
|
.space-add-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
.add-btn {
|
||||||
|
flex: 1;
|
||||||
|
background: transparent;
|
||||||
|
border: 0.5px solid #1e1e1e;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px;
|
||||||
|
font-size: 10px;
|
||||||
|
color: #333;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nfc-prompt {
|
||||||
|
margin-top: auto;
|
||||||
|
border: 0.5px solid #1a1a1a;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 12px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.nfc-ring {
|
||||||
|
width: 40px; height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 0.5px solid #222;
|
||||||
|
margin: 0 auto 6px;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
.nfc-inner {
|
||||||
|
width: 24px; height: 24px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 0.5px solid #2e2e2e;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
.nfc-core { width: 8px; height: 8px; border-radius: 50%; background: #1e1e1e; border: 0.5px solid #333; }
|
||||||
|
.nfc-label { font-size: 10px; color: #333; letter-spacing: 0.06em; }
|
||||||
|
.nfc-sub { font-size: 9px; color: #222; letter-spacing: 0.04em; margin-top: 2px; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div class="phones">
|
||||||
|
|
||||||
|
<!-- SCREEN 1: contacts -->
|
||||||
|
<div class="phone">
|
||||||
|
<div class="phone-inner">
|
||||||
|
<div class="status-bar"><span>09:41</span><span>▪▪▪</span></div>
|
||||||
|
<div class="screen-label">contacts</div>
|
||||||
|
<div class="contacts-header">
|
||||||
|
<div class="contacts-title">messages</div>
|
||||||
|
<div class="contacts-sub">5 contacts · end-to-end encrypted</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="walkie-row">
|
||||||
|
<div class="walkie-indicator active"></div>
|
||||||
|
<div class="walkie-label">flat · walkie talkie</div>
|
||||||
|
<div class="walkie-btn">hold to talk</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="flex:1;overflow:hidden;">
|
||||||
|
<div class="contact-item">
|
||||||
|
<div class="avatar">JM</div>
|
||||||
|
<div class="contact-info">
|
||||||
|
<div class="contact-name">jamie</div>
|
||||||
|
<div class="contact-preview">voice message · 0:12</div>
|
||||||
|
</div>
|
||||||
|
<div class="contact-meta">
|
||||||
|
<div class="contact-time">now</div>
|
||||||
|
<div class="unread-dot"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="contact-item">
|
||||||
|
<div class="avatar">RP</div>
|
||||||
|
<div class="contact-info">
|
||||||
|
<div class="contact-name">climbing crew</div>
|
||||||
|
<div class="contact-preview">priya: who's in sat?</div>
|
||||||
|
</div>
|
||||||
|
<div class="contact-meta">
|
||||||
|
<div class="contact-time">14:22</div>
|
||||||
|
<div class="unread-dot"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="contact-item">
|
||||||
|
<div class="avatar">AL</div>
|
||||||
|
<div class="contact-info">
|
||||||
|
<div class="contact-name">alex</div>
|
||||||
|
<div class="contact-preview">sent a file</div>
|
||||||
|
</div>
|
||||||
|
<div class="contact-meta">
|
||||||
|
<div class="contact-time">tue</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="contact-item">
|
||||||
|
<div class="avatar">SC</div>
|
||||||
|
<div class="contact-info">
|
||||||
|
<div class="contact-name">sam</div>
|
||||||
|
<div class="contact-preview">yeah let's do it</div>
|
||||||
|
</div>
|
||||||
|
<div class="contact-meta">
|
||||||
|
<div class="contact-time">mon</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="nfc-prompt" style="margin:12px 16px 0;">
|
||||||
|
<div class="nfc-ring"><div class="nfc-inner"><div class="nfc-core"></div></div></div>
|
||||||
|
<div class="nfc-label">add contact</div>
|
||||||
|
<div class="nfc-sub">tap phones · or share a link</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bottom-nav">
|
||||||
|
<div class="nav-item active">
|
||||||
|
<div class="nav-icon">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><rect x="1" y="2" width="14" height="10" rx="2" stroke="#e8e8e8" stroke-width="0.8"/><path d="M5 15h6" stroke="#e8e8e8" stroke-width="0.8" stroke-linecap="round"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="nav-label">chats</div>
|
||||||
|
</div>
|
||||||
|
<div class="nav-item">
|
||||||
|
<div class="nav-icon">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><circle cx="8" cy="6" r="3" stroke="#333" stroke-width="0.8"/><path d="M2 14c0-3.3 2.7-5 6-5s6 1.7 6 5" stroke="#333" stroke-width="0.8" stroke-linecap="round"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="nav-label">contacts</div>
|
||||||
|
</div>
|
||||||
|
<div class="nav-item">
|
||||||
|
<div class="nav-icon">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><circle cx="8" cy="8" r="6" stroke="#333" stroke-width="0.8"/><circle cx="8" cy="8" r="2" stroke="#333" stroke-width="0.8"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="nav-label">spaces</div>
|
||||||
|
</div>
|
||||||
|
<div class="nav-item">
|
||||||
|
<div class="nav-icon">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><circle cx="8" cy="8" r="6" stroke="#333" stroke-width="0.8"/><path d="M8 5v3l2 2" stroke="#333" stroke-width="0.8" stroke-linecap="round"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="nav-label">settings</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SCREEN 2: chat -->
|
||||||
|
<div class="phone">
|
||||||
|
<div class="phone-inner">
|
||||||
|
<div class="status-bar"><span>09:41</span><span>▪▪▪</span></div>
|
||||||
|
<div class="screen-label">chat</div>
|
||||||
|
<div class="chat-header">
|
||||||
|
<div class="back-btn">‹</div>
|
||||||
|
<div class="chat-header-info">
|
||||||
|
<div class="chat-name">climbing crew</div>
|
||||||
|
<div class="chat-status">3 members · e2e encrypted</div>
|
||||||
|
</div>
|
||||||
|
<div class="chat-actions">
|
||||||
|
<svg class="action-icon" width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M2 4h12M2 8h12M2 12h8" stroke="#3a3a3a" stroke-width="0.8" stroke-linecap="round"/></svg>
|
||||||
|
<svg class="action-icon" width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M8 3v5l3 3" stroke="#3a3a3a" stroke-width="0.8" stroke-linecap="round"/><circle cx="8" cy="8" r="6" stroke="#3a3a3a" stroke-width="0.8"/></svg>
|
||||||
|
<svg class="action-icon" width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M3 8a5 5 0 009.9-1M13 8a5 5 0 01-9.9 1" stroke="#3a3a3a" stroke-width="0.8" stroke-linecap="round"/><path d="M11 4l2 3-3 1" stroke="#3a3a3a" stroke-width="0.8" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="chat-body">
|
||||||
|
<div class="msg-row them">
|
||||||
|
<div class="msg-bubble">who's in for saturday?</div>
|
||||||
|
<div class="msg-time">priya · 14:20</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="poll-card">
|
||||||
|
<div class="poll-q">climbing saturday</div>
|
||||||
|
<div class="poll-option">
|
||||||
|
<div class="poll-opt-label">morning</div>
|
||||||
|
<div class="poll-bar-bg"><div class="poll-bar-fill" style="width:70%"></div></div>
|
||||||
|
<div class="poll-pct">70%</div>
|
||||||
|
</div>
|
||||||
|
<div class="poll-option">
|
||||||
|
<div class="poll-opt-label">afternoon</div>
|
||||||
|
<div class="poll-bar-bg"><div class="poll-bar-fill" style="width:20%"></div></div>
|
||||||
|
<div class="poll-pct">20%</div>
|
||||||
|
</div>
|
||||||
|
<div class="poll-option" style="margin-bottom:0">
|
||||||
|
<div class="poll-opt-label">can't make it</div>
|
||||||
|
<div class="poll-bar-bg"><div class="poll-bar-fill" style="width:10%"></div></div>
|
||||||
|
<div class="poll-pct">10%</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="msg-row me">
|
||||||
|
<div class="msg-bubble">morning works, i'll book the wall</div>
|
||||||
|
<div class="msg-time">14:31</div>
|
||||||
|
</div>
|
||||||
|
<div class="reaction-row" style="justify-content:flex-end">
|
||||||
|
<div class="reaction-pill">+1 2</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="voice-msg">
|
||||||
|
<div class="play-btn"><div class="play-tri"></div></div>
|
||||||
|
<div class="waveform">
|
||||||
|
<div class="wave-bar" style="height:4px"></div>
|
||||||
|
<div class="wave-bar" style="height:8px"></div>
|
||||||
|
<div class="wave-bar" style="height:14px"></div>
|
||||||
|
<div class="wave-bar" style="height:10px"></div>
|
||||||
|
<div class="wave-bar" style="height:6px"></div>
|
||||||
|
<div class="wave-bar" style="height:12px"></div>
|
||||||
|
<div class="wave-bar" style="height:8px"></div>
|
||||||
|
<div class="wave-bar" style="height:5px"></div>
|
||||||
|
<div class="wave-bar" style="height:10px"></div>
|
||||||
|
<div class="wave-bar" style="height:14px"></div>
|
||||||
|
<div class="wave-bar" style="height:9px"></div>
|
||||||
|
<div class="wave-bar" style="height:6px"></div>
|
||||||
|
</div>
|
||||||
|
<div class="voice-dur">0:12</div>
|
||||||
|
</div>
|
||||||
|
<div class="msg-time" style="padding-left:4px">jamie · 14:38</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="chat-input-row">
|
||||||
|
<div class="input-action">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="1" y="1" width="12" height="12" rx="2" stroke="#333" stroke-width="0.8"/><path d="M4 7h6M7 4v6" stroke="#333" stroke-width="0.8" stroke-linecap="round"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="chat-input">message</div>
|
||||||
|
<div class="input-action">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><circle cx="7" cy="5" r="2.5" stroke="#333" stroke-width="0.8"/><path d="M7 8v4M5 11h4" stroke="#333" stroke-width="0.8" stroke-linecap="round"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="input-action">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><path d="M2 7h10M7 2l5 5-5 5" stroke="#333" stroke-width="0.8" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SCREEN 3: space -->
|
||||||
|
<div class="phone">
|
||||||
|
<div class="phone-inner">
|
||||||
|
<div class="status-bar"><span>09:41</span><span>▪▪▪</span></div>
|
||||||
|
<div class="screen-label">shared space</div>
|
||||||
|
<div class="space-header">
|
||||||
|
<div class="space-eyebrow">climbing crew</div>
|
||||||
|
<div class="space-title">space</div>
|
||||||
|
<div class="space-members">3 members</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-tabs">
|
||||||
|
<div class="space-tab active">files</div>
|
||||||
|
<div class="space-tab">docs</div>
|
||||||
|
<div class="space-tab">links</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-body">
|
||||||
|
<div class="file-item">
|
||||||
|
<div class="file-icon">jpg</div>
|
||||||
|
<div class="file-info">
|
||||||
|
<div class="file-name">wall-beta-map.jpg</div>
|
||||||
|
<div class="file-meta">priya · 3 days ago</div>
|
||||||
|
</div>
|
||||||
|
<div class="file-size">2.1mb</div>
|
||||||
|
</div>
|
||||||
|
<div class="file-item">
|
||||||
|
<div class="file-icon">pdf</div>
|
||||||
|
<div class="file-info">
|
||||||
|
<div class="file-name">membership-discount.pdf</div>
|
||||||
|
<div class="file-meta">jamie · 1 week ago</div>
|
||||||
|
</div>
|
||||||
|
<div class="file-size">84kb</div>
|
||||||
|
</div>
|
||||||
|
<div class="file-item">
|
||||||
|
<div class="file-icon">mp4</div>
|
||||||
|
<div class="file-info">
|
||||||
|
<div class="file-name">dyno-attempt.mp4</div>
|
||||||
|
<div class="file-meta">you · 2 weeks ago</div>
|
||||||
|
</div>
|
||||||
|
<div class="file-size">18mb</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="height:0.5px;background:#161616;margin:4px 0;"></div>
|
||||||
|
|
||||||
|
<div class="doc-item">
|
||||||
|
<div class="doc-preview">
|
||||||
|
<div class="doc-line"></div>
|
||||||
|
<div class="doc-line short"></div>
|
||||||
|
<div class="doc-line"></div>
|
||||||
|
<div class="doc-line short"></div>
|
||||||
|
</div>
|
||||||
|
<div class="file-info">
|
||||||
|
<div class="file-name">gear checklist</div>
|
||||||
|
<div class="file-meta">shared doc · edited yesterday</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-add-row">
|
||||||
|
<div class="add-btn">+ upload file</div>
|
||||||
|
<div class="add-btn">+ new doc</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="nfc-prompt" style="margin-top:auto;">
|
||||||
|
<div style="font-size:10px;color:#2a2a2a;letter-spacing:0.06em;">storage used</div>
|
||||||
|
<div style="display:flex;align-items:center;gap:8px;margin-top:6px;">
|
||||||
|
<div style="flex:1;height:2px;background:#1a1a1a;border-radius:1px;">
|
||||||
|
<div style="width:34%;height:100%;background:#444;border-radius:1px;"></div>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:10px;color:#2e2e2e;letter-spacing:0.04em;">34%</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bottom-nav">
|
||||||
|
<div class="nav-item">
|
||||||
|
<div class="nav-icon">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><rect x="1" y="2" width="14" height="10" rx="2" stroke="#333" stroke-width="0.8"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="nav-label">chats</div>
|
||||||
|
</div>
|
||||||
|
<div class="nav-item">
|
||||||
|
<div class="nav-icon">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><circle cx="8" cy="6" r="3" stroke="#333" stroke-width="0.8"/><path d="M2 14c0-3.3 2.7-5 6-5s6 1.7 6 5" stroke="#333" stroke-width="0.8" stroke-linecap="round"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="nav-label">contacts</div>
|
||||||
|
</div>
|
||||||
|
<div class="nav-item active">
|
||||||
|
<div class="nav-icon">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><circle cx="8" cy="8" r="6" stroke="#e8e8e8" stroke-width="0.8"/><circle cx="8" cy="8" r="2" stroke="#e8e8e8" stroke-width="0.8"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="nav-label">spaces</div>
|
||||||
|
</div>
|
||||||
|
<div class="nav-item">
|
||||||
|
<div class="nav-icon">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><circle cx="8" cy="8" r="6" stroke="#333" stroke-width="0.8"/><path d="M8 5v3l2 2" stroke="#333" stroke-width="0.8" stroke-linecap="round"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="nav-label">settings</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,627 @@
|
|||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
.phones {
|
||||||
|
display: flex;
|
||||||
|
gap: 24px;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phone {
|
||||||
|
width: 280px;
|
||||||
|
background: #0a0a0a;
|
||||||
|
border-radius: 36px;
|
||||||
|
border: 1px solid #222;
|
||||||
|
overflow: hidden;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phone-inner {
|
||||||
|
height: 580px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-bar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px 20px 4px;
|
||||||
|
font-size: 10px;
|
||||||
|
color: #444;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.screen-label {
|
||||||
|
font-size: 9px;
|
||||||
|
letter-spacing: 0.15em;
|
||||||
|
color: #2a2a2a;
|
||||||
|
text-align: center;
|
||||||
|
padding: 0 0 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
padding: 6px 18px 10px;
|
||||||
|
border-bottom: 0.5px solid #1a1a1a;
|
||||||
|
}
|
||||||
|
.eyebrow { font-size: 9px; color: #2e2e2e; letter-spacing: 0.1em; margin-bottom: 3px; }
|
||||||
|
.title { font-size: 18px; color: #d8d8d8; letter-spacing: -0.01em; font-weight: 400; }
|
||||||
|
.subtitle { font-size: 10px; color: #333; letter-spacing: 0.04em; margin-top: 2px; }
|
||||||
|
|
||||||
|
.space-tabs {
|
||||||
|
display: flex;
|
||||||
|
border-bottom: 0.5px solid #161616;
|
||||||
|
padding: 0 18px;
|
||||||
|
}
|
||||||
|
.tab {
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: #333;
|
||||||
|
padding: 7px 0;
|
||||||
|
margin-right: 18px;
|
||||||
|
border-bottom: 1px solid transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.tab.active { color: #e8e8e8; border-bottom-color: #e8e8e8; }
|
||||||
|
|
||||||
|
.body {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-list {
|
||||||
|
padding: 12px 18px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-card {
|
||||||
|
background: #0d0d0d;
|
||||||
|
border: 0.5px solid #1a1a1a;
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 8px 10px 6px;
|
||||||
|
border-bottom: 0.5px solid #161616;
|
||||||
|
}
|
||||||
|
.table-card-name { font-size: 11px; color: #b0b0b0; letter-spacing: 0.04em; }
|
||||||
|
.table-card-meta { font-size: 9px; color: #2e2e2e; letter-spacing: 0.04em; }
|
||||||
|
|
||||||
|
.mini-table { width: 100%; overflow: hidden; }
|
||||||
|
.mini-table table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 10px;
|
||||||
|
table-layout: fixed;
|
||||||
|
}
|
||||||
|
.mini-table th {
|
||||||
|
padding: 4px 10px;
|
||||||
|
text-align: left;
|
||||||
|
color: #333;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
font-weight: 400;
|
||||||
|
border-bottom: 0.5px solid #161616;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.mini-table td {
|
||||||
|
padding: 5px 10px;
|
||||||
|
color: #666;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
border-bottom: 0.5px solid #111;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.mini-table tr:last-child td { border-bottom: none; }
|
||||||
|
|
||||||
|
.check-on {
|
||||||
|
display: inline-block;
|
||||||
|
width: 10px; height: 10px;
|
||||||
|
border: 0.5px solid #444;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: #222;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.check-on::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 2px; top: 0px;
|
||||||
|
width: 4px; height: 7px;
|
||||||
|
border-right: 1px solid #888;
|
||||||
|
border-bottom: 1px solid #888;
|
||||||
|
transform: rotate(45deg) translate(-1px, -2px);
|
||||||
|
}
|
||||||
|
.check-off {
|
||||||
|
display: inline-block;
|
||||||
|
width: 10px; height: 10px;
|
||||||
|
border: 0.5px solid #222;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-table-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 9px 10px;
|
||||||
|
background: transparent;
|
||||||
|
border: 0.5px dashed #1e1e1e;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 10px;
|
||||||
|
color: #2e2e2e;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-nav {
|
||||||
|
display: flex;
|
||||||
|
border-top: 0.5px solid #161616;
|
||||||
|
padding: 10px 0 14px;
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
.nav-item {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.nav-label { font-size: 9px; color: #2e2e2e; letter-spacing: 0.06em; }
|
||||||
|
.nav-item.active .nav-label { color: #e8e8e8; }
|
||||||
|
|
||||||
|
/* ── SCREEN 2: open table ── */
|
||||||
|
.table-header {
|
||||||
|
padding: 6px 18px 10px;
|
||||||
|
border-bottom: 0.5px solid #1a1a1a;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.table-title { font-size: 18px; color: #d8d8d8; letter-spacing: -0.01em; font-weight: 400; }
|
||||||
|
.table-actions { display: flex; gap: 10px; padding-bottom: 2px; }
|
||||||
|
.tbl-action { font-size: 10px; color: #2e2e2e; letter-spacing: 0.06em; cursor: pointer; border: 0.5px solid #1e1e1e; border-radius: 4px; padding: 3px 8px; }
|
||||||
|
|
||||||
|
.full-table-wrap {
|
||||||
|
flex: 1;
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.full-table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
min-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.full-table thead th {
|
||||||
|
padding: 7px 12px;
|
||||||
|
text-align: left;
|
||||||
|
color: #444;
|
||||||
|
letter-spacing: 0.07em;
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 9px;
|
||||||
|
border-bottom: 0.5px solid #1e1e1e;
|
||||||
|
border-right: 0.5px solid #161616;
|
||||||
|
background: #0d0d0d;
|
||||||
|
white-space: nowrap;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
.full-table thead th:last-child { border-right: none; }
|
||||||
|
|
||||||
|
.full-table td {
|
||||||
|
padding: 8px 12px;
|
||||||
|
color: #888;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
border-bottom: 0.5px solid #111;
|
||||||
|
border-right: 0.5px solid #0f0f0f;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.full-table td:last-child { border-right: none; }
|
||||||
|
.full-table tr:last-child td { border-bottom: none; }
|
||||||
|
.full-table tr.selected td { background: #111; color: #b0b0b0; }
|
||||||
|
|
||||||
|
.type-badge {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 8px;
|
||||||
|
color: #2e2e2e;
|
||||||
|
border: 0.5px solid #1e1e1e;
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 1px 4px;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
margin-left: 4px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-header-inner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-row-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 10px;
|
||||||
|
color: #2a2a2a;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
cursor: pointer;
|
||||||
|
border-top: 0.5px solid #161616;
|
||||||
|
background: #0a0a0a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 7px 18px;
|
||||||
|
border-bottom: 0.5px solid #161616;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
.filter-chip {
|
||||||
|
font-size: 9px;
|
||||||
|
color: #333;
|
||||||
|
border: 0.5px solid #1e1e1e;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
white-space: nowrap;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.filter-chip.active { color: #888; border-color: #333; }
|
||||||
|
|
||||||
|
/* ── SCREEN 3: new table ── */
|
||||||
|
.sheet-header {
|
||||||
|
padding: 6px 18px 10px;
|
||||||
|
border-bottom: 0.5px solid #1a1a1a;
|
||||||
|
}
|
||||||
|
.sheet-title { font-size: 18px; color: #d8d8d8; letter-spacing: -0.01em; font-weight: 400; }
|
||||||
|
.sheet-sub { font-size: 10px; color: #2e2e2e; letter-spacing: 0.04em; margin-top: 2px; }
|
||||||
|
|
||||||
|
.sheet-body { flex: 1; overflow: hidden; padding: 14px 18px; display: flex; flex-direction: column; gap: 14px; }
|
||||||
|
|
||||||
|
.field-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
.field-label { font-size: 9px; color: #333; letter-spacing: 0.08em; }
|
||||||
|
.field-input {
|
||||||
|
background: #0d0d0d;
|
||||||
|
border: 0.5px solid #1e1e1e;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #b0b0b0;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-defs { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.col-def-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.col-name-input {
|
||||||
|
flex: 1;
|
||||||
|
background: #0d0d0d;
|
||||||
|
border: 0.5px solid #1a1a1a;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 7px 9px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #888;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
.col-type-select {
|
||||||
|
background: #0d0d0d;
|
||||||
|
border: 0.5px solid #1a1a1a;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 7px 6px;
|
||||||
|
font-size: 10px;
|
||||||
|
color: #444;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
width: 72px;
|
||||||
|
}
|
||||||
|
.col-drag { font-size: 10px; color: #222; padding: 0 2px; cursor: grab; }
|
||||||
|
|
||||||
|
.add-col-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 10px;
|
||||||
|
color: #2a2a2a;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
.template-label { font-size: 9px; color: #333; letter-spacing: 0.08em; }
|
||||||
|
.template-chips { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||||
|
.template-chip {
|
||||||
|
font-size: 9px;
|
||||||
|
color: #3a3a3a;
|
||||||
|
border: 0.5px solid #1e1e1e;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.template-chip.selected { color: #888; border-color: #444; }
|
||||||
|
|
||||||
|
.create-btn {
|
||||||
|
background: #e8e8e8;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #0a0a0a;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
cursor: pointer;
|
||||||
|
width: 100%;
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div class="phones">
|
||||||
|
|
||||||
|
<!-- SCREEN 1: space with tables tab -->
|
||||||
|
<div class="phone">
|
||||||
|
<div class="phone-inner">
|
||||||
|
<div class="status-bar"><span>09:41</span><span>▪▪▪</span></div>
|
||||||
|
<div class="screen-label">shared space · tables</div>
|
||||||
|
<div class="header">
|
||||||
|
<div class="eyebrow">climbing crew</div>
|
||||||
|
<div class="title">space</div>
|
||||||
|
<div class="subtitle">3 members</div>
|
||||||
|
</div>
|
||||||
|
<div class="space-tabs">
|
||||||
|
<div class="tab">files</div>
|
||||||
|
<div class="tab">docs</div>
|
||||||
|
<div class="tab active">tables</div>
|
||||||
|
<div class="tab">links</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="body">
|
||||||
|
<div class="table-list">
|
||||||
|
|
||||||
|
<div class="table-card">
|
||||||
|
<div class="table-card-header">
|
||||||
|
<div class="table-card-name">availability</div>
|
||||||
|
<div class="table-card-meta">edited today</div>
|
||||||
|
</div>
|
||||||
|
<div class="mini-table">
|
||||||
|
<table>
|
||||||
|
<colgroup>
|
||||||
|
<col style="width:35%">
|
||||||
|
<col style="width:22%">
|
||||||
|
<col style="width:22%">
|
||||||
|
<col style="width:21%">
|
||||||
|
</colgroup>
|
||||||
|
<thead>
|
||||||
|
<tr><th>name</th><th>sat</th><th>sun</th><th>notes</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>priya</td><td><span class="check-on"></span></td><td><span class="check-off"></span></td><td>morning</td></tr>
|
||||||
|
<tr><td>jamie</td><td><span class="check-on"></span></td><td><span class="check-on"></span></td><td></td></tr>
|
||||||
|
<tr><td>you</td><td><span class="check-on"></span></td><td><span class="check-off"></span></td><td>am only</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-card">
|
||||||
|
<div class="table-card-header">
|
||||||
|
<div class="table-card-name">gear to bring</div>
|
||||||
|
<div class="table-card-meta">edited 3 days ago</div>
|
||||||
|
</div>
|
||||||
|
<div class="mini-table">
|
||||||
|
<table>
|
||||||
|
<colgroup>
|
||||||
|
<col style="width:42%">
|
||||||
|
<col style="width:30%">
|
||||||
|
<col style="width:28%">
|
||||||
|
</colgroup>
|
||||||
|
<thead>
|
||||||
|
<tr><th>item</th><th>who</th><th>packed</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>rope</td><td>jamie</td><td><span class="check-on"></span></td></tr>
|
||||||
|
<tr><td>crash pad</td><td>priya</td><td><span class="check-off"></span></td></tr>
|
||||||
|
<tr><td>first aid</td><td>you</td><td><span class="check-off"></span></td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="add-table-btn">+ new table</button>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bottom-nav">
|
||||||
|
<div class="nav-item"><div style="width:16px;height:16px;display:flex;align-items:center;justify-content:center;"><svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="1" y="1" width="12" height="10" rx="2" stroke="#2e2e2e" stroke-width="0.8"/></svg></div><div class="nav-label">chats</div></div>
|
||||||
|
<div class="nav-item"><div style="width:16px;height:16px;display:flex;align-items:center;justify-content:center;"><svg width="14" height="14" viewBox="0 0 14 14" fill="none"><circle cx="7" cy="5" r="2.5" stroke="#2e2e2e" stroke-width="0.8"/><path d="M1.5 13c0-3 2.5-4.5 5.5-4.5s5.5 1.5 5.5 4.5" stroke="#2e2e2e" stroke-width="0.8" stroke-linecap="round"/></svg></div><div class="nav-label">contacts</div></div>
|
||||||
|
<div class="nav-item active"><div style="width:16px;height:16px;display:flex;align-items:center;justify-content:center;"><svg width="14" height="14" viewBox="0 0 14 14" fill="none"><circle cx="7" cy="7" r="5.5" stroke="#e8e8e8" stroke-width="0.8"/><circle cx="7" cy="7" r="2" stroke="#e8e8e8" stroke-width="0.8"/></svg></div><div class="nav-label">spaces</div></div>
|
||||||
|
<div class="nav-item"><div style="width:16px;height:16px;display:flex;align-items:center;justify-content:center;"><svg width="14" height="14" viewBox="0 0 14 14" fill="none"><circle cx="7" cy="7" r="5.5" stroke="#2e2e2e" stroke-width="0.8"/><path d="M7 4v3l2 2" stroke="#2e2e2e" stroke-width="0.8" stroke-linecap="round"/></svg></div><div class="nav-label">settings</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SCREEN 2: open table -->
|
||||||
|
<div class="phone">
|
||||||
|
<div class="phone-inner">
|
||||||
|
<div class="status-bar"><span>09:41</span><span>▪▪▪</span></div>
|
||||||
|
<div class="screen-label">table view</div>
|
||||||
|
|
||||||
|
<div class="table-header">
|
||||||
|
<div>
|
||||||
|
<div class="eyebrow">climbing crew · space</div>
|
||||||
|
<div class="table-title">availability</div>
|
||||||
|
</div>
|
||||||
|
<div class="table-actions">
|
||||||
|
<div class="tbl-action">filter</div>
|
||||||
|
<div class="tbl-action">+ col</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-toolbar">
|
||||||
|
<div class="filter-chip active">all rows</div>
|
||||||
|
<div class="filter-chip">sat only</div>
|
||||||
|
<div class="filter-chip">available</div>
|
||||||
|
<div class="filter-chip">missing</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="full-table-wrap">
|
||||||
|
<table class="full-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:72px"><div class="col-header-inner">name<span class="type-badge">txt</span></div></th>
|
||||||
|
<th style="width:52px"><div class="col-header-inner">sat<span class="type-badge">bool</span></div></th>
|
||||||
|
<th style="width:52px"><div class="col-header-inner">sun<span class="type-badge">bool</span></div></th>
|
||||||
|
<th style="width:52px"><div class="col-header-inner">mon<span class="type-badge">bool</span></div></th>
|
||||||
|
<th style="width:80px"><div class="col-header-inner">notes<span class="type-badge">txt</span></div></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr class="selected">
|
||||||
|
<td>priya</td>
|
||||||
|
<td><span class="check-on"></span></td>
|
||||||
|
<td><span class="check-off"></span></td>
|
||||||
|
<td><span class="check-on"></span></td>
|
||||||
|
<td>morning only</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>jamie</td>
|
||||||
|
<td><span class="check-on"></span></td>
|
||||||
|
<td><span class="check-on"></span></td>
|
||||||
|
<td><span class="check-off"></span></td>
|
||||||
|
<td></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>you</td>
|
||||||
|
<td><span class="check-on"></span></td>
|
||||||
|
<td><span class="check-off"></span></td>
|
||||||
|
<td><span class="check-on"></span></td>
|
||||||
|
<td>am only</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="color:#2a2a2a;font-style:italic" colspan="5">+ add row</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="padding:8px 18px;border-top:0.5px solid #161616;display:flex;justify-content:space-between;align-items:center;">
|
||||||
|
<div style="font-size:9px;color:#2a2a2a;letter-spacing:0.06em;">3 rows · 5 columns</div>
|
||||||
|
<div style="font-size:9px;color:#2a2a2a;letter-spacing:0.06em;">synced · e2e</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bottom-nav">
|
||||||
|
<div class="nav-item"><div style="width:16px;height:16px;display:flex;align-items:center;justify-content:center;"><svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="1" y="1" width="12" height="10" rx="2" stroke="#2e2e2e" stroke-width="0.8"/></svg></div><div class="nav-label">chats</div></div>
|
||||||
|
<div class="nav-item"><div style="width:16px;height:16px;display:flex;align-items:center;justify-content:center;"><svg width="14" height="14" viewBox="0 0 14 14" fill="none"><circle cx="7" cy="5" r="2.5" stroke="#2e2e2e" stroke-width="0.8"/><path d="M1.5 13c0-3 2.5-4.5 5.5-4.5s5.5 1.5 5.5 4.5" stroke="#2e2e2e" stroke-width="0.8" stroke-linecap="round"/></svg></div><div class="nav-label">contacts</div></div>
|
||||||
|
<div class="nav-item active"><div style="width:16px;height:16px;display:flex;align-items:center;justify-content:center;"><svg width="14" height="14" viewBox="0 0 14 14" fill="none"><circle cx="7" cy="7" r="5.5" stroke="#e8e8e8" stroke-width="0.8"/><circle cx="7" cy="7" r="2" stroke="#e8e8e8" stroke-width="0.8"/></svg></div><div class="nav-label">spaces</div></div>
|
||||||
|
<div class="nav-item"><div style="width:16px;height:16px;display:flex;align-items:center;justify-content:center;"><svg width="14" height="14" viewBox="0 0 14 14" fill="none"><circle cx="7" cy="7" r="5.5" stroke="#2e2e2e" stroke-width="0.8"/><path d="M7 4v3l2 2" stroke="#2e2e2e" stroke-width="0.8" stroke-linecap="round"/></svg></div><div class="nav-label">settings</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SCREEN 3: new table -->
|
||||||
|
<div class="phone">
|
||||||
|
<div class="phone-inner">
|
||||||
|
<div class="status-bar"><span>09:41</span><span>▪▪▪</span></div>
|
||||||
|
<div class="screen-label">new table</div>
|
||||||
|
|
||||||
|
<div class="sheet-header">
|
||||||
|
<div class="eyebrow">climbing crew · space</div>
|
||||||
|
<div class="sheet-title">new table</div>
|
||||||
|
<div class="sheet-sub">define columns · start typing</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sheet-body">
|
||||||
|
|
||||||
|
<div class="field-row">
|
||||||
|
<div class="field-label">table name</div>
|
||||||
|
<div class="field-input">expenses</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="template-row">
|
||||||
|
<div class="template-label">start from template</div>
|
||||||
|
<div class="template-chips">
|
||||||
|
<div class="template-chip selected">availability</div>
|
||||||
|
<div class="template-chip">shopping list</div>
|
||||||
|
<div class="template-chip">expenses</div>
|
||||||
|
<div class="template-chip">tasks</div>
|
||||||
|
<div class="template-chip">blank</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-row">
|
||||||
|
<div class="field-label">columns</div>
|
||||||
|
<div class="col-defs">
|
||||||
|
<div class="col-def-row">
|
||||||
|
<div class="col-drag">⠿</div>
|
||||||
|
<input class="col-name-input" value="item" readonly>
|
||||||
|
<select class="col-type-select"><option>text</option></select>
|
||||||
|
</div>
|
||||||
|
<div class="col-def-row">
|
||||||
|
<div class="col-drag">⠿</div>
|
||||||
|
<input class="col-name-input" value="amount" readonly>
|
||||||
|
<select class="col-type-select"><option>number</option></select>
|
||||||
|
</div>
|
||||||
|
<div class="col-def-row">
|
||||||
|
<div class="col-drag">⠿</div>
|
||||||
|
<input class="col-name-input" value="paid by" readonly>
|
||||||
|
<select class="col-type-select"><option>text</option></select>
|
||||||
|
</div>
|
||||||
|
<div class="col-def-row">
|
||||||
|
<div class="col-drag">⠿</div>
|
||||||
|
<input class="col-name-input" value="settled" readonly>
|
||||||
|
<select class="col-type-select"><option>bool</option></select>
|
||||||
|
</div>
|
||||||
|
<div class="add-col-row">
|
||||||
|
<span style="color:#222">+</span>
|
||||||
|
<span>add column</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="create-btn">create table</button>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="purple_200">#FFBB86FC</color>
|
||||||
|
<color name="purple_500">#FF6200EE</color>
|
||||||
|
<color name="purple_700">#FF3700B3</color>
|
||||||
|
<color name="teal_200">#FF03DAC5</color>
|
||||||
|
<color name="teal_700">#FF018786</color>
|
||||||
|
<color name="black">#FF000000</color>
|
||||||
|
<color name="white">#FFFFFFFF</color>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<resources>
|
||||||
|
<string name="app_name">Chatapp</string>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
|
||||||
|
<style name="Theme.Chatapp" parent="android:Theme.Material.Light.NoActionBar" />
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?><!--
|
||||||
|
Sample backup rules file; uncomment and customize as necessary.
|
||||||
|
See https://developer.android.com/guide/topics/data/autobackup
|
||||||
|
for details.
|
||||||
|
Note: This file is ignored for devices older than API 31
|
||||||
|
See https://developer.android.com/about/versions/12/backup-restore
|
||||||
|
-->
|
||||||
|
<full-backup-content>
|
||||||
|
<!--
|
||||||
|
<include domain="sharedpref" path="."/>
|
||||||
|
<exclude domain="sharedpref" path="device.xml"/>
|
||||||
|
-->
|
||||||
|
</full-backup-content>
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?><!--
|
||||||
|
Sample data extraction rules file; uncomment and customize as necessary.
|
||||||
|
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
|
||||||
|
for details.
|
||||||
|
-->
|
||||||
|
<data-extraction-rules>
|
||||||
|
<cloud-backup>
|
||||||
|
<!-- TODO: Use <include> and <exclude> to control what is backed up.
|
||||||
|
<include .../>
|
||||||
|
<exclude .../>
|
||||||
|
-->
|
||||||
|
</cloud-backup>
|
||||||
|
<!--
|
||||||
|
<device-transfer>
|
||||||
|
<include .../>
|
||||||
|
<exclude .../>
|
||||||
|
</device-transfer>
|
||||||
|
-->
|
||||||
|
</data-extraction-rules>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package dev.zxq5.chatapp.android
|
||||||
|
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
import org.junit.Assert.*
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example local unit test, which will execute on the development machine (host).
|
||||||
|
*
|
||||||
|
* See [testing documentation](http://d.android.com/tools/testing).
|
||||||
|
*/
|
||||||
|
class ExampleUnitTest {
|
||||||
|
@Test
|
||||||
|
fun addition_isCorrect() {
|
||||||
|
assertEquals(4, 2 + 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||||
|
plugins {
|
||||||
|
alias(libs.plugins.android.application) apply false
|
||||||
|
alias(libs.plugins.kotlin.compose) apply false
|
||||||
|
alias(libs.plugins.kotlin.serialization) apply false
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# Project-wide Gradle settings.
|
||||||
|
# IDE (e.g. Android Studio) users:
|
||||||
|
# Gradle settings configured through the IDE *will override*
|
||||||
|
# any settings specified in this file.
|
||||||
|
# For more details on how to configure your build environment visit
|
||||||
|
# http://www.gradle.org/docs/current/userguide/build_environment.html
|
||||||
|
# Specifies the JVM arguments used for the daemon process.
|
||||||
|
# The setting is particularly useful for tweaking memory settings.
|
||||||
|
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||||
|
# When configured, Gradle will run in incubating parallel mode.
|
||||||
|
# This option should only be used with decoupled projects. For more details, visit
|
||||||
|
# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
|
||||||
|
# org.gradle.parallel=true
|
||||||
|
# Kotlin code style for this project: "official" or "obsolete":
|
||||||
|
kotlin.code.style=official
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
#This file is generated by updateDaemonJvm
|
||||||
|
toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect
|
||||||
|
toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect
|
||||||
|
toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect
|
||||||
|
toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect
|
||||||
|
toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/73bcfb608d1fde9fb62e462f834a3299/redirect
|
||||||
|
toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/846ee0d876d26a26f37aa1ce8de73224/redirect
|
||||||
|
toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect
|
||||||
|
toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect
|
||||||
|
toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/9482ddec596298c84656d31d16652665/redirect
|
||||||
|
toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/39701d92e1756bb2f141eb67cd4c660e/redirect
|
||||||
|
toolchainVersion=21
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
[versions]
|
||||||
|
agp = "9.1.0"
|
||||||
|
coreKtx = "1.10.1"
|
||||||
|
junit = "4.13.2"
|
||||||
|
junitVersion = "1.1.5"
|
||||||
|
espressoCore = "3.5.1"
|
||||||
|
kotlinxCoroutinesAndroid = "1.10.2"
|
||||||
|
ktorClientAndroidVersion = "2.3.7"
|
||||||
|
ktorClientContentNegotiation = "3.4.2"
|
||||||
|
ktorClientAndroid = "3.4.2"
|
||||||
|
ktorSerializationKotlinxJson = "3.4.2"
|
||||||
|
kotlinxSerializationJson = "1.10.0"
|
||||||
|
lifecycleRuntimeKtx = "2.6.1"
|
||||||
|
activityCompose = "1.8.0"
|
||||||
|
kotlin = "2.2.10"
|
||||||
|
composeBom = "2024.09.00"
|
||||||
|
foundationLayout = "1.10.6"
|
||||||
|
lifecycleViewmodelCompose = "2.10.0"
|
||||||
|
securityCrypto = "1.1.0"
|
||||||
|
composeIconsExtended = "1.7.0"
|
||||||
|
|
||||||
|
[libraries]
|
||||||
|
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||||
|
androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycleViewmodelCompose" }
|
||||||
|
androidx-security-crypto = { module = "androidx.security:security-crypto", version.ref = "securityCrypto" }
|
||||||
|
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
||||||
|
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
|
||||||
|
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
|
||||||
|
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
|
||||||
|
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
|
||||||
|
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
|
||||||
|
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
|
||||||
|
androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
|
||||||
|
androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
|
||||||
|
androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
|
||||||
|
androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
|
||||||
|
androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
|
||||||
|
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
|
||||||
|
androidx-compose-foundation-layout = { group = "androidx.compose.foundation", name = "foundation-layout", version.ref = "foundationLayout" }
|
||||||
|
androidx-compose-material-icons-extended = { module = "androidx.compose.material:material-icons-extended", version.ref = "composeIconsExtended" }
|
||||||
|
kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinxCoroutinesAndroid" }
|
||||||
|
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
|
||||||
|
ktor-client-android = { module = "io.ktor:ktor-client-android", version.ref = "ktorClientAndroid" }
|
||||||
|
ktor-client-auth = { module = "io.ktor:ktor-client-auth", version.require = "3.4.2" }
|
||||||
|
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktorClientContentNegotiation" }
|
||||||
|
ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktorSerializationKotlinxJson" }
|
||||||
|
|
||||||
|
[plugins]
|
||||||
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
|
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||||
|
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
#Tue Mar 31 13:46:27 BST 2026
|
||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
distributionSha256Sum=b266d5ff6b90eada6dc3b20cb090e3731302e553a27c5d3e4df1f0d76beaff06
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip
|
||||||
|
networkTimeout=10000
|
||||||
|
validateDistributionUrl=true
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
#
|
||||||
|
# Copyright © 2015 the original authors.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: Apache-2.0
|
||||||
|
#
|
||||||
|
|
||||||
|
##############################################################################
|
||||||
|
#
|
||||||
|
# Gradle start up script for POSIX generated by Gradle.
|
||||||
|
#
|
||||||
|
# Important for running:
|
||||||
|
#
|
||||||
|
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||||
|
# noncompliant, but you have some other compliant shell such as ksh or
|
||||||
|
# bash, then to run this script, type that shell name before the whole
|
||||||
|
# command line, like:
|
||||||
|
#
|
||||||
|
# ksh Gradle
|
||||||
|
#
|
||||||
|
# Busybox and similar reduced shells will NOT work, because this script
|
||||||
|
# requires all of these POSIX shell features:
|
||||||
|
# * functions;
|
||||||
|
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||||
|
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||||
|
# * compound commands having a testable exit status, especially «case»;
|
||||||
|
# * various built-in commands including «command», «set», and «ulimit».
|
||||||
|
#
|
||||||
|
# Important for patching:
|
||||||
|
#
|
||||||
|
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||||
|
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||||
|
#
|
||||||
|
# The "traditional" practice of packing multiple parameters into a
|
||||||
|
# space-separated string is a well documented source of bugs and security
|
||||||
|
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||||
|
# options in "$@", and eventually passing that to Java.
|
||||||
|
#
|
||||||
|
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||||
|
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||||
|
# see the in-line comments for details.
|
||||||
|
#
|
||||||
|
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||||
|
# Darwin, MinGW, and NonStop.
|
||||||
|
#
|
||||||
|
# (3) This script is generated from the Groovy template
|
||||||
|
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||||
|
# within the Gradle project.
|
||||||
|
#
|
||||||
|
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||||
|
#
|
||||||
|
##############################################################################
|
||||||
|
|
||||||
|
# Attempt to set APP_HOME
|
||||||
|
|
||||||
|
# Resolve links: $0 may be a link
|
||||||
|
app_path=$0
|
||||||
|
|
||||||
|
# Need this for daisy-chained symlinks.
|
||||||
|
while
|
||||||
|
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||||
|
[ -h "$app_path" ]
|
||||||
|
do
|
||||||
|
ls=$( ls -ld "$app_path" )
|
||||||
|
link=${ls#*' -> '}
|
||||||
|
case $link in #(
|
||||||
|
/*) app_path=$link ;; #(
|
||||||
|
*) app_path=$APP_HOME$link ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# This is normally unused
|
||||||
|
# shellcheck disable=SC2034
|
||||||
|
APP_BASE_NAME=${0##*/}
|
||||||
|
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||||
|
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||||
|
|
||||||
|
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||||
|
MAX_FD=maximum
|
||||||
|
|
||||||
|
warn () {
|
||||||
|
echo "$*"
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
die () {
|
||||||
|
echo
|
||||||
|
echo "$*"
|
||||||
|
echo
|
||||||
|
exit 1
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
# OS specific support (must be 'true' or 'false').
|
||||||
|
cygwin=false
|
||||||
|
msys=false
|
||||||
|
darwin=false
|
||||||
|
nonstop=false
|
||||||
|
case "$( uname )" in #(
|
||||||
|
CYGWIN* ) cygwin=true ;; #(
|
||||||
|
Darwin* ) darwin=true ;; #(
|
||||||
|
MSYS* | MINGW* ) msys=true ;; #(
|
||||||
|
NONSTOP* ) nonstop=true ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
CLASSPATH="\\\"\\\""
|
||||||
|
|
||||||
|
|
||||||
|
# Determine the Java command to use to start the JVM.
|
||||||
|
if [ -n "$JAVA_HOME" ] ; then
|
||||||
|
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||||
|
# IBM's JDK on AIX uses strange locations for the executables
|
||||||
|
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||||
|
else
|
||||||
|
JAVACMD=$JAVA_HOME/bin/java
|
||||||
|
fi
|
||||||
|
if [ ! -x "$JAVACMD" ] ; then
|
||||||
|
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
JAVACMD=java
|
||||||
|
if ! command -v java >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Increase the maximum file descriptors if we can.
|
||||||
|
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||||
|
case $MAX_FD in #(
|
||||||
|
max*)
|
||||||
|
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
MAX_FD=$( ulimit -H -n ) ||
|
||||||
|
warn "Could not query maximum file descriptor limit"
|
||||||
|
esac
|
||||||
|
case $MAX_FD in #(
|
||||||
|
'' | soft) :;; #(
|
||||||
|
*)
|
||||||
|
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
ulimit -n "$MAX_FD" ||
|
||||||
|
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Collect all arguments for the java command, stacking in reverse order:
|
||||||
|
# * args from the command line
|
||||||
|
# * the main class name
|
||||||
|
# * -classpath
|
||||||
|
# * -D...appname settings
|
||||||
|
# * --module-path (only if needed)
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||||
|
|
||||||
|
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||||
|
if "$cygwin" || "$msys" ; then
|
||||||
|
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||||
|
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||||
|
|
||||||
|
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||||
|
|
||||||
|
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||||
|
for arg do
|
||||||
|
if
|
||||||
|
case $arg in #(
|
||||||
|
-*) false ;; # don't mess with options #(
|
||||||
|
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||||
|
[ -e "$t" ] ;; #(
|
||||||
|
*) false ;;
|
||||||
|
esac
|
||||||
|
then
|
||||||
|
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||||
|
fi
|
||||||
|
# Roll the args list around exactly as many times as the number of
|
||||||
|
# args, so each arg winds up back in the position where it started, but
|
||||||
|
# possibly modified.
|
||||||
|
#
|
||||||
|
# NB: a `for` loop captures its iteration list before it begins, so
|
||||||
|
# changing the positional parameters here affects neither the number of
|
||||||
|
# iterations, nor the values presented in `arg`.
|
||||||
|
shift # remove old arg
|
||||||
|
set -- "$@" "$arg" # push replacement arg
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||||
|
|
||||||
|
# Collect all arguments for the java command:
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||||
|
# and any embedded shellness will be escaped.
|
||||||
|
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||||
|
# treated as '${Hostname}' itself on the command line.
|
||||||
|
|
||||||
|
set -- \
|
||||||
|
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||||
|
-classpath "$CLASSPATH" \
|
||||||
|
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||||
|
"$@"
|
||||||
|
|
||||||
|
# Stop when "xargs" is not available.
|
||||||
|
if ! command -v xargs >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "xargs is not available"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Use "xargs" to parse quoted args.
|
||||||
|
#
|
||||||
|
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||||
|
#
|
||||||
|
# In Bash we could simply go:
|
||||||
|
#
|
||||||
|
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||||
|
# set -- "${ARGS[@]}" "$@"
|
||||||
|
#
|
||||||
|
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||||
|
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||||
|
# character that might be a shell metacharacter, then use eval to reverse
|
||||||
|
# that process (while maintaining the separation between arguments), and wrap
|
||||||
|
# the whole thing up as a single "set" statement.
|
||||||
|
#
|
||||||
|
# This will of course break if any of these variables contains a newline or
|
||||||
|
# an unmatched quote.
|
||||||
|
#
|
||||||
|
|
||||||
|
eval "set -- $(
|
||||||
|
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||||
|
xargs -n1 |
|
||||||
|
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||||
|
tr '\n' ' '
|
||||||
|
)" '"$@"'
|
||||||
|
|
||||||
|
exec "$JAVACMD" "$@"
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
@rem
|
||||||
|
@rem Copyright 2015 the original author or authors.
|
||||||
|
@rem
|
||||||
|
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
@rem you may not use this file except in compliance with the License.
|
||||||
|
@rem You may obtain a copy of the License at
|
||||||
|
@rem
|
||||||
|
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
@rem
|
||||||
|
@rem Unless required by applicable law or agreed to in writing, software
|
||||||
|
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
@rem See the License for the specific language governing permissions and
|
||||||
|
@rem limitations under the License.
|
||||||
|
@rem
|
||||||
|
@rem SPDX-License-Identifier: Apache-2.0
|
||||||
|
@rem
|
||||||
|
|
||||||
|
@if "%DEBUG%"=="" @echo off
|
||||||
|
@rem ##########################################################################
|
||||||
|
@rem
|
||||||
|
@rem Gradle startup script for Windows
|
||||||
|
@rem
|
||||||
|
@rem ##########################################################################
|
||||||
|
|
||||||
|
@rem Set local scope for the variables with windows NT shell
|
||||||
|
if "%OS%"=="Windows_NT" setlocal
|
||||||
|
|
||||||
|
set DIRNAME=%~dp0
|
||||||
|
if "%DIRNAME%"=="" set DIRNAME=.
|
||||||
|
@rem This is normally unused
|
||||||
|
set APP_BASE_NAME=%~n0
|
||||||
|
set APP_HOME=%DIRNAME%
|
||||||
|
|
||||||
|
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||||
|
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||||
|
|
||||||
|
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||||
|
|
||||||
|
@rem Find java.exe
|
||||||
|
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||||
|
|
||||||
|
set JAVA_EXE=java.exe
|
||||||
|
%JAVA_EXE% -version >NUL 2>&1
|
||||||
|
if %ERRORLEVEL% equ 0 goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:findJavaFromJavaHome
|
||||||
|
set JAVA_HOME=%JAVA_HOME:"=%
|
||||||
|
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||||
|
|
||||||
|
if exist "%JAVA_EXE%" goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:execute
|
||||||
|
@rem Setup the command line
|
||||||
|
|
||||||
|
set CLASSPATH=
|
||||||
|
|
||||||
|
|
||||||
|
@rem Execute Gradle
|
||||||
|
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
||||||
|
|
||||||
|
:end
|
||||||
|
@rem End local scope for the variables with windows NT shell
|
||||||
|
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||||
|
|
||||||
|
:fail
|
||||||
|
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||||
|
rem the _cmd.exe /c_ return code!
|
||||||
|
set EXIT_CODE=%ERRORLEVEL%
|
||||||
|
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||||
|
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||||
|
exit /b %EXIT_CODE%
|
||||||
|
|
||||||
|
:mainEnd
|
||||||
|
if "%OS%"=="Windows_NT" endlocal
|
||||||
|
|
||||||
|
:omega
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
pluginManagement {
|
||||||
|
repositories {
|
||||||
|
google {
|
||||||
|
content {
|
||||||
|
includeGroupByRegex("com\\.android.*")
|
||||||
|
includeGroupByRegex("com\\.google.*")
|
||||||
|
includeGroupByRegex("androidx.*")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mavenCentral()
|
||||||
|
gradlePluginPortal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
plugins {
|
||||||
|
id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
|
||||||
|
}
|
||||||
|
dependencyResolutionManagement {
|
||||||
|
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rootProject.name = "Chatapp"
|
||||||
|
include(":app")
|
||||||
|
|
||||||
@@ -9,6 +9,7 @@ chrono = { version = "0.4.42", features = ["serde"] }
|
|||||||
dotenv = "0.15.0"
|
dotenv = "0.15.0"
|
||||||
futures-util = "0.3.31"
|
futures-util = "0.3.31"
|
||||||
image = "0.25.8"
|
image = "0.25.8"
|
||||||
|
jsonwebtoken = { version = "10.3.0", features = ["rust_crypto"] }
|
||||||
rand = "0.9.2"
|
rand = "0.9.2"
|
||||||
redis = { version = "0.25.4", features = ["tokio-comp"] }
|
redis = { version = "0.25.4", features = ["tokio-comp"] }
|
||||||
reqwest = { version = "0.12.23", features = ["json"] }
|
reqwest = { version = "0.12.23", features = ["json"] }
|
||||||
@@ -22,4 +23,5 @@ sha2 = "0.10.9"
|
|||||||
sqlx = { version = "0.7.4", features = ["macros", "time"] }
|
sqlx = { version = "0.7.4", features = ["macros", "time"] }
|
||||||
tokio = { version = "1.47.1", features = ["full"] }
|
tokio = { version = "1.47.1", features = ["full"] }
|
||||||
totp-rs = { version = "5.7.0", features = ["gen_secret", "qr", "rand"] }
|
totp-rs = { version = "5.7.0", features = ["gen_secret", "qr", "rand"] }
|
||||||
|
tracing = "0.1.44"
|
||||||
uuid = { version = "1.18.1", features = ["v4"] }
|
uuid = { version = "1.18.1", features = ["v4"] }
|
||||||
|
|||||||
@@ -3,15 +3,17 @@ secret_key = "yYhvCGnRh/TrcHtB8sZqCFifrVmJxoKFLBYw/WWBZeU="
|
|||||||
address = "0.0.0.0"
|
address = "0.0.0.0"
|
||||||
port = 8000
|
port = 8000
|
||||||
|
|
||||||
[default.databases.postgres_db]
|
[debug.databases.postgres_db]
|
||||||
url = "postgresql://chatapp:chatapp@100.118.108.58:5432/chatapp"
|
url = "postgresql://chatapp:chatapp@100.118.108.58:5432/chatapp_dev"
|
||||||
|
|
||||||
[default.databases.redis_cache]
|
[release.databases.postgres_db]
|
||||||
url = "redis://chatapp_redis:6379"
|
url = "postgresql://chatapp:chatapp@100.118.108.58:5432/chatapp_prod"
|
||||||
|
|
||||||
[debug.databases.redis_cache]
|
[debug.databases.redis_cache]
|
||||||
url = "redis://localhost:6379"
|
url = "redis://localhost:6379"
|
||||||
|
|
||||||
|
[release.databases.redis_cache]
|
||||||
|
url = "redis://chatapp_redis:6379"
|
||||||
|
|
||||||
[default] # run inside a docker container or pod
|
[default] # run inside a docker container or pod
|
||||||
address = "0.0.0.0"
|
address = "0.0.0.0"
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- Add migration script here
|
||||||
|
TRUNCATE TABLE users CASCADE;
|
||||||
|
|
||||||
|
ALTER TABLE users DROP COLUMN password;
|
||||||
|
ALTER TABLE users ADD COLUMN pass_hash VARCHAR(255) NOT NULL;
|
||||||
|
ALTER TABLE users ADD CONSTRAINT users_username_unique UNIQUE (username);
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
-- Add migration script here
|
||||||
|
CREATE TYPE status AS ENUM ('pending', 'accepted', 'blocked');
|
||||||
|
|
||||||
|
CREATE TABLE relationships (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
from_user INTEGER NOT NULL REFERENCES users(id),
|
||||||
|
to_user INTEGER NOT NULL REFERENCES users(id),
|
||||||
|
status status NOT NULL DEFAULT 'pending',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
CONSTRAINT no_self_relationship CHECK (from_user != to_user),
|
||||||
|
CONSTRAINT unique_relationship UNIQUE (from_user, to_user)
|
||||||
|
);
|
||||||
@@ -1,3 +1,8 @@
|
|||||||
|
use argon2::{
|
||||||
|
Argon2, PasswordHash, PasswordHasher, PasswordVerifier,
|
||||||
|
password_hash::{SaltString, rand_core::OsRng},
|
||||||
|
};
|
||||||
|
use jsonwebtoken::{EncodingKey, Header, encode};
|
||||||
use rocket::{
|
use rocket::{
|
||||||
http::{CookieJar, Status},
|
http::{CookieJar, Status},
|
||||||
response::{Redirect, status::BadRequest},
|
response::{Redirect, status::BadRequest},
|
||||||
@@ -9,7 +14,11 @@ use rocket_dyn_templates::{Template, context};
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::{auth::session::Session, db::Postgres};
|
use crate::{
|
||||||
|
auth::session::{Claims, Session, TokenScope},
|
||||||
|
db::Postgres,
|
||||||
|
user::User,
|
||||||
|
};
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize)]
|
#[derive(Serialize, Deserialize)]
|
||||||
pub struct SignupCredentials {
|
pub struct SignupCredentials {
|
||||||
@@ -25,6 +34,12 @@ pub struct LoginCredentials {
|
|||||||
pub password: String,
|
pub password: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
pub struct AuthResponse {
|
||||||
|
pub token: String,
|
||||||
|
pub totp_required: bool,
|
||||||
|
}
|
||||||
|
|
||||||
#[get("/signup")]
|
#[get("/signup")]
|
||||||
pub async fn signup_page() -> Template {
|
pub async fn signup_page() -> Template {
|
||||||
Template::render("signup", context!())
|
Template::render("signup", context!())
|
||||||
@@ -35,35 +50,38 @@ pub async fn signup(
|
|||||||
cred: Json<SignupCredentials>,
|
cred: Json<SignupCredentials>,
|
||||||
jar: &CookieJar<'_>,
|
jar: &CookieJar<'_>,
|
||||||
mut db: Connection<Postgres>,
|
mut db: Connection<Postgres>,
|
||||||
) -> Result<Redirect, BadRequest<String>> {
|
) -> Result<Json<AuthResponse>, Status> {
|
||||||
println!("phase 1 {}", cred.access_token);
|
let token_id = AccessToken::validate(&cred.access_token, &mut db)
|
||||||
let token_id = AccessToken::validate(&cred.access_token, &mut db).await?;
|
.await
|
||||||
|
.map_err(|_| Status::Unauthorized)?;
|
||||||
|
|
||||||
|
let salt = SaltString::generate(&mut OsRng);
|
||||||
|
let hashed = Argon2::default()
|
||||||
|
.hash_password(cred.password.as_bytes(), &salt)
|
||||||
|
.map_err(|_| Status::InternalServerError)?
|
||||||
|
.to_string();
|
||||||
|
|
||||||
println!("phase 2");
|
|
||||||
let result = sqlx::query!(
|
let result = sqlx::query!(
|
||||||
"INSERT INTO users (email, username, password) VALUES ($1, $2, $3) RETURNING id",
|
"INSERT INTO users (email, username, pass_hash) VALUES ($1, $2, $3) RETURNING id",
|
||||||
cred.email,
|
cred.email,
|
||||||
cred.username,
|
cred.username,
|
||||||
cred.password
|
hashed,
|
||||||
)
|
)
|
||||||
.fetch_one(&mut **db)
|
.fetch_one(&mut **db)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| BadRequest(String::from("Failed to create user")))?;
|
.map_err(|_| Status::InternalServerError)?;
|
||||||
|
|
||||||
println!("phase 3");
|
let jwt = Claims::new(result.id as usize, TokenScope::Full).encode();
|
||||||
let session = Session::new(result.id as usize);
|
|
||||||
if let Err(e) = session.commit(&mut db).await {
|
|
||||||
eprintln!("Failed to create session: {}", e);
|
|
||||||
return Err(BadRequest(String::from("Failed to create session")));
|
|
||||||
}
|
|
||||||
|
|
||||||
println!("phase 4");
|
token_id
|
||||||
jar.add_private(("session", session.token));
|
.use_token(&mut db)
|
||||||
|
.await
|
||||||
|
.expect("unable to use access code");
|
||||||
|
|
||||||
token_id.use_token(&mut db).await?;
|
Ok(Json(AuthResponse {
|
||||||
|
token: jwt,
|
||||||
println!("phase 5");
|
totp_required: false,
|
||||||
Ok(Redirect::to("/chat"))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("/login")]
|
#[get("/login")]
|
||||||
@@ -74,29 +92,40 @@ pub async fn login_page() -> Template {
|
|||||||
#[post("/login", data = "<cred>")]
|
#[post("/login", data = "<cred>")]
|
||||||
pub async fn login(
|
pub async fn login(
|
||||||
mut db: Connection<Postgres>,
|
mut db: Connection<Postgres>,
|
||||||
jar: &CookieJar<'_>,
|
|
||||||
cred: Json<LoginCredentials>,
|
cred: Json<LoginCredentials>,
|
||||||
) -> Result<Redirect, Status> {
|
) -> Result<Json<AuthResponse>, Status> {
|
||||||
if let Ok(row) = sqlx::query!(
|
println!("e");
|
||||||
"SELECT id FROM users WHERE username = $1 AND password = $2",
|
let row = sqlx::query!(
|
||||||
|
"SELECT id, pass_hash, twofa_enabled FROM users WHERE username = $1",
|
||||||
cred.username,
|
cred.username,
|
||||||
cred.password,
|
|
||||||
)
|
)
|
||||||
.fetch_one(&mut **db)
|
.fetch_one(&mut **db)
|
||||||
.await
|
.await
|
||||||
{
|
.map_err(|_| Status::Unauthorized)?;
|
||||||
let session = Session::new(row.id as usize);
|
|
||||||
if let Err(e) = session.commit(&mut db).await {
|
|
||||||
eprintln!("Failed to create session: {}", e);
|
|
||||||
return Err(Status::InternalServerError);
|
|
||||||
}
|
|
||||||
|
|
||||||
jar.add_private(("session", session.token));
|
println!("ok");
|
||||||
return Ok(Redirect::to("/chat"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: implement actual login logic, e.g. verify password and generate token
|
// verify password as before
|
||||||
Err(Status::Unauthorized)
|
let parsed_hash = PasswordHash::new(&row.pass_hash).map_err(|_| Status::InternalServerError)?;
|
||||||
|
Argon2::default()
|
||||||
|
.verify_password(cred.password.as_bytes(), &parsed_hash)
|
||||||
|
.map_err(|_| Status::Unauthorized)?;
|
||||||
|
|
||||||
|
println!("ok2");
|
||||||
|
|
||||||
|
let user_id = row.id as usize;
|
||||||
|
|
||||||
|
// issue either a partial or full token depending on 2FA status
|
||||||
|
let (session, totp_required) = if row.twofa_enabled {
|
||||||
|
(Claims::new(user_id, TokenScope::TotpPending), true)
|
||||||
|
} else {
|
||||||
|
(Claims::new(user_id, TokenScope::Full), false)
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Json(AuthResponse {
|
||||||
|
token: session.encode(),
|
||||||
|
totp_required,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -149,7 +178,7 @@ impl AccessToken {
|
|||||||
pub async fn validate(
|
pub async fn validate(
|
||||||
token: &str,
|
token: &str,
|
||||||
db: &mut Connection<Postgres>,
|
db: &mut Connection<Postgres>,
|
||||||
) -> Result<AccessToken, BadRequest<String>> {
|
) -> Result<AccessToken, String> {
|
||||||
match sqlx::query!(
|
match sqlx::query!(
|
||||||
"SELECT id FROM access_codes
|
"SELECT id FROM access_codes
|
||||||
WHERE code = $1
|
WHERE code = $1
|
||||||
@@ -165,18 +194,18 @@ impl AccessToken {
|
|||||||
id: row.id,
|
id: row.id,
|
||||||
_code: token.to_string(),
|
_code: token.to_string(),
|
||||||
}),
|
}),
|
||||||
Err(_) => Err(BadRequest(String::from("Invalid or Expired token!"))),
|
Err(_) => Err(String::from("Invalid or Expired token!")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn use_token(&self, db: &mut Connection<Postgres>) -> Result<(), BadRequest<String>> {
|
pub async fn use_token(&self, db: &mut Connection<Postgres>) -> Result<(), String> {
|
||||||
sqlx::query!(
|
sqlx::query!(
|
||||||
"UPDATE access_codes SET uses = uses + 1 WHERE id = $1",
|
"UPDATE access_codes SET uses = uses + 1 WHERE id = $1",
|
||||||
self.id
|
self.id
|
||||||
)
|
)
|
||||||
.execute(&mut ***db)
|
.execute(&mut ***db)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| BadRequest(String::from("Invalid or Expired token!")))?;
|
.map_err(|_| String::from("Invalid or Expired token!"))?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
pub mod account;
|
pub mod account;
|
||||||
|
pub mod profile;
|
||||||
pub mod session;
|
pub mod session;
|
||||||
pub mod two_factor;
|
pub mod two_factor;
|
||||||
|
|
||||||
pub use session::Session;
|
pub use session::Session;
|
||||||
|
|
||||||
pub use account::{generate_invite, invite_page, login, login_page, signup, signup_page};
|
pub use account::{generate_invite, invite_page, login, login_page, signup, signup_page};
|
||||||
pub use two_factor::{confirm_totp, get_totp, mfa_page};
|
pub use profile::{change_display_name, change_password};
|
||||||
|
pub use two_factor::{
|
||||||
|
confirm_totp, disable_totp, get_totp, get_totp_status, mfa_page, verify_totp,
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
use argon2::{
|
||||||
|
Argon2, PasswordHash, PasswordHasher, PasswordVerifier,
|
||||||
|
password_hash::{SaltString, rand_core::OsRng},
|
||||||
|
};
|
||||||
|
use rocket::{http::Status, serde::json::Json};
|
||||||
|
use rocket_db_pools::Connection;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::{auth::Session, db::Postgres, user::User};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct PasswordForm {
|
||||||
|
old_password: String,
|
||||||
|
new_password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[post("/settings/password", data = "<form>")]
|
||||||
|
pub async fn change_password(
|
||||||
|
session: Session,
|
||||||
|
mut db: Connection<Postgres>,
|
||||||
|
form: Json<PasswordForm>,
|
||||||
|
) -> Result<(), Status> {
|
||||||
|
let mut user = User::get_by_id(session.user_id, &mut db)
|
||||||
|
.await
|
||||||
|
.ok_or(Status::NotFound)
|
||||||
|
.inspect_err(|_| {
|
||||||
|
tracing::error!(
|
||||||
|
"Valid session does not have a valid user. ID: {}",
|
||||||
|
session.user_id
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let parsed_hash = PasswordHash::new(&user.pass_hash)
|
||||||
|
.inspect_err(|e| tracing::error!("Failed to parse hash for password! uid:{} {e}", user.id))
|
||||||
|
.map_err(|_| Status::InternalServerError)?;
|
||||||
|
|
||||||
|
Argon2::default()
|
||||||
|
.verify_password(form.old_password.as_bytes(), &parsed_hash)
|
||||||
|
.map_err(|_| Status::Unauthorized)?;
|
||||||
|
|
||||||
|
// old password is correct, so new one can be set.
|
||||||
|
let salt = SaltString::generate(&mut OsRng);
|
||||||
|
let hashed = Argon2::default()
|
||||||
|
.hash_password(form.new_password.as_bytes(), &salt)
|
||||||
|
.inspect_err(|e| tracing::error!("failed to hash password! {e}"))
|
||||||
|
.map_err(|_| Status::InternalServerError)?
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
user.set_pass_hash(hashed, &mut db)
|
||||||
|
.await
|
||||||
|
.inspect_err(|e| tracing::error!("{e}"))
|
||||||
|
.map_err(|_| Status::InternalServerError)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Debug, Clone)]
|
||||||
|
pub struct DisplayNameForm {
|
||||||
|
pub display_name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[post("/settings/display_name", data = "<new>")]
|
||||||
|
pub async fn change_display_name(
|
||||||
|
session: Session,
|
||||||
|
mut db: Connection<Postgres>,
|
||||||
|
new: Json<DisplayNameForm>,
|
||||||
|
) -> Result<(), Status> {
|
||||||
|
let mut user = User::get_by_id(session.user_id, &mut db)
|
||||||
|
.await
|
||||||
|
.ok_or(Status::NotFound)
|
||||||
|
.inspect_err(|_| {
|
||||||
|
tracing::error!(
|
||||||
|
"Valid session does not have a valid user. ID: {}",
|
||||||
|
session.user_id
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
user.set_display_name(new.display_name.clone(), &mut db)
|
||||||
|
.await
|
||||||
|
.inspect_err(|e| tracing::error!("{e}"))
|
||||||
|
.map_err(|_| Status::InternalServerError)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::{
|
||||||
|
sync::LazyLock,
|
||||||
|
time::{SystemTime, UNIX_EPOCH},
|
||||||
|
};
|
||||||
|
|
||||||
|
use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation, decode, encode};
|
||||||
use rand::Rng;
|
use rand::Rng;
|
||||||
use rocket::{
|
use rocket::{
|
||||||
Request,
|
Request,
|
||||||
@@ -7,73 +11,99 @@ use rocket::{
|
|||||||
request::{self, FromRequest, Outcome},
|
request::{self, FromRequest, Outcome},
|
||||||
};
|
};
|
||||||
use rocket_db_pools::Connection;
|
use rocket_db_pools::Connection;
|
||||||
use sha2::{Digest, Sha256};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sha2::{Digest, Sha256, digest::block_buffer::Lazy};
|
||||||
use sqlx::postgres::PgQueryResult;
|
use sqlx::postgres::PgQueryResult;
|
||||||
|
|
||||||
use crate::db::Postgres;
|
use crate::db::Postgres;
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
static JWT_SECRET: LazyLock<String> = LazyLock::new(|| std::env::var("JWT_SECRET").unwrap());
|
||||||
pub struct Session {
|
|
||||||
pub token: String,
|
#[derive(Serialize, Deserialize, PartialEq, Debug, Clone, Copy)]
|
||||||
pub user_id: usize,
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum TokenScope {
|
||||||
|
Full,
|
||||||
|
TotpPending,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Session {
|
pub struct Session {
|
||||||
pub fn new(user_id: usize) -> Self {
|
pub user_id: usize,
|
||||||
let current_time = SystemTime::now().duration_since(UNIX_EPOCH).unwrap();
|
|
||||||
let random: u32 = rand::rng().random();
|
|
||||||
let token = format!("{}-{}", current_time.as_secs(), random);
|
|
||||||
let hashed = format!("{:x}", Sha256::digest(token.as_bytes()));
|
|
||||||
Self {
|
|
||||||
token: hashed,
|
|
||||||
user_id,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn commit(
|
|
||||||
&self,
|
|
||||||
db: &mut Connection<Postgres>,
|
|
||||||
) -> Result<PgQueryResult, sqlx::Error> {
|
|
||||||
sqlx::query!(
|
|
||||||
"INSERT INTO sessions (user_id, token) VALUES ($1, $2)",
|
|
||||||
self.user_id as i32,
|
|
||||||
self.token,
|
|
||||||
)
|
|
||||||
.execute(&mut ***db)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[rocket::async_trait]
|
#[rocket::async_trait]
|
||||||
impl<'r> FromRequest<'r> for Session {
|
impl<'r> FromRequest<'r> for Session {
|
||||||
type Error = ();
|
type Error = ();
|
||||||
|
|
||||||
async fn from_request(request: &'r Request<'_>) -> request::Outcome<Self, Self::Error> {
|
async fn from_request(req: &'r Request<'_>) -> Outcome<Self, Self::Error> {
|
||||||
if let Some(c) = request.cookies().get_private("session") {
|
match Claims::from_request(req).await {
|
||||||
let mut pool = match request.guard::<Connection<Postgres>>().await {
|
Outcome::Success(user) if user.scope == TokenScope::Full => Outcome::Success(Session {
|
||||||
Outcome::Success(pool) => pool,
|
user_id: user.sub as usize,
|
||||||
_ => return Outcome::Error((Status::Unauthorized, ())),
|
}),
|
||||||
};
|
Outcome::Success(_) => {
|
||||||
|
eprintln!("warning: user with scope other than Full attempted to access session");
|
||||||
let value = c.value();
|
Outcome::Error((Status::Forbidden, ()))
|
||||||
let result = sqlx::query!(
|
}
|
||||||
"SELECT user_id, token FROM sessions WHERE token = $1 AND expires_at > NOW()",
|
Outcome::Error(err) => {
|
||||||
value
|
eprintln!("Session request guard failed: {:?}", err);
|
||||||
)
|
Outcome::Error(err)
|
||||||
.fetch_optional(&mut **pool)
|
}
|
||||||
.await
|
_ => unreachable!("forward should never be called"),
|
||||||
.expect("query failed!");
|
}
|
||||||
|
}
|
||||||
if let Some(session) = result {
|
}
|
||||||
Outcome::Success(Self {
|
|
||||||
user_id: session.user_id as usize,
|
#[derive(Serialize, Deserialize)]
|
||||||
token: session.token,
|
pub struct Claims {
|
||||||
})
|
pub sub: i32,
|
||||||
} else {
|
pub exp: usize,
|
||||||
Outcome::Error((Status::Unauthorized, ()))
|
pub scope: TokenScope,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Claims {
|
||||||
|
pub fn new(user_id: usize, scope: TokenScope) -> Self {
|
||||||
|
Self {
|
||||||
|
sub: user_id as i32,
|
||||||
|
exp: (SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_secs()
|
||||||
|
+ 3600) as usize,
|
||||||
|
scope,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn encode(&self) -> String {
|
||||||
|
encode(
|
||||||
|
&Header::default(),
|
||||||
|
self,
|
||||||
|
&EncodingKey::from_secret(JWT_SECRET.as_bytes()),
|
||||||
|
)
|
||||||
|
.expect("unable to encode jwt")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[rocket::async_trait]
|
||||||
|
impl<'r> FromRequest<'r> for Claims {
|
||||||
|
type Error = ();
|
||||||
|
|
||||||
|
async fn from_request(req: &'r Request<'_>) -> Outcome<Self, Self::Error> {
|
||||||
|
let token = req
|
||||||
|
.headers()
|
||||||
|
.get_one("Authorization")
|
||||||
|
.and_then(|v| v.strip_prefix("Bearer "));
|
||||||
|
|
||||||
|
match token {
|
||||||
|
None => Outcome::Error((Status::Unauthorized, ())),
|
||||||
|
Some(t) => {
|
||||||
|
match decode::<Claims>(
|
||||||
|
t,
|
||||||
|
&DecodingKey::from_secret(JWT_SECRET.as_bytes()),
|
||||||
|
&Validation::default(),
|
||||||
|
) {
|
||||||
|
Ok(data) => Outcome::Success(data.claims),
|
||||||
|
Err(_) => Outcome::Error((Status::Unauthorized, ())),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
Outcome::Error((Status::Unauthorized, ()))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,13 @@ use rocket_dyn_templates::{Template, context};
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use totp_rs::{Algorithm, Secret, TOTP};
|
use totp_rs::{Algorithm, Secret, TOTP};
|
||||||
|
|
||||||
use crate::{auth::session::Session, db::Postgres};
|
use crate::{
|
||||||
|
auth::{
|
||||||
|
account::AuthResponse,
|
||||||
|
session::{Claims, Session, TokenScope},
|
||||||
|
},
|
||||||
|
db::Postgres,
|
||||||
|
};
|
||||||
|
|
||||||
// Utility methods
|
// Utility methods
|
||||||
|
|
||||||
@@ -35,25 +41,23 @@ pub async fn mfa_page(_session: Session) -> Template {
|
|||||||
Template::render("2fa", context!())
|
Template::render("2fa", context!())
|
||||||
}
|
}
|
||||||
|
|
||||||
// api
|
|
||||||
|
|
||||||
#[post("/totp", data = "<form>")]
|
#[post("/totp", data = "<form>")]
|
||||||
pub async fn confirm_totp(
|
pub async fn confirm_totp(
|
||||||
mfa: TOTPSecret,
|
mfa: TOTPSecret,
|
||||||
form: Json<TOTPSixDigitCode>,
|
form: Json<TOTPSixDigitCode>,
|
||||||
mut db: Connection<Postgres>,
|
mut db: Connection<Postgres>,
|
||||||
) -> Result<(), status::Custom<&'static str>> {
|
) -> Result<(), status::Custom<&'static str>> {
|
||||||
if form.code.len() != 6 && form.code.parse::<usize>().is_err() {
|
if form.code.len() != 6 || form.code.parse::<u32>().is_err() {
|
||||||
return Err(status::Custom(Status::BadRequest, "Invalid 6-digit code"));
|
return Err(status::Custom(Status::BadRequest, "Invalid 6-digit code"));
|
||||||
}
|
}
|
||||||
|
|
||||||
println!("valid");
|
println!("valid");
|
||||||
|
|
||||||
let totp = totp_gen(mfa.user_id, mfa.secret.as_bytes()).unwrap();
|
let totp = totp_gen(mfa.user_id, mfa.secret.as_bytes())
|
||||||
if !totp.check_current(&form.code.to_string()).unwrap() {
|
.map_err(|_| status::Custom(Status::InternalServerError, "TOTP Error"))?;
|
||||||
return Err(status::Custom(Status::BadRequest, "Invalid 6-digit code"));
|
if !totp.check_current(&form.code).unwrap_or(false) {
|
||||||
|
return Err(status::Custom(Status::BadRequest, "Incorrect code"));
|
||||||
}
|
}
|
||||||
|
|
||||||
println!("correct");
|
println!("correct");
|
||||||
|
|
||||||
if sqlx::query!(
|
if sqlx::query!(
|
||||||
@@ -92,6 +96,13 @@ pub struct TOTPSixDigitCode {
|
|||||||
code: String,
|
code: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum TotpStatus {
|
||||||
|
Enabled,
|
||||||
|
Disabled,
|
||||||
|
}
|
||||||
|
|
||||||
pub struct TOTPSecret {
|
pub struct TOTPSecret {
|
||||||
user_id: usize,
|
user_id: usize,
|
||||||
secret: String,
|
secret: String,
|
||||||
@@ -107,37 +118,53 @@ impl<'r> FromRequest<'r> for TOTPSecret {
|
|||||||
type Error = ();
|
type Error = ();
|
||||||
|
|
||||||
async fn from_request(request: &'r Request<'_>) -> request::Outcome<Self, Self::Error> {
|
async fn from_request(request: &'r Request<'_>) -> request::Outcome<Self, Self::Error> {
|
||||||
|
let auth_header = request.headers().get_one("Authorization");
|
||||||
|
println!(
|
||||||
|
"TOTPSecret guard - Auth header present: {}",
|
||||||
|
auth_header.is_some()
|
||||||
|
);
|
||||||
|
|
||||||
|
let user = try_outcome!(request.guard::<Claims>().await);
|
||||||
|
println!(
|
||||||
|
"TOTPSecret guard - Claims ok, user: {}, scope: {:?}",
|
||||||
|
user.sub, user.scope
|
||||||
|
);
|
||||||
|
|
||||||
|
// only allow full tokens for TOTP setup
|
||||||
|
if user.scope != TokenScope::Full {
|
||||||
|
println!("TOTPSecret guard - rejected, scope is {:?}", user.scope);
|
||||||
|
return Outcome::Error((Status::Forbidden, ()));
|
||||||
|
}
|
||||||
|
|
||||||
let user = try_outcome!(request.guard::<Session>().await);
|
let user = try_outcome!(request.guard::<Session>().await);
|
||||||
let mut pool = match request.guard::<Connection<Postgres>>().await {
|
let mut pool = match request.guard::<Connection<Postgres>>().await {
|
||||||
Outcome::Success(pool) => pool,
|
Outcome::Success(pool) => pool,
|
||||||
_ => return Outcome::Error((Status::Unauthorized, ())),
|
_ => return Outcome::Error((Status::Unauthorized, ())),
|
||||||
};
|
};
|
||||||
|
|
||||||
let (enabled, mut secret) = match sqlx::query!(
|
let row = sqlx::query!(
|
||||||
"SELECT twofa_enabled, totp_secret FROM users WHERE id = $1",
|
"SELECT twofa_enabled, totp_secret FROM users WHERE id = $1",
|
||||||
user.user_id as i32,
|
user.user_id as i32
|
||||||
)
|
)
|
||||||
.fetch_one(&mut **pool)
|
.fetch_one(&mut **pool)
|
||||||
.await
|
.await;
|
||||||
{
|
|
||||||
Ok(row) => (row.twofa_enabled, row.totp_secret),
|
let (enabled, mut secret) = match row {
|
||||||
|
Ok(r) => (r.twofa_enabled, r.totp_secret),
|
||||||
Err(_) => return Outcome::Error((Status::Unauthorized, ())),
|
Err(_) => return Outcome::Error((Status::Unauthorized, ())),
|
||||||
};
|
};
|
||||||
|
|
||||||
if !enabled || secret.is_none() {
|
if secret.is_none() {
|
||||||
secret = Some(Secret::generate_secret().to_string());
|
let new_secret = Secret::generate_secret().to_encoded().to_string();
|
||||||
|
sqlx::query!(
|
||||||
match sqlx::query!(
|
|
||||||
"UPDATE users SET totp_secret = $1 WHERE id = $2",
|
"UPDATE users SET totp_secret = $1 WHERE id = $2",
|
||||||
secret.as_ref().unwrap(),
|
new_secret,
|
||||||
user.user_id as i32,
|
user.user_id as i32
|
||||||
)
|
)
|
||||||
.execute(&mut **pool)
|
.execute(&mut **pool)
|
||||||
.await
|
.await
|
||||||
{
|
.ok();
|
||||||
Ok(_) => (),
|
secret = Some(new_secret);
|
||||||
Err(_) => return Outcome::Error((Status::InternalServerError, ())),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Outcome::Success(TOTPSecret {
|
Outcome::Success(TOTPSecret {
|
||||||
@@ -161,3 +188,99 @@ impl TOTPSecret {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct TotpVerifyRequest {
|
||||||
|
pub code: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/totp/status")]
|
||||||
|
pub async fn get_totp_status(
|
||||||
|
user: Session,
|
||||||
|
mut db: Connection<Postgres>,
|
||||||
|
) -> Result<Json<TotpStatus>, Status> {
|
||||||
|
Ok(Json(
|
||||||
|
if sqlx::query!(
|
||||||
|
"SELECT twofa_enabled FROM users WHERE id = $1",
|
||||||
|
user.user_id as i32,
|
||||||
|
)
|
||||||
|
.fetch_one(&mut **db)
|
||||||
|
.await
|
||||||
|
.map_err(|_| Status::NotFound)?
|
||||||
|
.twofa_enabled
|
||||||
|
{
|
||||||
|
TotpStatus::Enabled
|
||||||
|
} else {
|
||||||
|
TotpStatus::Disabled
|
||||||
|
},
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[delete("/totp")]
|
||||||
|
pub async fn disable_totp(
|
||||||
|
user: Session,
|
||||||
|
mut db: Connection<Postgres>,
|
||||||
|
) -> Result<Json<AuthResponse>, Status> {
|
||||||
|
sqlx::query!(
|
||||||
|
"UPDATE users SET twofa_enabled = false, totp_secret = NULL WHERE id = $1",
|
||||||
|
user.user_id as i32,
|
||||||
|
)
|
||||||
|
.execute(&mut **db)
|
||||||
|
.await
|
||||||
|
.map_err(|_| Status::NotFound)?;
|
||||||
|
|
||||||
|
Ok(Json(AuthResponse {
|
||||||
|
token: Claims::new(user.user_id, TokenScope::Full).encode(),
|
||||||
|
totp_required: false,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[post("/totp/verify", data = "<body>")]
|
||||||
|
pub async fn verify_totp(
|
||||||
|
user: Claims, // request guard checks token validity
|
||||||
|
mut db: Connection<Postgres>,
|
||||||
|
body: Json<TotpVerifyRequest>,
|
||||||
|
) -> Result<Json<AuthResponse>, Status> {
|
||||||
|
println!("reached 1");
|
||||||
|
|
||||||
|
// reject if they somehow got here with a full token
|
||||||
|
if user.scope != TokenScope::TotpPending {
|
||||||
|
return Err(Status::Forbidden);
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("reached 2");
|
||||||
|
|
||||||
|
let row = sqlx::query!(
|
||||||
|
"SELECT totp_secret FROM users WHERE id = $1 AND twofa_enabled = TRUE",
|
||||||
|
user.sub
|
||||||
|
)
|
||||||
|
.fetch_one(&mut **db)
|
||||||
|
.await
|
||||||
|
.map_err(|_| Status::Unauthorized)?;
|
||||||
|
|
||||||
|
println!("reached 3");
|
||||||
|
|
||||||
|
let totp = totp_gen(
|
||||||
|
user.sub as usize,
|
||||||
|
row.totp_secret
|
||||||
|
.expect("user with 2fa enabled has no totp secret")
|
||||||
|
.as_bytes(),
|
||||||
|
)
|
||||||
|
.map_err(|_| Status::InternalServerError)?;
|
||||||
|
|
||||||
|
if !totp
|
||||||
|
.check_current(&body.code)
|
||||||
|
.map_err(|_| Status::InternalServerError)?
|
||||||
|
{
|
||||||
|
return Err(Status::Unauthorized);
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("reached 5");
|
||||||
|
|
||||||
|
let claims = Claims::new(user.sub as usize, TokenScope::Full);
|
||||||
|
|
||||||
|
Ok(Json(AuthResponse {
|
||||||
|
token: claims.encode(),
|
||||||
|
totp_required: false,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|||||||
@@ -73,6 +73,11 @@ fn rocket() -> Rocket<Build> {
|
|||||||
auth::get_totp,
|
auth::get_totp,
|
||||||
auth::confirm_totp,
|
auth::confirm_totp,
|
||||||
auth::generate_invite,
|
auth::generate_invite,
|
||||||
|
auth::verify_totp,
|
||||||
|
auth::disable_totp,
|
||||||
|
auth::get_totp_status,
|
||||||
|
auth::change_password,
|
||||||
|
auth::change_display_name
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
.register(
|
.register(
|
||||||
|
|||||||
@@ -96,6 +96,8 @@ pub async fn post_message(
|
|||||||
.await
|
.await
|
||||||
.map_err(|_| "Failed".to_string())?;
|
.map_err(|_| "Failed".to_string())?;
|
||||||
|
|
||||||
|
println!("gisfujdeghnjuisdfjngiosdfgjkosdf gnojdfsg nmodfsg");
|
||||||
|
|
||||||
if let Some(ref mut cache) = cache {
|
if let Some(ref mut cache) = cache {
|
||||||
messenger::cache::insert(cache, channel_id, &msg)
|
messenger::cache::insert(cache, channel_id, &msg)
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use redis::AsyncCommands;
|
use redis::AsyncCommands;
|
||||||
use rocket::serde::json::Json;
|
use rocket::{serde::json::Json, time::OffsetDateTime};
|
||||||
use rocket_db_pools::Connection;
|
use rocket_db_pools::Connection;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
@@ -7,6 +7,63 @@ use crate::{
|
|||||||
db::{Postgres, Redis},
|
db::{Postgres, Redis},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
pub struct User {
|
||||||
|
pub id: i32,
|
||||||
|
pub email: Option<String>,
|
||||||
|
pub username: String,
|
||||||
|
pub display_name: Option<String>,
|
||||||
|
pub pass_hash: String,
|
||||||
|
pub twofa_enabled: bool,
|
||||||
|
pub totp_secret: Option<String>,
|
||||||
|
pub created_at: Option<OffsetDateTime>,
|
||||||
|
pub updated_at: Option<OffsetDateTime>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl User {
|
||||||
|
pub async fn get_by_id(id: usize, db: &mut Connection<Postgres>) -> Option<Self> {
|
||||||
|
sqlx::query_as!(
|
||||||
|
Self,
|
||||||
|
"SELECT id, email, username, display_name, pass_hash, twofa_enabled, totp_secret, created_at, updated_at FROM users WHERE id = $1",
|
||||||
|
id as i32
|
||||||
|
)
|
||||||
|
.fetch_optional(&mut ***db)
|
||||||
|
.await
|
||||||
|
.unwrap_or(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_display_name(
|
||||||
|
&mut self,
|
||||||
|
display_name: Option<String>,
|
||||||
|
db: &mut Connection<Postgres>,
|
||||||
|
) -> Result<(), sqlx::Error> {
|
||||||
|
self.display_name = display_name;
|
||||||
|
sqlx::query!(
|
||||||
|
"UPDATE users SET display_name = $1 WHERE id = $2",
|
||||||
|
self.display_name,
|
||||||
|
self.id
|
||||||
|
)
|
||||||
|
.execute(&mut ***db)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_pass_hash(
|
||||||
|
&mut self,
|
||||||
|
pass_hash: String,
|
||||||
|
db: &mut Connection<Postgres>,
|
||||||
|
) -> Result<(), sqlx::Error> {
|
||||||
|
self.pass_hash = pass_hash;
|
||||||
|
sqlx::query!(
|
||||||
|
"UPDATE users SET pass_hash = $1 WHERE id = $2",
|
||||||
|
self.pass_hash,
|
||||||
|
self.id
|
||||||
|
)
|
||||||
|
.execute(&mut ***db)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[get("/users", rank = 2)]
|
#[get("/users", rank = 2)]
|
||||||
pub async fn users(_ag: Session, mut db: Connection<Postgres>) -> Json<Vec<i32>> {
|
pub async fn users(_ag: Session, mut db: Connection<Postgres>) -> Json<Vec<i32>> {
|
||||||
sqlx::query!("SELECT id FROM users")
|
sqlx::query!("SELECT id FROM users")
|
||||||
|
|||||||
@@ -124,9 +124,9 @@
|
|||||||
successMessage.classList.add("show");
|
successMessage.classList.add("show");
|
||||||
submitButton.innerHTML = "Logged in!!";
|
submitButton.innerHTML = "Logged in!!";
|
||||||
|
|
||||||
setTimeout(() => {
|
// setTimeout(() => {
|
||||||
window.location.replace('/chat');
|
// window.location.replace('/chat');
|
||||||
}, 1000);
|
// }, 1000);
|
||||||
} else {
|
} else {
|
||||||
const error = await response.text();
|
const error = await response.text();
|
||||||
throw new Error(error || "Login failed");
|
throw new Error(error || "Login failed");
|
||||||
|
|||||||