Compare commits
12 Commits
7664433064
...
v0.4.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 529d09aabc | |||
| 5291e7dee6 | |||
| 3c52ade946 | |||
| 0f692e4372 | |||
| d33eee1281 | |||
| 7c9b733813 | |||
| bda1ef251a | |||
| a2f7f5a505 | |||
| ad0cf85b34 | |||
| 3dfaab4865 | |||
| a0e9244d6a | |||
| 24fe3ef543 |
@@ -1,6 +1,7 @@
|
||||
*/target
|
||||
.env
|
||||
.log*
|
||||
|
||||
Cargo.lock
|
||||
.cargo/
|
||||
docker-compose*
|
||||
.sqlx/
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# Ignored default folder with query files
|
||||
/queries/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
||||
# Editor-based HTTP Client requests
|
||||
/httpRequests/
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="JAVA_MODULE" version="4">
|
||||
<component name="NewModuleRootManager" inherit-compiler-output="true">
|
||||
<exclude-output />
|
||||
<content url="file://$MODULE_DIR$">
|
||||
<sourceFolder url="file://$MODULE_DIR$/backend/src" isTestSource="false" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/backend/target" />
|
||||
</content>
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
||||
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
|
||||
<data-source source="LOCAL" name="chatapp dev" uuid="81992477-fd6f-427e-a27e-7378c26db6ef">
|
||||
<driver-ref>postgresql</driver-ref>
|
||||
<synchronize>true</synchronize>
|
||||
<jdbc-driver>org.postgresql.Driver</jdbc-driver>
|
||||
<jdbc-url>jdbc:postgresql://100.118.108.58:5432/chatapp_dev</jdbc-url>
|
||||
<jdbc-additional-properties>
|
||||
<property name="com.intellij.clouds.kubernetes.db.host.port" />
|
||||
<property name="com.intellij.clouds.kubernetes.db.enabled" value="false" />
|
||||
<property name="com.intellij.clouds.kubernetes.db.container.port" />
|
||||
</jdbc-additional-properties>
|
||||
<working-dir>$ProjectFileDir$</working-dir>
|
||||
</data-source>
|
||||
</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,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="GradleMigrationSettings" migrationVersion="1" />
|
||||
<component name="GradleSettings">
|
||||
<option name="linkedExternalProjectsSettings">
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/android" />
|
||||
</GradleProjectSettings>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
@@ -0,0 +1,6 @@
|
||||
<component name="InspectionProjectProfileManager">
|
||||
<profile version="1.0">
|
||||
<option name="myName" value="Project Default" />
|
||||
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
</profile>
|
||||
</component>
|
||||
@@ -0,0 +1,9 @@
|
||||
<component name="libraryTable">
|
||||
<library name="highlight(1)">
|
||||
<CLASSES>
|
||||
<root url="jar://$PROJECT_DIR$/backend/static/highlight(1).zip!/" />
|
||||
</CLASSES>
|
||||
<JAVADOC />
|
||||
<SOURCES />
|
||||
</library>
|
||||
</component>
|
||||
@@ -0,0 +1,6 @@
|
||||
<project version="4">
|
||||
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||
<component name="ProjectRootManager" version="2" project-jdk-name="25" project-jdk-type="JavaSDK">
|
||||
<output url="file://$PROJECT_DIR$/out" />
|
||||
</component>
|
||||
</project>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/chatapp.iml" filepath="$PROJECT_DIR$/.idea/chatapp.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
@@ -10,6 +10,12 @@
|
||||
"command": "clippy" // rust-analyzer.check.command (default: "check")
|
||||
}
|
||||
}
|
||||
},
|
||||
"nu": {
|
||||
"binary": {
|
||||
"path": "/home/fantasypvp/.cargo/bin/nu",
|
||||
"arguments": ["--lsp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
*.iml
|
||||
.gradle
|
||||
/local.properties
|
||||
/keystore.properties
|
||||
/.idea/caches
|
||||
/.idea/.cache
|
||||
/.idea/libraries
|
||||
/.idea/modules.xml
|
||||
/.idea/workspace.xml
|
||||
/.idea/navEditor.xml
|
||||
/.idea/assetWizardSettings.xml
|
||||
.DS_Store
|
||||
/build
|
||||
/captures
|
||||
.externalNativeBuild
|
||||
.cxx
|
||||
local.properties
|
||||
release/
|
||||
@@ -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,21 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="deploymentTargetSelector">
|
||||
<selectionStates>
|
||||
<SelectionState runConfigName="app">
|
||||
<option name="selectionMode" value="DROPDOWN" />
|
||||
<DropdownSelection timestamp="2026-04-02T14:33:39.814557661Z">
|
||||
<Target type="DEFAULT_BOOT">
|
||||
<handle>
|
||||
<DeviceId pluginId="PhysicalDevice" identifier="serial=00319362N000094" />
|
||||
</handle>
|
||||
</Target>
|
||||
</DropdownSelection>
|
||||
<DialogSelection />
|
||||
</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,107 @@
|
||||
import java.util.Properties
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "dev.zxq5.chatapp.android"
|
||||
compileSdk = 35
|
||||
|
||||
val keystorePropertiesFile = rootProject.file("local.properties")
|
||||
val keystoreProperties = Properties()
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
keystoreProperties.load(keystorePropertiesFile.inputStream())
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
create("release") {
|
||||
storeFile = file("${System.getProperty("user.home")}/keystores/chatapp.jks")
|
||||
storePassword = keystoreProperties["KEYSTORE_PASSWORD"] as String?
|
||||
?: System.getenv("KEYSTORE_PASSWORD")
|
||||
?: ""
|
||||
keyAlias = "chatapp"
|
||||
keyPassword = keystoreProperties["KEY_PASSWORD"] as String?
|
||||
?: System.getenv("KEY_PASSWORD")
|
||||
?: ""
|
||||
}
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "dev.zxq5.chatapp.android"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 1
|
||||
versionName = "1.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = true // shrinks code
|
||||
isShrinkResources = true // removes unused resources
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
buildConfigField("String", "BASE_URL", "\"https://chat.zxq5.dev\"")
|
||||
}
|
||||
debug {
|
||||
isMinifyEnabled = false
|
||||
isDebuggable = true
|
||||
applicationIdSuffix = ".debug" // lets you install both side by side
|
||||
buildConfigField("String", "BASE_URL", "\"http://zxq5-x1:8000\"")
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_11
|
||||
targetCompatibility = JavaVersion.VERSION_11
|
||||
}
|
||||
buildFeatures {
|
||||
compose = true
|
||||
buildConfig = 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,47 @@
|
||||
# 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
|
||||
|
||||
# Ktor
|
||||
-keep class io.ktor.** { *; }
|
||||
-keep class kotlinx.coroutines.** { *; }
|
||||
|
||||
# Kotlinx serialization
|
||||
-keepattributes *Annotation*, InnerClasses
|
||||
-dontnote kotlinx.serialization.AnnotationsKt
|
||||
-keep,includedescriptorclasses class dev.zxq5.chatapp.android.**$$serializer { *; }
|
||||
-keepclassmembers class dev.zxq5.chatapp.android.** {
|
||||
*** Companion;
|
||||
}
|
||||
-keepclasseswithmembers class dev.zxq5.chatapp.android.** {
|
||||
kotlinx.serialization.KSerializer serializer(...);
|
||||
}
|
||||
|
||||
# Keep model classes (serialization needs these)
|
||||
-keep class dev.zxq5.chatapp.android.api.model.** { *; }
|
||||
-keep class dev.zxq5.chatapp.android.data.model.** { *; }
|
||||
|
||||
# Fix for missing errorprone and javax annotations used by Tink and other libraries
|
||||
-dontwarn com.google.errorprone.annotations.**
|
||||
-dontwarn javax.annotation.**
|
||||
|
||||
# Fix for missing java.lang.management referenced by Ktor (not available on Android)
|
||||
-dontwarn java.lang.management.**
|
||||
@@ -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,41 @@
|
||||
<?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" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/>
|
||||
|
||||
|
||||
<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">
|
||||
|
||||
<service
|
||||
android:name=".core.service.MessageStreamService"
|
||||
android:foregroundServiceType="dataSync"
|
||||
android:exported="false"/>
|
||||
|
||||
<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,51 @@
|
||||
package dev.zxq5.chatapp.android
|
||||
|
||||
import android.app.Application
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.os.Build
|
||||
import dev.zxq5.chatapp.android.core.data.TokenStore
|
||||
import dev.zxq5.chatapp.android.data.repository.AuthRepository
|
||||
import dev.zxq5.chatapp.android.data.repository.ChatRepository
|
||||
import dev.zxq5.chatapp.android.data.repository.SettingsRepository
|
||||
|
||||
class ChatApplication : Application() {
|
||||
|
||||
object AppState {
|
||||
var isInForeground = false
|
||||
}
|
||||
|
||||
val tokenStore by lazy { TokenStore(this) }
|
||||
val authRepository by lazy { AuthRepository(tokenStore) }
|
||||
val chatRepository by lazy { ChatRepository(tokenStore) }
|
||||
val settingsRepository by lazy { SettingsRepository(tokenStore) }
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
createNotificationChannels()
|
||||
}
|
||||
|
||||
private fun createNotificationChannels() {
|
||||
val messageChannel = NotificationChannel(
|
||||
"messages",
|
||||
"Messages",
|
||||
NotificationManager.IMPORTANCE_HIGH
|
||||
).apply {
|
||||
description = "New message notifications"
|
||||
enableVibration(true)
|
||||
}
|
||||
|
||||
// add this — required for the foreground service persistent notification
|
||||
val serviceChannel = NotificationChannel(
|
||||
"service",
|
||||
"Background connection",
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
).apply {
|
||||
description = "Keeps messages running in background"
|
||||
}
|
||||
|
||||
val manager = getSystemService(NotificationManager::class.java)
|
||||
manager.createNotificationChannel(messageChannel)
|
||||
manager.createNotificationChannel(serviceChannel)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
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.border
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.ChatBubbleOutline
|
||||
import androidx.compose.material.icons.outlined.PeopleOutline
|
||||
import androidx.compose.material.icons.outlined.Settings
|
||||
import androidx.compose.material3.Icon
|
||||
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.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import dev.zxq5.chatapp.android.ChatApplication.AppState
|
||||
import dev.zxq5.chatapp.android.core.service.MessageStreamService
|
||||
import dev.zxq5.chatapp.android.data.repository.AuthState
|
||||
import dev.zxq5.chatapp.android.feature.auth.AuthScreen
|
||||
import dev.zxq5.chatapp.android.feature.auth.AuthViewModel
|
||||
import dev.zxq5.chatapp.android.feature.chat.ChatScreen
|
||||
import dev.zxq5.chatapp.android.feature.chat.ChatViewModel
|
||||
import dev.zxq5.chatapp.android.feature.chat.Screen
|
||||
import dev.zxq5.chatapp.android.feature.contacts.ContactsScreen
|
||||
import dev.zxq5.chatapp.android.feature.settings.SettingsScreen
|
||||
import dev.zxq5.chatapp.android.feature.settings.SettingsViewModel
|
||||
import dev.zxq5.chatapp.android.ui.theme.ChatappTheme
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
val app = application as ChatApplication
|
||||
val authRepository = app.authRepository
|
||||
val chatRepository = app.chatRepository
|
||||
val settingsRepository = app.settingsRepository
|
||||
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
ChatappTheme {
|
||||
val authViewModel: AuthViewModel = viewModel(factory = ViewModelFactory(authRepository))
|
||||
val chatViewModel: ChatViewModel = viewModel(factory = ViewModelFactory(chatRepository))
|
||||
val settingsViewModel: SettingsViewModel = viewModel(factory = ViewModelFactory(settingsRepository))
|
||||
|
||||
val authState by authViewModel.authState.collectAsState()
|
||||
val currentScreen by chatViewModel.currentScreen.collectAsState()
|
||||
val selectedChannelId by chatViewModel.channelId.collectAsState()
|
||||
|
||||
LaunchedEffect(authState) {
|
||||
when (authState) {
|
||||
AuthState.Authenticated -> MessageStreamService.start(this@MainActivity)
|
||||
AuthState.Unauthenticated -> MessageStreamService.stop(this@MainActivity)
|
||||
AuthState.AwaitingTotp -> {}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
intent.getIntExtra("channel_id", -1).takeIf { it != -1 }?.let {
|
||||
chatViewModel.switchChannel(it.toLong())
|
||||
}
|
||||
}
|
||||
|
||||
if (authState == AuthState.Authenticated) {
|
||||
Scaffold(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
bottomBar = {
|
||||
// Only show bottom bar if we are NOT inside a specific chat channel
|
||||
if (selectedChannelId == null) {
|
||||
BottomDock(
|
||||
currentScreen = currentScreen,
|
||||
onNavigate = { chatViewModel.navigateTo(it) }
|
||||
)
|
||||
}
|
||||
}
|
||||
) { innerPadding ->
|
||||
Box(modifier = Modifier.padding(innerPadding)) {
|
||||
when (currentScreen) {
|
||||
Screen.CHAT -> ChatScreen(
|
||||
viewModel = chatViewModel,
|
||||
onNavigateToSettings = { chatViewModel.navigateTo(Screen.SETTINGS) },
|
||||
onLogout = {
|
||||
authViewModel.logout()
|
||||
chatViewModel.clearChat()
|
||||
}
|
||||
)
|
||||
Screen.CONTACTS -> ContactsScreen()
|
||||
Screen.SETTINGS -> SettingsScreen(
|
||||
viewModel = settingsViewModel,
|
||||
onLogout = {
|
||||
authViewModel.logout()
|
||||
chatViewModel.clearChat()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
AuthScreen(viewModel = authViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
AppState.isInForeground = true
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
AppState.isInForeground = false
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: android.content.Intent) {
|
||||
super.onNewIntent(intent)
|
||||
intent.getIntExtra("channel_id", -1).takeIf { it != -1 }?.let { channelId ->
|
||||
MessageStreamService.instance?.activeChannelId = channelId.toLong()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BottomDock(currentScreen: Screen, onNavigate: (Screen) -> Unit) {
|
||||
NavigationBar(
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
tonalElevation = 0.dp,
|
||||
modifier = Modifier
|
||||
.height(80.dp)
|
||||
.border(
|
||||
0.5.dp,
|
||||
MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.2f),
|
||||
RoundedCornerShape(topStart = 0.dp, topEnd = 0.dp)
|
||||
)
|
||||
) {
|
||||
NavigationBarItem(
|
||||
selected = currentScreen == Screen.CHAT,
|
||||
onClick = { onNavigate(Screen.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 == Screen.CONTACTS,
|
||||
onClick = { onNavigate(Screen.CONTACTS) },
|
||||
icon = { Icon(Icons.Outlined.PeopleOutline, contentDescription = "Contacts") },
|
||||
label = { Text("contacts", 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 == Screen.SETTINGS,
|
||||
onClick = { onNavigate(Screen.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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package dev.zxq5.chatapp.android
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import dev.zxq5.chatapp.android.data.repository.AuthRepository
|
||||
import dev.zxq5.chatapp.android.data.repository.ChatRepository
|
||||
import dev.zxq5.chatapp.android.data.repository.SettingsRepository
|
||||
import dev.zxq5.chatapp.android.feature.auth.AuthViewModel
|
||||
import dev.zxq5.chatapp.android.feature.chat.ChatViewModel
|
||||
import dev.zxq5.chatapp.android.feature.settings.SettingsViewModel
|
||||
|
||||
class ViewModelFactory(private val repository: Any) : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
return when {
|
||||
modelClass.isAssignableFrom(AuthViewModel::class.java) -> {
|
||||
AuthViewModel(repository as AuthRepository) as T
|
||||
}
|
||||
modelClass.isAssignableFrom(ChatViewModel::class.java) -> {
|
||||
ChatViewModel(repository as ChatRepository) as T
|
||||
}
|
||||
modelClass.isAssignableFrom(SettingsViewModel::class.java) -> {
|
||||
SettingsViewModel(repository as SettingsRepository) as T
|
||||
}
|
||||
else -> throw IllegalArgumentException("Unknown ViewModel class")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package dev.zxq5.chatapp.android.api
|
||||
|
||||
import android.util.Log
|
||||
import dev.zxq5.chatapp.android.BuildConfig
|
||||
import dev.zxq5.chatapp.android.BuildConfig.BASE_URL
|
||||
import dev.zxq5.chatapp.android.api.model.LoginRequest
|
||||
import dev.zxq5.chatapp.android.api.model.LoginResponse
|
||||
import dev.zxq5.chatapp.android.api.model.TOTPSixDigitCode
|
||||
import dev.zxq5.chatapp.android.core.error.ApiResult
|
||||
import dev.zxq5.chatapp.android.api.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.header
|
||||
import io.ktor.client.request.post
|
||||
import io.ktor.client.request.setBody
|
||||
import io.ktor.http.ContentType
|
||||
import io.ktor.http.HttpHeaders
|
||||
import io.ktor.http.contentType
|
||||
import io.ktor.http.isSuccess
|
||||
import io.ktor.serialization.kotlinx.json.json
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
/**
|
||||
* Client for unauthenticated and pre-authenticated (2FA) requests.
|
||||
*/
|
||||
object AuthClient {
|
||||
private val http = HttpClient(Android) {
|
||||
install(ContentNegotiation) {
|
||||
json(Json { ignoreUnknownKeys = true })
|
||||
}
|
||||
}
|
||||
|
||||
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()) {
|
||||
ApiResult.Success(response.body<LoginResponse>())
|
||||
} 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()) {
|
||||
ApiResult.Success(response.body<LoginResponse>())
|
||||
} 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")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun verifyTotpLogin(partialToken: String, code: String): ApiResult<LoginResponse> {
|
||||
return try {
|
||||
val response = http.post("${BASE_URL}/api/totp/verify") {
|
||||
header(HttpHeaders.Authorization, "Bearer $partialToken")
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(TOTPSixDigitCode(code))
|
||||
}
|
||||
if (response.status.isSuccess()) {
|
||||
ApiResult.Success(response.body<LoginResponse>())
|
||||
} 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package dev.zxq5.chatapp.android.api
|
||||
|
||||
import dev.zxq5.chatapp.android.BuildConfig.BASE_URL
|
||||
import dev.zxq5.chatapp.android.api.model.Message
|
||||
import dev.zxq5.chatapp.android.api.model.SendMessage
|
||||
import dev.zxq5.chatapp.android.api.model.SpaceDto
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.call.body
|
||||
import io.ktor.client.engine.android.Android
|
||||
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.client.plugins.contentnegotiation.ContentNegotiation
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.client.request.post
|
||||
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.serialization.kotlinx.json.json
|
||||
import io.ktor.utils.io.readLine
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.ExperimentalTime
|
||||
|
||||
class ChatClient(private val token: String) {
|
||||
|
||||
private val http = HttpClient(Android) {
|
||||
install(ContentNegotiation) {
|
||||
json(Json { ignoreUnknownKeys = true })
|
||||
}
|
||||
install(Auth) {
|
||||
bearer {
|
||||
loadTokens { BearerTokens(token, "") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getAccessibleChannels(): List<SpaceDto> = http.get("${BASE_URL}/api/accessible_channels").body()
|
||||
|
||||
@OptIn(ExperimentalTime::class)
|
||||
suspend fun sendMessage(channelId: Long, userId: Int, text: String) {
|
||||
http.post("${BASE_URL}/api/chat/$channelId") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(SendMessage(user_id = userId, text = text, timestamp = Clock.System.now()))
|
||||
}
|
||||
}
|
||||
|
||||
fun messageStream(channelId: Long): Flow<Message> = flow {
|
||||
http.prepareGet("${BASE_URL}/api/events/$channelId").execute { response ->
|
||||
val channel = response.bodyAsChannel()
|
||||
while (!channel.isClosedForRead) {
|
||||
val line = channel.readLine() ?: break
|
||||
if (line.startsWith("data:")) {
|
||||
val json = line.removePrefix("data:").trim()
|
||||
runCatching { Json.decodeFromString<Message>(json) }
|
||||
.onSuccess { emit(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package dev.zxq5.chatapp.android.api
|
||||
|
||||
import android.util.Log
|
||||
import dev.zxq5.chatapp.android.BuildConfig.BASE_URL
|
||||
import dev.zxq5.chatapp.android.api.model.AccountDeleteRequest
|
||||
import dev.zxq5.chatapp.android.api.model.DisplayNameRequest
|
||||
import dev.zxq5.chatapp.android.api.model.PasswordChangeRequest
|
||||
import dev.zxq5.chatapp.android.api.model.QrResponse
|
||||
import dev.zxq5.chatapp.android.api.model.TOTPSixDigitCode
|
||||
import dev.zxq5.chatapp.android.api.model.TotpStatus
|
||||
import dev.zxq5.chatapp.android.api.model.UsernameRequest
|
||||
import dev.zxq5.chatapp.android.api.model.TotpDeleteRequest
|
||||
import dev.zxq5.chatapp.android.api.model.PasswordRequest
|
||||
import dev.zxq5.chatapp.android.core.error.ApiResult
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.call.body
|
||||
import io.ktor.client.engine.android.Android
|
||||
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.client.plugins.contentnegotiation.ContentNegotiation
|
||||
import io.ktor.client.request.delete
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.client.request.patch
|
||||
import io.ktor.client.request.post
|
||||
import io.ktor.client.request.setBody
|
||||
import io.ktor.http.ContentType
|
||||
import io.ktor.http.contentType
|
||||
import io.ktor.http.isSuccess
|
||||
import io.ktor.serialization.kotlinx.json.json
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
/**
|
||||
* Client for account settings and TOTP management.
|
||||
*/
|
||||
class SettingsClient(private val token: String) {
|
||||
private val http = HttpClient(Android) {
|
||||
install(ContentNegotiation) {
|
||||
json(Json { ignoreUnknownKeys = true })
|
||||
}
|
||||
install(Auth) {
|
||||
bearer {
|
||||
loadTokens { BearerTokens(token, "") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getTotpQr(password: String): ApiResult<QrResponse> {
|
||||
return try {
|
||||
val response = http.post("${BASE_URL}/api/totp.jpg") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(PasswordRequest(password))
|
||||
}
|
||||
if (response.status.isSuccess()) {
|
||||
ApiResult.Success(response.body<QrResponse>())
|
||||
} else {
|
||||
ApiResult.HttpError(response.status.value, "Failed to get QR code")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("Chat", "Error fetching TOTP QR", e)
|
||||
ApiResult.NetworkError(e.localizedMessage ?: "Network error")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun confirmTotp(code: String): ApiResult<Unit> {
|
||||
return try {
|
||||
val response = http.post("${BASE_URL}/api/totp") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(TOTPSixDigitCode(code))
|
||||
}
|
||||
if (response.status.isSuccess()) {
|
||||
ApiResult.Success(Unit)
|
||||
} else {
|
||||
ApiResult.HttpError(response.status.value, "Failed to confirm TOTP")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("Chat", "Error confirming TOTP", e)
|
||||
ApiResult.NetworkError(e.localizedMessage ?: "Network error")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getTotpStatus(): ApiResult<TotpStatus> {
|
||||
return try {
|
||||
val response = http.get("${BASE_URL}/api/totp/status")
|
||||
if (response.status.isSuccess()) {
|
||||
ApiResult.Success(response.body<TotpStatus>())
|
||||
} else {
|
||||
ApiResult.HttpError(response.status.value, "Failed to get TOTP status")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("Chat", "Error getting TOTP status", e)
|
||||
ApiResult.NetworkError(e.localizedMessage ?: "Network error")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun disableTotp(password: String, totpCode: String): ApiResult<Unit> {
|
||||
return try {
|
||||
val response = http.delete("${BASE_URL}/api/totp") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(TotpDeleteRequest(password, totpCode))
|
||||
}
|
||||
if (response.status.isSuccess()) {
|
||||
ApiResult.Success(Unit)
|
||||
} 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.patch("${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
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun updateUsername(username: String): ApiResult<Unit> {
|
||||
return try {
|
||||
val response = http.patch("${BASE_URL}/api/settings/username") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(UsernameRequest(username))
|
||||
}
|
||||
if (response.status.isSuccess()) ApiResult.Success(Unit)
|
||||
else ApiResult.HttpError(response.status.value, "Failed to update username")
|
||||
} catch (e: Exception) {
|
||||
ApiResult.NetworkError(e.localizedMessage ?: "Network error")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun deleteAccount(password: String, totpCode: String?): ApiResult<Unit> {
|
||||
return try {
|
||||
val response = http.delete("${BASE_URL}/api/settings") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(AccountDeleteRequest(password, totpCode))
|
||||
}
|
||||
if (response.status.isSuccess()) ApiResult.Success(Unit)
|
||||
else ApiResult.HttpError(response.status.value, "Failed to delete account")
|
||||
} catch (e: Exception) {
|
||||
ApiResult.NetworkError(e.localizedMessage ?: "Network error")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package dev.zxq5.chatapp.android.api.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class AccountDeleteRequest(val password: String, val totp_code: String? = null)
|
||||
@@ -0,0 +1,15 @@
|
||||
package dev.zxq5.chatapp.android.api.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlin.time.ExperimentalTime
|
||||
import kotlin.time.Instant
|
||||
|
||||
@Serializable
|
||||
data class Channel @OptIn(ExperimentalTime::class) constructor(
|
||||
val id: Long,
|
||||
val name: String,
|
||||
val description: String? = null,
|
||||
val space_id: Long,
|
||||
val created_at: Instant,
|
||||
val updated_at: Instant
|
||||
)
|
||||
@@ -0,0 +1,6 @@
|
||||
package dev.zxq5.chatapp.android.api.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class DisplayNameRequest(val display_name: String?)
|
||||
@@ -0,0 +1,9 @@
|
||||
package dev.zxq5.chatapp.android.api.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class LoginRequest(
|
||||
val username: String,
|
||||
val password: String
|
||||
)
|
||||
@@ -0,0 +1,6 @@
|
||||
package dev.zxq5.chatapp.android.api.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class LoginResponse(val token: String)
|
||||
@@ -0,0 +1,13 @@
|
||||
package dev.zxq5.chatapp.android.api.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlin.time.ExperimentalTime
|
||||
import kotlin.time.Instant
|
||||
|
||||
@Serializable
|
||||
data class Message @OptIn(ExperimentalTime::class) constructor(
|
||||
val user_id: Int,
|
||||
val display_name: String,
|
||||
val text: String,
|
||||
val timestamp: Instant
|
||||
)
|
||||
@@ -0,0 +1,6 @@
|
||||
package dev.zxq5.chatapp.android.api.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class PasswordChangeRequest(val old_password: String, val new_password: String)
|
||||
@@ -0,0 +1,6 @@
|
||||
package dev.zxq5.chatapp.android.api.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class PasswordRequest(val password: String)
|
||||
@@ -0,0 +1,6 @@
|
||||
package dev.zxq5.chatapp.android.api.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class QrResponse(val qr_code: String)
|
||||
@@ -0,0 +1,12 @@
|
||||
package dev.zxq5.chatapp.android.api.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlin.time.ExperimentalTime
|
||||
import kotlin.time.Instant
|
||||
|
||||
@Serializable
|
||||
data class SendMessage @OptIn(ExperimentalTime::class) constructor(
|
||||
val user_id: Int,
|
||||
val text: String,
|
||||
val timestamp: Instant
|
||||
)
|
||||
@@ -0,0 +1,14 @@
|
||||
package dev.zxq5.chatapp.android.api.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,28 @@
|
||||
package dev.zxq5.chatapp.android.api.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlin.time.ExperimentalTime
|
||||
import kotlin.time.Instant
|
||||
|
||||
|
||||
@Serializable
|
||||
data class Space @OptIn(ExperimentalTime::class) constructor(
|
||||
val id: Long,
|
||||
val name: String,
|
||||
val description: String? = null,
|
||||
val owner_id: Long,
|
||||
val created_at: Instant,
|
||||
val updated_at: Instant
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SpaceDto @OptIn(ExperimentalTime::class) constructor(
|
||||
val channels: List<Channel>,
|
||||
|
||||
val id: Long,
|
||||
val name: String,
|
||||
val description: String? = null,
|
||||
val owner_id: Long,
|
||||
val created_at: Instant,
|
||||
val updated_at: Instant
|
||||
)
|
||||
@@ -0,0 +1,6 @@
|
||||
package dev.zxq5.chatapp.android.api.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class TOTPSixDigitCode(val code: String)
|
||||
@@ -0,0 +1,6 @@
|
||||
package dev.zxq5.chatapp.android.api.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class TotpDeleteRequest(val password: String, val totp_code: String)
|
||||
@@ -0,0 +1,24 @@
|
||||
package dev.zxq5.chatapp.android.api.model
|
||||
|
||||
import kotlinx.serialization.KSerializer
|
||||
import kotlinx.serialization.Serializable
|
||||
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(with = TotpStatus.TotpStatusSerializer::class)
|
||||
enum class TotpStatus {
|
||||
ENABLED, DISABLED;
|
||||
val isEnabled: Boolean get() = this == ENABLED
|
||||
|
||||
|
||||
companion 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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
package dev.zxq5.chatapp.android.api.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class UsernameRequest(val username: String)
|
||||
@@ -0,0 +1,3 @@
|
||||
package dev.zxq5.chatapp.android.core
|
||||
|
||||
//const val BASE_URL = "http://zxq5-x1:8000"
|
||||
@@ -0,0 +1,80 @@
|
||||
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
|
||||
|
||||
private const val KEY = "auth_token"
|
||||
private const val TWOFA_KEY = "twofa_enabled"
|
||||
|
||||
// In your ChatClient.kt or a dedicated TokenStore
|
||||
class TokenStore(appContext: Context) {
|
||||
private val context = appContext.applicationContext;
|
||||
|
||||
private fun prefs(): 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(token: String) =
|
||||
prefs().edit { putString(KEY, token) }
|
||||
|
||||
fun get(): String? =
|
||||
prefs().getString(KEY, null)
|
||||
|
||||
fun save2faEnabled( enabled: Boolean) =
|
||||
prefs().edit { putBoolean(TWOFA_KEY, enabled) }
|
||||
|
||||
fun is2faEnabled(): Boolean =
|
||||
prefs().getBoolean(TWOFA_KEY, false)
|
||||
|
||||
fun clear() =
|
||||
prefs().edit { remove(KEY).remove(TWOFA_KEY) }
|
||||
|
||||
fun getUserId(): Int? {
|
||||
val token = get() ?: 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,104 @@
|
||||
package dev.zxq5.chatapp.android.core.service
|
||||
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.util.Log
|
||||
import dev.zxq5.chatapp.android.ChatApplication
|
||||
import dev.zxq5.chatapp.android.data.repository.ChatRepository
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
// core/service/MessageStreamService.kt
|
||||
class MessageStreamService : Service() {
|
||||
|
||||
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private lateinit var notificationService: NotificationService
|
||||
private lateinit var chatRepository: ChatRepository
|
||||
|
||||
// which channel the user is currently looking at
|
||||
// set by the ViewModel when the user opens/closes a channel
|
||||
var activeChannelId: Long? = null
|
||||
set(value) {
|
||||
field = value
|
||||
Log.d("Service", "activeChannelId set to $value")
|
||||
if (value != null) {
|
||||
// restart stream with new channel
|
||||
currentStreamJob?.cancel()
|
||||
observeMessages()
|
||||
}
|
||||
}
|
||||
|
||||
private var currentStreamJob: kotlinx.coroutines.Job? = null
|
||||
|
||||
companion object {
|
||||
var instance: MessageStreamService? = null
|
||||
|
||||
fun start(context: Context) {
|
||||
val intent = Intent(context, MessageStreamService::class.java)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
context.startForegroundService(intent)
|
||||
} else {
|
||||
context.startService(intent)
|
||||
}
|
||||
}
|
||||
|
||||
fun stop(context: Context) {
|
||||
context.stopService(Intent(context, MessageStreamService::class.java))
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
instance = this
|
||||
notificationService = NotificationService(this)
|
||||
chatRepository = (application as ChatApplication).chatRepository
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
startForeground(
|
||||
NotificationService.FOREGROUND_NOTIFICATION_ID,
|
||||
notificationService.buildForegroundNotification()
|
||||
)
|
||||
observeMessages()
|
||||
return START_STICKY // restart if killed
|
||||
}
|
||||
|
||||
private fun observeMessages() {
|
||||
val channelId = activeChannelId ?: chatRepository.getLastActiveChannel()
|
||||
Log.d("Service", "observeMessages called, channelId=$channelId")
|
||||
if (channelId == null) {
|
||||
Log.d("Service", "No channel to observe, waiting for switchChannel")
|
||||
return
|
||||
}
|
||||
|
||||
Log.d("Service", "Starting stream for channel $channelId")
|
||||
currentStreamJob = serviceScope.launch {
|
||||
chatRepository.messageStream(channelId)
|
||||
.catch { e -> Log.e("Service", "Stream error", e) }
|
||||
.collect { message ->
|
||||
if (!ChatApplication.AppState.isInForeground) { // no channel focused, always notify
|
||||
notificationService.showMessageNotification(
|
||||
conversationId = activeChannelId.toString(),
|
||||
senderName = message.display_name,
|
||||
messagePreview = message.text.take(80)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
instance = null
|
||||
serviceScope.cancel()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package dev.zxq5.chatapp.android.core.service
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import androidx.core.app.NotificationCompat
|
||||
import dev.zxq5.chatapp.android.MainActivity
|
||||
import dev.zxq5.chatapp.android.R
|
||||
|
||||
class NotificationService(private val context: Context) {
|
||||
|
||||
companion object {
|
||||
const val CHANNEL_ID = "messages"
|
||||
const val FOREGROUND_NOTIFICATION_ID = 1 // ← this needs to exist
|
||||
}
|
||||
|
||||
private val manager = context.getSystemService(NotificationManager::class.java)
|
||||
|
||||
fun createChannels() {
|
||||
// channel for new message notifications
|
||||
val messageChannel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
"Messages",
|
||||
NotificationManager.IMPORTANCE_HIGH
|
||||
).apply {
|
||||
enableVibration(true)
|
||||
}
|
||||
|
||||
// channel for the persistent foreground service notification
|
||||
// low importance so it doesn't make noise
|
||||
val serviceChannel = NotificationChannel(
|
||||
"service",
|
||||
"Background connection",
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
)
|
||||
|
||||
val mgr = context.getSystemService(NotificationManager::class.java)
|
||||
mgr.createNotificationChannel(messageChannel)
|
||||
mgr.createNotificationChannel(serviceChannel)
|
||||
}
|
||||
|
||||
fun buildForegroundNotification(): Notification {
|
||||
return NotificationCompat.Builder(context, "service")
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setContentTitle("chatapp")
|
||||
.setContentText("Connected")
|
||||
.setOngoing(true)
|
||||
.setSilent(true)
|
||||
.build()
|
||||
}
|
||||
|
||||
fun showMessageNotification(
|
||||
conversationId: String,
|
||||
senderName: String,
|
||||
messagePreview: String, // for E2E this would be "New message" — no plaintext
|
||||
notificationId: Int = conversationId.hashCode()
|
||||
) {
|
||||
// intent that opens the app to the right conversation when tapped
|
||||
val intent = Intent(context, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||
putExtra("conversation_id", conversationId)
|
||||
}
|
||||
|
||||
val pendingIntent = PendingIntent.getActivity(
|
||||
context,
|
||||
notificationId,
|
||||
intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
val notification = NotificationCompat.Builder(context, "messages")
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setContentTitle(senderName)
|
||||
.setContentText(messagePreview)
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.setContentIntent(pendingIntent)
|
||||
.setAutoCancel(true) // dismiss on tap
|
||||
.build()
|
||||
|
||||
manager.notify(notificationId, notification)
|
||||
}
|
||||
|
||||
fun dismissNotification(conversationId: String) {
|
||||
manager.cancel(conversationId.hashCode())
|
||||
}
|
||||
|
||||
fun dismissAll() {
|
||||
manager.cancelAll()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package dev.zxq5.chatapp.android.data.repository
|
||||
|
||||
import dev.zxq5.chatapp.android.api.AuthClient
|
||||
import dev.zxq5.chatapp.android.core.data.TokenStore
|
||||
import dev.zxq5.chatapp.android.core.data.getScopeFromToken
|
||||
import dev.zxq5.chatapp.android.core.error.ApiResult
|
||||
import dev.zxq5.chatapp.android.feature.auth.TokenScope
|
||||
|
||||
class AuthRepository(
|
||||
private val tokenStore: TokenStore,
|
||||
) {
|
||||
suspend fun signup(username: String, email: String, password: String, accessToken: String): SignupResult {
|
||||
return when(val result = AuthClient.signup(username, email, password, accessToken)) {
|
||||
is ApiResult.HttpError -> SignupResult.Error(result.message)
|
||||
is ApiResult.NetworkError -> SignupResult.Error("Network error: ${result.message}")
|
||||
is ApiResult.Success -> {
|
||||
tokenStore.save(result.data.token)
|
||||
SignupResult.Success
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun verifyTotpLogin(code: String): LoginResult {
|
||||
val partialToken = tokenStore.get() ?: return LoginResult.Error("Session expired")
|
||||
return when(val result = AuthClient.verifyTotpLogin(partialToken, code)) {
|
||||
is ApiResult.HttpError -> LoginResult.TotpError(result.message)
|
||||
is ApiResult.NetworkError -> LoginResult.TotpError("Network error: ${result.message}")
|
||||
is ApiResult.Success -> {
|
||||
tokenStore.save(result.data.token)
|
||||
LoginResult.Success
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun login(username: String, password: String): LoginResult {
|
||||
return when(val result = AuthClient.login(username, password)) {
|
||||
is ApiResult.HttpError -> LoginResult.Error(result.message)
|
||||
is ApiResult.NetworkError -> LoginResult.Error("Network error: ${result.message}")
|
||||
is ApiResult.Success -> {
|
||||
tokenStore.save(result.data.token)
|
||||
|
||||
when (val scope = getScopeFromToken(result.data.token)) {
|
||||
TokenScope.TOTP_PENDING -> LoginResult.TotpRequired
|
||||
TokenScope.FULL -> LoginResult.Success
|
||||
else -> LoginResult.Error("Unexpected token scope: $scope")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun logout() {
|
||||
tokenStore.clear()
|
||||
}
|
||||
|
||||
fun getUserId() = tokenStore.getUserId()
|
||||
|
||||
fun getAuthState(): AuthState {
|
||||
val token = tokenStore.get() ?: return AuthState.Unauthenticated
|
||||
return when (getScopeFromToken(token)) {
|
||||
TokenScope.FULL -> AuthState.Authenticated
|
||||
TokenScope.TOTP_PENDING -> AuthState.AwaitingTotp
|
||||
else -> AuthState.Unauthenticated
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sealed class SignupResult {
|
||||
object Success : SignupResult()
|
||||
data class Error(val message: String) : SignupResult()
|
||||
}
|
||||
|
||||
sealed class LoginResult {
|
||||
object Success : LoginResult()
|
||||
object TotpRequired : LoginResult()
|
||||
data class TotpError(val message: String) : LoginResult()
|
||||
data class Error(val message: String) : LoginResult()
|
||||
}
|
||||
|
||||
sealed class AuthState {
|
||||
object Authenticated : AuthState()
|
||||
object AwaitingTotp : AuthState()
|
||||
object Unauthenticated : AuthState()
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package dev.zxq5.chatapp.android.data.repository
|
||||
|
||||
import dev.zxq5.chatapp.android.api.ChatClient
|
||||
import dev.zxq5.chatapp.android.core.data.TokenStore
|
||||
import dev.zxq5.chatapp.android.api.model.Message
|
||||
import dev.zxq5.chatapp.android.api.model.SpaceDto
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
|
||||
class ChatRepository(private val tokenStore: TokenStore) {
|
||||
|
||||
private var _chatClient: ChatClient? = null
|
||||
private var _lastToken: String? = null
|
||||
|
||||
private var _lastActiveChannel: Long? = null
|
||||
|
||||
private fun getChatClient(): ChatClient? {
|
||||
val token = tokenStore.get() ?: return null
|
||||
if (_chatClient == null || token != _lastToken) {
|
||||
_chatClient = ChatClient(token)
|
||||
_lastToken = token
|
||||
}
|
||||
return _chatClient
|
||||
}
|
||||
|
||||
fun resetClient() {
|
||||
_chatClient = null
|
||||
_lastToken = null
|
||||
}
|
||||
|
||||
fun getLastActiveChannel(): Long? {
|
||||
return _lastActiveChannel
|
||||
}
|
||||
|
||||
fun getUserId() = tokenStore.getUserId()
|
||||
|
||||
suspend fun getAccessibleChannels(): List<SpaceDto> {
|
||||
return getChatClient()?.getAccessibleChannels() ?: emptyList()
|
||||
}
|
||||
|
||||
suspend fun sendMessage(channelId: Long, text: String) {
|
||||
val userId = tokenStore.getUserId() ?: return
|
||||
getChatClient()?.sendMessage(channelId, userId, text)
|
||||
}
|
||||
|
||||
fun messageStream(channelId: Long): Flow<Message> {
|
||||
_lastActiveChannel = channelId
|
||||
return getChatClient()?.messageStream(channelId) ?: emptyFlow()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package dev.zxq5.chatapp.android.data.repository
|
||||
|
||||
import dev.zxq5.chatapp.android.api.model.QrResponse
|
||||
import dev.zxq5.chatapp.android.api.SettingsClient
|
||||
import dev.zxq5.chatapp.android.api.model.TotpStatus
|
||||
import dev.zxq5.chatapp.android.core.data.TokenStore
|
||||
import dev.zxq5.chatapp.android.core.error.ApiResult
|
||||
|
||||
class SettingsRepository(private val tokenStore: TokenStore) {
|
||||
|
||||
private var _settingsClient: SettingsClient? = null
|
||||
private var _lastToken: String? = null
|
||||
|
||||
private fun getSettingsClient(): SettingsClient? {
|
||||
val token = tokenStore.get() ?: return null
|
||||
if (_settingsClient == null || token != _lastToken) {
|
||||
_settingsClient = SettingsClient(token)
|
||||
_lastToken = token
|
||||
}
|
||||
return _settingsClient
|
||||
}
|
||||
|
||||
fun resetClient() {
|
||||
_settingsClient = null
|
||||
_lastToken = null
|
||||
}
|
||||
|
||||
suspend fun getTotpQr(password: String): ApiResult<QrResponse?> {
|
||||
val settingsClient = getSettingsClient() ?: return ApiResult.NetworkError("Not authenticated")
|
||||
return settingsClient.getTotpQr(password)
|
||||
}
|
||||
|
||||
suspend fun confirmTotp(code: String): ApiResult<Unit> {
|
||||
val client = getSettingsClient() ?: return ApiResult.NetworkError("Not authenticated")
|
||||
return client.confirmTotp(code)
|
||||
}
|
||||
|
||||
suspend fun getTotpStatus(): ApiResult<TotpStatus> {
|
||||
return getSettingsClient()?.getTotpStatus() ?: ApiResult.NetworkError("Not authenticated")
|
||||
}
|
||||
|
||||
suspend fun disableTotp(password: String, totpCode: String): ApiResult<Unit> {
|
||||
val client = getSettingsClient() ?: return ApiResult.NetworkError("Not authenticated")
|
||||
return client.disableTotp(password, totpCode)
|
||||
}
|
||||
|
||||
suspend fun changePassword(old: String, new: String): ApiResult<Unit> {
|
||||
return getSettingsClient()?.changePassword(old, new) ?: ApiResult.NetworkError("Not authenticated")
|
||||
}
|
||||
|
||||
suspend fun updateDisplayName(name: String?): Boolean {
|
||||
return getSettingsClient()?.updateDisplayName(name) ?: false
|
||||
}
|
||||
|
||||
suspend fun updateUsername(username: String): ApiResult<Unit> {
|
||||
return getSettingsClient()?.updateUsername(username) ?: ApiResult.NetworkError("Not authenticated")
|
||||
}
|
||||
|
||||
suspend fun deleteAccount(password: String, totpCode: String?): ApiResult<Unit> {
|
||||
return getSettingsClient()?.deleteAccount(password, totpCode) ?: ApiResult.NetworkError("Not authenticated")
|
||||
}
|
||||
|
||||
fun logout() {
|
||||
tokenStore.clear()
|
||||
resetClient()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package dev.zxq5.chatapp.android.feature.auth
|
||||
|
||||
enum class AuthMode {
|
||||
LOGIN, SIGNUP
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package dev.zxq5.chatapp.android.feature.auth
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import dev.zxq5.chatapp.android.model.LoginState
|
||||
|
||||
@Composable
|
||||
fun AuthScreen(viewModel: AuthViewModel) {
|
||||
val loginState by viewModel.loginState.collectAsState()
|
||||
val authMode by viewModel.authMode.collectAsState()
|
||||
val totpError by viewModel.totpError.collectAsState()
|
||||
|
||||
if (loginState is LoginState.TwoFactorRequired ||
|
||||
(loginState is LoginState.Loading && totpError != null)) {
|
||||
TwoFactorLoginScreen(
|
||||
onVerify = { viewModel.verifyTotpLogin(it) },
|
||||
onBack = {
|
||||
viewModel.clearTotpError()
|
||||
viewModel.setAuthMode(AuthMode.LOGIN)
|
||||
},
|
||||
isLoading = loginState is LoginState.Loading,
|
||||
error = totpError
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (authMode == AuthMode.SIGNUP) {
|
||||
SignupScreen(
|
||||
viewModel = viewModel,
|
||||
onSwitchToLogin = { viewModel.setAuthMode(AuthMode.LOGIN) }
|
||||
)
|
||||
} else {
|
||||
LoginScreen(
|
||||
viewModel = viewModel,
|
||||
onSwitchToSignup = { viewModel.setAuthMode(AuthMode.SIGNUP) }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package dev.zxq5.chatapp.android.feature.auth
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dev.zxq5.chatapp.android.core.service.MessageStreamService
|
||||
import dev.zxq5.chatapp.android.data.repository.AuthRepository
|
||||
import dev.zxq5.chatapp.android.data.repository.LoginResult
|
||||
import dev.zxq5.chatapp.android.data.repository.SignupResult
|
||||
import dev.zxq5.chatapp.android.data.repository.AuthState
|
||||
import dev.zxq5.chatapp.android.model.LoginState
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class AuthViewModel(private val authRepository: AuthRepository) : ViewModel() {
|
||||
|
||||
private val _loginState = MutableStateFlow<LoginState>(LoginState.Idle)
|
||||
val loginState: StateFlow<LoginState> = _loginState
|
||||
|
||||
private val _authMode = MutableStateFlow(AuthMode.LOGIN)
|
||||
val authMode: StateFlow<AuthMode> = _authMode
|
||||
|
||||
private val _authState = MutableStateFlow(authRepository.getAuthState())
|
||||
val authState: StateFlow<AuthState> = _authState
|
||||
|
||||
private val _totpError = MutableStateFlow<String?>(null)
|
||||
val totpError: StateFlow<String?> = _totpError
|
||||
|
||||
fun setAuthMode(mode: AuthMode) {
|
||||
_authMode.value = mode
|
||||
if (_loginState.value is LoginState.Error) {
|
||||
_loginState.value = LoginState.Idle
|
||||
}
|
||||
}
|
||||
|
||||
fun signup(username: String, email: String, password: String, accessToken: String) {
|
||||
viewModelScope.launch {
|
||||
_loginState.value = LoginState.Loading
|
||||
when (val result = authRepository.signup(username, email, password, accessToken)) {
|
||||
is SignupResult.Success -> {
|
||||
updateAuthState()
|
||||
_loginState.value = LoginState.Success
|
||||
}
|
||||
is SignupResult.Error -> {
|
||||
_loginState.value = LoginState.Error(result.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun login(username: String, password: String) {
|
||||
viewModelScope.launch {
|
||||
_loginState.value = LoginState.Loading
|
||||
when (val result = authRepository.login(username, password)) {
|
||||
is LoginResult.Success -> {
|
||||
updateAuthState()
|
||||
_loginState.value = LoginState.Success
|
||||
}
|
||||
is LoginResult.TotpRequired -> {
|
||||
updateAuthState()
|
||||
_loginState.value = LoginState.TwoFactorRequired
|
||||
}
|
||||
is LoginResult.Error -> {
|
||||
_loginState.value = LoginState.Error(result.message)
|
||||
}
|
||||
is LoginResult.TotpError -> {
|
||||
_loginState.value = LoginState.Error(result.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun verifyTotpLogin(code: String) {
|
||||
viewModelScope.launch {
|
||||
_loginState.value = LoginState.Loading
|
||||
when (val result = authRepository.verifyTotpLogin(code)) {
|
||||
is LoginResult.Success -> {
|
||||
updateAuthState()
|
||||
_loginState.value = LoginState.Success
|
||||
}
|
||||
is LoginResult.TotpError -> {
|
||||
_totpError.value = result.message
|
||||
_loginState.value = LoginState.TwoFactorRequired
|
||||
}
|
||||
is LoginResult.Error -> {
|
||||
_loginState.value = LoginState.Error(result.message)
|
||||
}
|
||||
is LoginResult.TotpRequired -> {
|
||||
_loginState.value = LoginState.TwoFactorRequired
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun logout() {
|
||||
authRepository.logout()
|
||||
updateAuthState()
|
||||
_loginState.value = LoginState.Idle
|
||||
}
|
||||
|
||||
private fun updateAuthState() {
|
||||
_authState.value = authRepository.getAuthState()
|
||||
}
|
||||
|
||||
fun clearTotpError() {
|
||||
_totpError.value = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package dev.zxq5.chatapp.android.feature.auth
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
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.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.zxq5.chatapp.android.model.LoginState
|
||||
import dev.zxq5.chatapp.android.ui.components.TextField
|
||||
|
||||
@Composable
|
||||
fun LoginScreen(
|
||||
viewModel: AuthViewModel,
|
||||
onSwitchToSignup: () -> Unit
|
||||
) {
|
||||
val loginState by viewModel.loginState.collectAsState()
|
||||
var username by remember { mutableStateOf("") }
|
||||
var password by remember { mutableStateOf("") }
|
||||
var localError by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
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 = "welcome back",
|
||||
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"
|
||||
)
|
||||
|
||||
TextField(
|
||||
value = password,
|
||||
onValueChange = { password = it },
|
||||
label = "password",
|
||||
isPassword = true
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(32.dp))
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
localError = null
|
||||
if (username.isBlank() || password.isBlank()) {
|
||||
localError = "fill all fields"
|
||||
return@Button
|
||||
}
|
||||
viewModel.login(username, password)
|
||||
},
|
||||
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("login", 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 = onSwitchToSignup) {
|
||||
Text(
|
||||
"no account? sign up",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package dev.zxq5.chatapp.android.feature.auth
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
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.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.zxq5.chatapp.android.model.LoginState
|
||||
import dev.zxq5.chatapp.android.ui.components.TextField
|
||||
|
||||
@Composable
|
||||
fun SignupScreen(
|
||||
viewModel: AuthViewModel,
|
||||
onSwitchToLogin: () -> Unit
|
||||
) {
|
||||
val loginState by viewModel.loginState.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) }
|
||||
|
||||
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 = "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"
|
||||
)
|
||||
|
||||
TextField(
|
||||
value = email,
|
||||
onValueChange = { email = it },
|
||||
label = "email"
|
||||
)
|
||||
|
||||
TextField(
|
||||
value = password,
|
||||
onValueChange = { password = it },
|
||||
label = "password",
|
||||
isPassword = true
|
||||
)
|
||||
|
||||
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 (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("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 = onSwitchToLogin) {
|
||||
Text(
|
||||
"have account? login",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package dev.zxq5.chatapp.android.feature.auth
|
||||
|
||||
object TokenScope {
|
||||
const val FULL = "full"
|
||||
const val TOTP_PENDING = "totp_pending"
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package dev.zxq5.chatapp.android.feature.auth
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
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.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
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.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.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
|
||||
|
||||
@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))
|
||||
}
|
||||
|
||||
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,130 @@
|
||||
package dev.zxq5.chatapp.android.feature.chat
|
||||
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dev.zxq5.chatapp.android.api.model.Channel
|
||||
import dev.zxq5.chatapp.android.data.repository.ChatRepository
|
||||
import dev.zxq5.chatapp.android.api.model.Message
|
||||
import dev.zxq5.chatapp.android.api.model.Space
|
||||
import dev.zxq5.chatapp.android.api.model.SpaceDto
|
||||
import dev.zxq5.chatapp.android.core.service.MessageStreamService
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class ChatViewModel(private val chatRepository: ChatRepository) : ViewModel() {
|
||||
|
||||
private val _messages = MutableStateFlow<List<Message>>(emptyList())
|
||||
val messages: StateFlow<List<Message>> = _messages
|
||||
|
||||
private val _channelId = MutableStateFlow<Long?>(null)
|
||||
val channelId: StateFlow<Long?> = _channelId
|
||||
|
||||
private val _currentScreen = MutableStateFlow(Screen.CHAT)
|
||||
val currentScreen: StateFlow<Screen> = _currentScreen
|
||||
|
||||
private val _currentUserId = MutableStateFlow<Int?>(null)
|
||||
val currentUserId: StateFlow<Int?> = _currentUserId
|
||||
|
||||
private val _spaces = MutableStateFlow<List<SpaceDto>>(emptyList())
|
||||
val spaces: StateFlow<List<SpaceDto>> = _spaces
|
||||
|
||||
private val _error = MutableStateFlow<String?>(null)
|
||||
val error: StateFlow<String?> = _error
|
||||
|
||||
private val _channelError = MutableStateFlow<String?>(null)
|
||||
val channelError: StateFlow<String?> = _channelError
|
||||
|
||||
private var streamJob: Job? = null
|
||||
|
||||
init {
|
||||
_currentUserId.value = chatRepository.getUserId()
|
||||
observeChannel()
|
||||
loadAccessibleChannels()
|
||||
}
|
||||
|
||||
fun loadAccessibleChannels() {
|
||||
_error.value = null
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
chatRepository.getAccessibleChannels()
|
||||
}.onSuccess { data ->
|
||||
_spaces.value = data
|
||||
}.onFailure { e ->
|
||||
Log.e("Chat", "Failed to load spaces", e)
|
||||
_error.value = "Failed to load channels: ${e.message}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun observeChannel() {
|
||||
viewModelScope.launch {
|
||||
_channelId.collect { id ->
|
||||
streamJob?.cancel()
|
||||
_messages.value = emptyList()
|
||||
_channelError.value = null
|
||||
if (id != null) {
|
||||
streamJob = launch {
|
||||
chatRepository.messageStream(id)
|
||||
.catch { e ->
|
||||
Log.e("Chat", "Stream error", e)
|
||||
_channelError.value = "Connection lost: ${e.message}"
|
||||
}
|
||||
.collect { message ->
|
||||
_messages.update { it + message }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun navigateTo(screen: Screen) {
|
||||
_currentScreen.value = screen
|
||||
}
|
||||
|
||||
fun switchChannel(id: Long?) {
|
||||
_channelId.value = id
|
||||
|
||||
MessageStreamService.instance?.activeChannelId = id
|
||||
|
||||
if (id != null) {
|
||||
// Refresh user ID just in case it wasn't available at init
|
||||
_currentUserId.value = chatRepository.getUserId()
|
||||
}
|
||||
}
|
||||
|
||||
fun sendMessage(text: String) {
|
||||
val currentId = _channelId.value ?: return
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
chatRepository.sendMessage(
|
||||
channelId = currentId,
|
||||
text = text
|
||||
)
|
||||
}.onFailure { e ->
|
||||
Log.e("Chat", "Send message error", e)
|
||||
_channelError.value = "Failed to send message"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clearChat() {
|
||||
_messages.value = emptyList()
|
||||
_channelId.value = null
|
||||
_currentUserId.value = null
|
||||
_error.value = null
|
||||
_channelError.value = null
|
||||
streamJob?.cancel()
|
||||
chatRepository.resetClient()
|
||||
MessageStreamService.instance?.activeChannelId = null
|
||||
}
|
||||
|
||||
fun clearChannelError() {
|
||||
_channelError.value = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package dev.zxq5.chatapp.android.feature.chat
|
||||
|
||||
enum class Screen {
|
||||
CHAT, CONTACTS, SETTINGS
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
package dev.zxq5.chatapp.android.feature.chat
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
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.filled.Add
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.Send
|
||||
import androidx.compose.material3.Button
|
||||
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.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
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.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.zxq5.chatapp.android.api.model.Channel
|
||||
import dev.zxq5.chatapp.android.api.model.Message
|
||||
import java.text.DateFormat
|
||||
import java.util.Date
|
||||
import kotlin.time.ExperimentalTime
|
||||
|
||||
@Composable
|
||||
fun ChatScreen(
|
||||
viewModel: ChatViewModel,
|
||||
onNavigateToSettings: () -> Unit,
|
||||
onLogout: () -> Unit
|
||||
) {
|
||||
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: (Long) -> Unit
|
||||
) {
|
||||
val spaces by viewModel.spaces.collectAsState()
|
||||
val error by viewModel.error.collectAsState()
|
||||
|
||||
Scaffold(
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
topBar = {
|
||||
Column {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
"messages",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = Color.Transparent,
|
||||
titleContentColor = MaterialTheme.colorScheme.onSurface
|
||||
),
|
||||
windowInsets = androidx.compose.foundation.layout.WindowInsets(0, 0, 0, 0),
|
||||
|
||||
)
|
||||
Text(
|
||||
"Public channels - dms coming soon.",
|
||||
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))
|
||||
}
|
||||
}
|
||||
) { padding ->
|
||||
if (error != null) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().padding(padding),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text(
|
||||
text = error!!,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(16.dp)
|
||||
)
|
||||
Button(onClick = { viewModel.loadAccessibleChannels() }) {
|
||||
Icon(Icons.Default.Refresh, contentDescription = null)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Retry")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
LazyColumn(modifier = Modifier.padding(padding).fillMaxSize()) {
|
||||
spaces.forEach { spaceDto ->
|
||||
item {
|
||||
Text(
|
||||
text = spaceDto.name.lowercase(),
|
||||
modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
items(spaceDto.channels) { channel ->
|
||||
ChannelItem(channel = channel, onClick = { onChannelSelect(channel.id) })
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(horizontal = 20.dp),
|
||||
thickness = 0.5.dp,
|
||||
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.2f)
|
||||
)
|
||||
}
|
||||
item {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChannelItem(channel: Channel, 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(
|
||||
channel.name.take(1).uppercase(),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = channel.name,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
if (channel.description != null) {
|
||||
Text(
|
||||
text = channel.description,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun MessageScreen(channelId: Long, viewModel: ChatViewModel, onBack: () -> Unit) {
|
||||
val messages by viewModel.messages.collectAsState()
|
||||
val currentUserId by viewModel.currentUserId.collectAsState()
|
||||
val channelError by viewModel.channelError.collectAsState()
|
||||
var input by remember { mutableStateOf("") }
|
||||
val listState = rememberLazyListState()
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
LaunchedEffect(messages.size) {
|
||||
if (messages.isNotEmpty()) {
|
||||
listState.animateScrollToItem(messages.size - 1)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(channelError) {
|
||||
channelError?.let {
|
||||
snackbarHostState.showSnackbar(it)
|
||||
viewModel.clearChannelError()
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
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
|
||||
)
|
||||
}
|
||||
},
|
||||
windowInsets = androidx.compose.foundation.layout.WindowInsets(0, 0, 0, 0),
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTime::class)
|
||||
@Composable
|
||||
fun MessageBubble(message: Message, currentUserId: Int?) {
|
||||
val time = remember(message.timestamp) {
|
||||
DateFormat.getTimeInstance(DateFormat.SHORT)
|
||||
.format(Date(message.timestamp.toEpochMilliseconds()))
|
||||
.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() ?: "unknown",
|
||||
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: Dp, color: Color) =
|
||||
BorderStroke(width, color)
|
||||
@@ -0,0 +1,52 @@
|
||||
package dev.zxq5.chatapp.android.feature.contacts
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ContactsScreen() {
|
||||
Scaffold(
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
"contacts",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
},
|
||||
windowInsets = androidx.compose.foundation.layout.WindowInsets(0, 0, 0, 0),
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = Color.Transparent,
|
||||
titleContentColor = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "Contacts coming soon",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package dev.zxq5.chatapp.android.feature.settings
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dev.zxq5.chatapp.android.api.model.QrResponse
|
||||
import dev.zxq5.chatapp.android.core.error.ApiResult
|
||||
import dev.zxq5.chatapp.android.data.repository.SettingsRepository
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class SettingsViewModel(private val settingsRepository: SettingsRepository) : ViewModel() {
|
||||
|
||||
private val _is2faEnabled = MutableStateFlow(false)
|
||||
val is2faEnabled: StateFlow<Boolean> = _is2faEnabled
|
||||
|
||||
private val _totpQr = MutableStateFlow<QrResponse?>(null)
|
||||
val totpQr: StateFlow<QrResponse?> = _totpQr
|
||||
|
||||
private val _totpError = MutableStateFlow<String?>(null)
|
||||
val totpError: StateFlow<String?> = _totpError
|
||||
|
||||
private val _settingsError = MutableStateFlow<String?>(null)
|
||||
val settingsError: StateFlow<String?> = _settingsError
|
||||
|
||||
private val _isSuccessState = MutableStateFlow<Map<String, Boolean>>(emptyMap())
|
||||
val isSuccessState: StateFlow<Map<String, Boolean>> = _isSuccessState
|
||||
|
||||
fun clearMessages() {
|
||||
_settingsError.value = null
|
||||
_totpError.value = null
|
||||
}
|
||||
|
||||
private fun triggerSuccess(key: String) {
|
||||
viewModelScope.launch {
|
||||
_isSuccessState.value = _isSuccessState.value + (key to true)
|
||||
delay(5000)
|
||||
_isSuccessState.value = _isSuccessState.value + (key to false)
|
||||
}
|
||||
}
|
||||
|
||||
fun fetchTotpStatus() {
|
||||
viewModelScope.launch {
|
||||
when (val result = settingsRepository.getTotpStatus()) {
|
||||
is ApiResult.Success -> _is2faEnabled.value = result.data.isEnabled
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun fetchTotpQr(password: String) {
|
||||
viewModelScope.launch {
|
||||
_totpError.value = null
|
||||
when (val result = settingsRepository.getTotpQr(password)) {
|
||||
is ApiResult.Success -> {
|
||||
_totpQr.value = result.data
|
||||
}
|
||||
is ApiResult.HttpError -> {
|
||||
_totpError.value = result.message
|
||||
}
|
||||
is ApiResult.NetworkError -> {
|
||||
_totpError.value = "Network error: ${result.message}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun confirmTotp(code: String) {
|
||||
viewModelScope.launch {
|
||||
_totpError.value = null
|
||||
when (val result = settingsRepository.confirmTotp(code)) {
|
||||
is ApiResult.Success -> {
|
||||
_is2faEnabled.value = true
|
||||
_totpQr.value = null
|
||||
triggerSuccess("2fa")
|
||||
}
|
||||
is ApiResult.HttpError -> {
|
||||
_totpError.value = result.message
|
||||
}
|
||||
is ApiResult.NetworkError -> {
|
||||
_totpError.value = "Network error: ${result.message}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun disableTotp(password: String, totpCode: String) {
|
||||
viewModelScope.launch {
|
||||
_totpError.value = null
|
||||
when (val result = settingsRepository.disableTotp(password, totpCode)) {
|
||||
is ApiResult.Success -> {
|
||||
_is2faEnabled.value = false
|
||||
triggerSuccess("2fa")
|
||||
}
|
||||
is ApiResult.HttpError -> _totpError.value = result.message
|
||||
is ApiResult.NetworkError -> _totpError.value = "Network error: ${result.message}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun changePassword(old: String, new: String) {
|
||||
viewModelScope.launch {
|
||||
clearMessages()
|
||||
when (val result = settingsRepository.changePassword(old, new)) {
|
||||
is ApiResult.Success -> {
|
||||
triggerSuccess("password")
|
||||
}
|
||||
is ApiResult.HttpError -> _settingsError.value = result.message
|
||||
is ApiResult.NetworkError -> _settingsError.value = "Network error: ${result.message}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun updateDisplayName(name: String?) {
|
||||
viewModelScope.launch {
|
||||
clearMessages()
|
||||
if (settingsRepository.updateDisplayName(name)) {
|
||||
triggerSuccess("display_name")
|
||||
} else {
|
||||
_settingsError.value = "Failed to update display name"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun updateUsername(username: String) {
|
||||
viewModelScope.launch {
|
||||
clearMessages()
|
||||
when (val result = settingsRepository.updateUsername(username)) {
|
||||
is ApiResult.Success -> {
|
||||
triggerSuccess("username")
|
||||
}
|
||||
is ApiResult.HttpError -> _settingsError.value = result.message
|
||||
is ApiResult.NetworkError -> _settingsError.value = "Network error: ${result.message}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteAccount(password: String, totpCode: String?, onLogout: () -> Unit) {
|
||||
viewModelScope.launch {
|
||||
clearMessages()
|
||||
when (val result = settingsRepository.deleteAccount(password, totpCode)) {
|
||||
is ApiResult.Success -> {
|
||||
_isSuccessState.value = _isSuccessState.value + ("delete" to true)
|
||||
delay(3000)
|
||||
onLogout()
|
||||
}
|
||||
is ApiResult.HttpError -> _settingsError.value = result.message
|
||||
is ApiResult.NetworkError -> _settingsError.value = "Network error: ${result.message}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun logout() {
|
||||
settingsRepository.logout()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,525 @@
|
||||
package dev.zxq5.chatapp.android.feature.settings
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.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.material.icons.filled.KeyboardArrowDown
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowUp
|
||||
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.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.asImageBitmap
|
||||
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 android.util.Base64
|
||||
import android.graphics.BitmapFactory
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SettingsScreen(
|
||||
viewModel: SettingsViewModel,
|
||||
onLogout: () -> Unit
|
||||
) {
|
||||
val is2faEnabled by viewModel.is2faEnabled.collectAsState()
|
||||
val totpQr by viewModel.totpQr.collectAsState()
|
||||
val settingsError by viewModel.settingsError.collectAsState()
|
||||
val isSuccessState by viewModel.isSuccessState.collectAsState()
|
||||
val totpError by viewModel.totpError.collectAsState()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.clearMessages()
|
||||
viewModel.fetchTotpStatus()
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
"settings",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
},
|
||||
windowInsets = androidx.compose.foundation.layout.WindowInsets(0, 0, 0, 0),
|
||||
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent)
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(padding)
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
SettingsSection(title = "profile") {
|
||||
var displayName by remember { mutableStateOf("") }
|
||||
var username by remember { mutableStateOf("") }
|
||||
|
||||
SettingsField(
|
||||
label = "display name",
|
||||
value = displayName,
|
||||
onValueChange = { displayName = it },
|
||||
buttonLabel = "update",
|
||||
isSuccess = isSuccessState["display_name"] == true,
|
||||
onClick = { viewModel.updateDisplayName(displayName.ifBlank { null }) }
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
SettingsField(
|
||||
label = "username",
|
||||
value = username,
|
||||
onValueChange = { username = it },
|
||||
buttonLabel = "update",
|
||||
isSuccess = isSuccessState["username"] == true,
|
||||
onClick = { if (username.isNotBlank()) viewModel.updateUsername(username) }
|
||||
)
|
||||
}
|
||||
|
||||
SettingsSection(title = "security") {
|
||||
var oldPassword by remember { mutableStateOf("") }
|
||||
var newPassword by remember { mutableStateOf("") }
|
||||
|
||||
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))
|
||||
SuccessButton(
|
||||
onClick = {
|
||||
viewModel.changePassword(oldPassword, newPassword)
|
||||
oldPassword = ""
|
||||
newPassword = ""
|
||||
},
|
||||
label = "update password",
|
||||
isSuccess = isSuccessState["password"] == true,
|
||||
enabled = oldPassword.isNotEmpty() && newPassword.isNotEmpty(),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp), color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.2f))
|
||||
|
||||
var show2faSetup by remember { mutableStateOf(false) }
|
||||
var setupPassword by remember { mutableStateOf("") }
|
||||
|
||||
var show2faDisable by remember { mutableStateOf(false) }
|
||||
var disablePassword by remember { mutableStateOf("") }
|
||||
var disableCode by remember { mutableStateOf("") }
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text("two-factor authentication", style = MaterialTheme.typography.bodyLarge)
|
||||
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 = !show2faSetup
|
||||
if (!show2faSetup) setupPassword = ""
|
||||
},
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), contentColor = MaterialTheme.colorScheme.onSurface)
|
||||
) {
|
||||
Text(if (show2faSetup) "cancel" else "setup", style = MaterialTheme.typography.labelSmall)
|
||||
}
|
||||
} else {
|
||||
SuccessButton(
|
||||
onClick = {
|
||||
show2faDisable = !show2faDisable
|
||||
if (!show2faDisable) {
|
||||
disablePassword = ""
|
||||
disableCode = ""
|
||||
}
|
||||
},
|
||||
label = if (show2faDisable) "cancel" else "disable",
|
||||
isSuccess = isSuccessState["2fa"] == true,
|
||||
baseColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.1f),
|
||||
contentColor = Color.Red,
|
||||
successColor = Color.Red
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (show2faSetup && !is2faEnabled) {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
if (totpQr == null) {
|
||||
OutlinedTextField(
|
||||
value = setupPassword,
|
||||
onValueChange = { setupPassword = it },
|
||||
label = { Text("confirm password to setup") },
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Button(
|
||||
onClick = { viewModel.fetchTotpQr(setupPassword) },
|
||||
enabled = setupPassword.isNotBlank(),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text("get qr code")
|
||||
}
|
||||
} else {
|
||||
TwoFactorSetup(
|
||||
qrCodeBase64 = totpQr?.qr_code,
|
||||
error = totpError,
|
||||
onConfirm = { viewModel.confirmTotp(it) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (show2faDisable && is2faEnabled) {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
OutlinedTextField(
|
||||
value = disablePassword,
|
||||
onValueChange = { disablePassword = it },
|
||||
label = { Text("password") },
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
value = disableCode,
|
||||
onValueChange = { if (it.length <= 6) disableCode = it },
|
||||
label = { Text("2fa code") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
SuccessButton(
|
||||
onClick = { viewModel.disableTotp(disablePassword, disableCode) },
|
||||
label = "confirm disable",
|
||||
isSuccess = isSuccessState["2fa"] == true,
|
||||
baseColor = Color.Red,
|
||||
enabled = disablePassword.isNotBlank() && disableCode.length == 6,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
|
||||
if (totpError != null && !show2faSetup && !show2faDisable) {
|
||||
Text(totpError!!.lowercase(), color = Color.Red, style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(top = 8.dp))
|
||||
}
|
||||
}
|
||||
|
||||
SettingsSection(title = "danger zone", color = Color.Red.copy(alpha = 0.7f)) {
|
||||
var deletePassword by remember { mutableStateOf("") }
|
||||
var deleteTotp by remember { mutableStateOf("") }
|
||||
var showDeleteConfirm by remember { mutableStateOf(false) }
|
||||
|
||||
if (!showDeleteConfirm) {
|
||||
Button(
|
||||
onClick = { showDeleteConfirm = true },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Color.Red.copy(alpha = 0.1f), contentColor = Color.Red)
|
||||
) {
|
||||
Text("delete account")
|
||||
}
|
||||
} else {
|
||||
Text("confirm account deletion", color = Color.Red, style = MaterialTheme.typography.bodyMedium)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
value = deletePassword,
|
||||
onValueChange = { deletePassword = it },
|
||||
label = { Text("password") },
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
)
|
||||
if (is2faEnabled) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
value = deleteTotp,
|
||||
onValueChange = { if (it.length <= 6) deleteTotp = it },
|
||||
label = { Text("2fa code") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(
|
||||
onClick = { showDeleteConfirm = false },
|
||||
modifier = Modifier.weight(1f),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)
|
||||
) {
|
||||
Text("cancel")
|
||||
}
|
||||
SuccessButton(
|
||||
onClick = { viewModel.deleteAccount(
|
||||
deletePassword, deleteTotp.ifBlank { null },
|
||||
onLogout
|
||||
) },
|
||||
label = "delete forever",
|
||||
isSuccess = isSuccessState["delete"] == true,
|
||||
baseColor = Color.Red.copy(alpha = 0.1f),
|
||||
contentColor = Color.Red,
|
||||
successColor = Color.Red,
|
||||
modifier = Modifier.weight(1f),
|
||||
enabled = deletePassword.isNotEmpty() && (!is2faEnabled || deleteTotp.length == 6)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingsSection(title = "session") {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Button(
|
||||
onClick = onLogout,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Color.White, contentColor = Color.Black)
|
||||
) {
|
||||
Text("logout")
|
||||
}
|
||||
}
|
||||
|
||||
if (settingsError != null) {
|
||||
Text(settingsError!!, color = Color.Red, style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(top = 8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SettingsSection(
|
||||
title: String,
|
||||
color: Color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(true) }
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.border(0.5.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.2f), RoundedCornerShape(12.dp))
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.5f))
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { expanded = !expanded }
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(title.lowercase(), style = MaterialTheme.typography.labelSmall, color = color)
|
||||
Icon(
|
||||
if (expanded) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown,
|
||||
contentDescription = null,
|
||||
tint = color,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
}
|
||||
|
||||
AnimatedVisibility(visible = expanded) {
|
||||
Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp).padding(bottom = 8.dp)) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SettingsField(
|
||||
label: String,
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
buttonLabel: String,
|
||||
isSuccess: Boolean,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Column {
|
||||
OutlinedTextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
label = { Text(label) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = if (isSuccess) Color.Green else MaterialTheme.colorScheme.primary
|
||||
)
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
SuccessButton(
|
||||
onClick = onClick,
|
||||
label = buttonLabel,
|
||||
isSuccess = isSuccess,
|
||||
enabled = value.isNotBlank(),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SuccessButton(
|
||||
onClick: () -> Unit,
|
||||
label: String,
|
||||
isSuccess: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
baseColor: Color = MaterialTheme.colorScheme.primary,
|
||||
contentColor: Color = MaterialTheme.colorScheme.onPrimary,
|
||||
successColor: Color = Color.Green.copy(alpha = 0.8f)
|
||||
) {
|
||||
val backgroundColor by animateColorAsState(
|
||||
targetValue = if (isSuccess) successColor else baseColor,
|
||||
animationSpec = tween(500)
|
||||
)
|
||||
|
||||
Button(
|
||||
onClick = onClick,
|
||||
modifier = modifier,
|
||||
enabled = enabled,
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = backgroundColor,
|
||||
contentColor = if (isSuccess) Color.White else contentColor
|
||||
)
|
||||
) {
|
||||
Text(if (isSuccess) "success" else label)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TwoFactorSetup(
|
||||
qrCodeBase64: String?,
|
||||
error: String?,
|
||||
onConfirm: (String) -> 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
|
||||
) {
|
||||
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)
|
||||
.padding(8.dp)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
CircularProgressIndicator(modifier = Modifier.size(40.dp))
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = code,
|
||||
onValueChange = { if (it.length <= 6) code = it },
|
||||
placeholder = { Text("000000") },
|
||||
modifier = Modifier.width(150.dp),
|
||||
textStyle = MaterialTheme.typography.headlineMedium.copy(textAlign = TextAlign.Center),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
)
|
||||
|
||||
if (error != null) {
|
||||
Text(error.lowercase(), color = Color.Red, style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(top = 8.dp))
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
|
||||
Button(
|
||||
onClick = { if (code.length == 6) onConfirm(code) },
|
||||
enabled = code.length == 6,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
Text("confirm code")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,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,5 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:autoMirrored="true" android:height="24dp" android:tint="#FFFFFF" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
|
||||
|
||||
<path android:fillColor="@android:color/white" android:pathData="M20,2L4,2c-1.1,0 -1.99,0.9 -1.99,2L2,22l4,-4h14c1.1,0 2,-0.9 2,-2L22,4c0,-1.1 -0.9,-2 -2,-2zM18,14L6,14v-2h12v2zM18,11L6,11L6,9h12v2zM18,8L6,8L6,6h12v2z"/>
|
||||
|
||||
</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>
|
||||