8 Commits

177 changed files with 10763 additions and 1063 deletions
+3
View File
@@ -1,6 +1,9 @@
*/target
.env
.log*
Cargo.lock
.cargo/
.sqlx/
docker-compose*
+10
View File
@@ -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/
+1550
View File
File diff suppressed because it is too large Load Diff
+12
View File
@@ -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>
+17
View File
@@ -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>
+13
View File
@@ -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>
+11
View File
@@ -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>
+6
View File
@@ -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>
+9
View File
@@ -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>
+6
View File
@@ -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>
+8
View File
@@ -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>
Generated
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>
+6
View File
@@ -10,6 +10,12 @@
"command": "clippy" // rust-analyzer.check.command (default: "check")
}
}
},
"nu": {
"binary": {
"path": "/home/fantasypvp/.cargo/bin/nu",
"arguments": ["--lsp"]
}
}
}
}
+18
View File
@@ -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/
+3
View File
@@ -0,0 +1,3 @@
# Default ignored files
/shelf/
/workspace.xml
+1
View File
@@ -0,0 +1 @@
Chatapp
+6
View File
@@ -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>
+6
View File
@@ -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>
+123
View File
@@ -0,0 +1,123 @@
<component name="ProjectCodeStyleConfiguration">
<code_scheme name="Project" version="173">
<JetCodeStyleSettings>
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</JetCodeStyleSettings>
<codeStyleSettings language="XML">
<option name="FORCE_REARRANGE_MODE" value="1" />
<indentOptions>
<option name="CONTINUATION_INDENT_SIZE" value="4" />
</indentOptions>
<arrangement>
<rules>
<section>
<rule>
<match>
<AND>
<NAME>xmlns:android</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>xmlns:.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:id</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:name</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>name</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>style</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
<order>ANDROID_ATTRIBUTE_ORDER</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>.*</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
</rules>
</arrangement>
</codeStyleSettings>
<codeStyleSettings language="kotlin">
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</codeStyleSettings>
</code_scheme>
</component>
+5
View File
@@ -0,0 +1,5 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
</state>
</component>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="21" />
</component>
</project>
+21
View File
@@ -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>
+13
View File
@@ -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>
+18
View File
@@ -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>
+61
View File
@@ -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>
+9
View File
@@ -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>
+17
View File
@@ -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>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="StudioBotProjectSettings">
<option name="shareContext" value="OptedIn" />
</component>
</project>
+6
View File
@@ -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>
+1
View File
@@ -0,0 +1 @@
/build
+107
View File
@@ -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)
}
+47
View File
@@ -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)
}
}
+41
View File
@@ -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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

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>

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