android frontend first implementation

This commit is contained in:
2026-04-01 19:52:59 +01:00
parent 7664433064
commit 24fe3ef543
75 changed files with 6362 additions and 0 deletions
+1550
View File
File diff suppressed because it is too large Load Diff
+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>
+15
View File
@@ -0,0 +1,15 @@
*.iml
.gradle
/local.properties
/.idea/caches
/.idea/libraries
/.idea/modules.xml
/.idea/workspace.xml
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
.DS_Store
/build
/captures
.externalNativeBuild
.cxx
local.properties
+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>
+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="deploymentTargetSelector">
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
</SelectionState>
<SelectionState runConfigName="MainActivity">
<option name="selectionMode" value="DROPDOWN" />
</SelectionState>
</selectionStates>
</component>
</project>
+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
+76
View File
@@ -0,0 +1,76 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.serialization)
}
android {
namespace = "dev.zxq5.chatapp.android"
compileSdk = 35
defaultConfig {
applicationId = "dev.zxq5.chatapp.android"
minSdk = 26
targetSdk = 35
versionCode = 1
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
buildFeatures {
compose = true
}
}
dependencies {
// Ktor client
implementation(libs.ktor.client.android)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.serialization.kotlinx.json)
implementation(libs.ktor.client.auth) // Auth plugin
// Kotlinx Serialization
implementation(libs.kotlinx.serialization.json)
// Coroutines
implementation(libs.kotlinx.coroutines.android)
// ViewModel
implementation(libs.androidx.lifecycle.viewmodel.compose)
// Encrypted storage for session cookie/token
implementation(libs.androidx.security.crypto)
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.graphics)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.foundation.layout)
implementation(libs.androidx.compose.material.icons.extended)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
debugImplementation(libs.androidx.compose.ui.tooling)
debugImplementation(libs.androidx.compose.ui.test.manifest)
}
+21
View File
@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
@@ -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)
}
}
+31
View File
@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:name=".ChatApplication"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Chatapp"
android:usesCleartextTraffic="true">
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:theme="@style/Theme.Chatapp">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -0,0 +1,11 @@
package dev.zxq5.chatapp.android
import android.app.Application
import dev.zxq5.chatapp.android.api.ApiClient
class ChatApplication : Application() {
override fun onCreate() {
super.onCreate()
ApiClient.init(this)
}
}
@@ -0,0 +1,50 @@
package dev.zxq5.chatapp.android
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.viewmodel.compose.viewModel
import dev.zxq5.chatapp.android.model.ChatViewModel
import dev.zxq5.chatapp.android.model.LoginState
import dev.zxq5.chatapp.android.model.MainScreen
import dev.zxq5.chatapp.android.ui.components.AuthScreen
import dev.zxq5.chatapp.android.ui.components.ChatScreen
import dev.zxq5.chatapp.android.ui.components.SettingsScreen
import dev.zxq5.chatapp.android.ui.theme.ChatappTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
ChatappTheme {
val viewModel: ChatViewModel = viewModel()
val loginState by viewModel.loginState.collectAsState()
val currentScreen by viewModel.currentScreen.collectAsState()
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
androidx.compose.foundation.layout.Box(modifier = Modifier.padding(innerPadding)) {
if (loginState is LoginState.Success) {
when (currentScreen) {
MainScreen.CHAT -> ChatScreen(viewModel = viewModel)
MainScreen.SETTINGS -> SettingsScreen(viewModel = viewModel)
}
} else {
AuthScreen(
viewModel = viewModel,
onSuccess = { }
)
}
}
}
}
}
}
}
@@ -0,0 +1,361 @@
package dev.zxq5.chatapp.android.api
import android.content.Context
import android.util.Log
import dev.zxq5.chatapp.android.core.data.TokenStore.getScopeFromToken
import dev.zxq5.chatapp.android.model.LoginRequest
import dev.zxq5.chatapp.android.model.LoginResponse
import dev.zxq5.chatapp.android.model.Message
import dev.zxq5.chatapp.android.model.SendMessage
import dev.zxq5.chatapp.android.model.SignupRequest
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.engine.android.Android
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.request.get
import io.ktor.client.request.post
import io.ktor.client.request.delete
import io.ktor.client.request.prepareGet
import io.ktor.client.request.setBody
import io.ktor.client.statement.bodyAsChannel
import io.ktor.http.ContentType
import io.ktor.http.contentType
import io.ktor.http.isSuccess
import io.ktor.serialization.kotlinx.json.json
import io.ktor.utils.io.readUTF8Line
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import io.ktor.client.plugins.auth.Auth
import io.ktor.client.plugins.auth.providers.BearerTokens
import io.ktor.client.plugins.auth.providers.bearer
import io.ktor.http.encodedPath
import dev.zxq5.chatapp.android.core.BASE_URL
import dev.zxq5.chatapp.android.core.data.TokenStore
import dev.zxq5.chatapp.android.core.data.TokenStore.getUserIdFromToken
import dev.zxq5.chatapp.android.core.error.ApiResult
import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
@Serializable
data class QrResponse(val qr_code: String)
@Serializable
data class TOTPSixDigitCode(val code: String)
@Serializable(with = TotpStatusSerializer::class)
enum class TotpStatus {
ENABLED, DISABLED;
val isEnabled: Boolean get() = this == ENABLED
}
object TotpStatusSerializer : KSerializer<TotpStatus> {
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("TotpStatus", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: TotpStatus) = encoder.encodeString(value.name.lowercase())
override fun deserialize(decoder: Decoder): TotpStatus =
TotpStatus.valueOf(decoder.decodeString().uppercase())
}
@Serializable
data class TotpStatusResponse(val status: TotpStatus)
@Serializable
data class PasswordChangeRequest(val old_password: String, val new_password: String)
@Serializable
data class DisplayNameRequest(val display_name: String?)
object ApiClient {
private lateinit var appContext: Context
fun init(context: Context) {
appContext = context.applicationContext
}
fun hasToken(): Boolean {
return TokenStore.get(appContext) != null
}
fun getTokenScope(): String? {
val token = TokenStore.get(appContext) ?: return null
val scope = getScopeFromToken(token)
Log.d("Chat", "Current token scope: $scope")
return scope
}
fun getStoredUserId(): Int? {
return TokenStore.getUserId(appContext)
}
fun is2faEnabledLocal(): Boolean {
return TokenStore.is2faEnabled(appContext)
}
fun set2faEnabledLocal(enabled: Boolean) {
TokenStore.save2faEnabled(appContext, enabled)
}
private var _http: HttpClient? = null
val http: HttpClient
get() = synchronized(this) {
_http ?: createClient().also { _http = it }
}
private fun createClient(): HttpClient {
return HttpClient(Android) {
install(ContentNegotiation) {
json(Json { ignoreUnknownKeys = true })
}
install(Auth) {
bearer {
loadTokens {
val token = TokenStore.get(appContext) ?: return@loadTokens null
Log.d("Chat", "Auth plugin loading token: ${getScopeFromToken(token)}")
BearerTokens(token, "")
}
sendWithoutRequest { request ->
val path = request.url.encodedPath
!path.endsWith("/login") && !path.endsWith("/signup")
}
}
}
}
}
private fun resetClient() {
Log.d("Chat", "Resetting HttpClient to refresh tokens")
_http?.close()
_http = null
}
suspend fun login(username: String, password: String): ApiResult<LoginResponse> {
return try {
val response = http.post("${BASE_URL}/api/login") {
contentType(ContentType.Application.Json)
setBody(LoginRequest(username, password))
}
if (response.status.isSuccess()) {
val body = response.body<LoginResponse>()
Log.d("Chat", "Login token scope: ${getScopeFromToken(body.token)}")
TokenStore.save(appContext, body.token)
resetClient()
ApiResult.Success(body)
} else {
ApiResult.HttpError(
status = response.status.value,
message = when (response.status.value) {
401 -> "Invalid username or password"
403 -> "Account suspended"
429 -> "Too many attempts, please wait"
else -> "Login failed (${response.status.value})"
}
)
}
} catch (e: Exception) {
Log.e("Chat", "Login network error", e)
ApiResult.NetworkError(e.localizedMessage ?: "Network error")
}
}
suspend fun signup(username: String, email: String, password: String, token: String): ApiResult<LoginResponse> {
return try {
val response = http.post("${BASE_URL}/api/signup") {
contentType(ContentType.Application.Json)
setBody(SignupRequest(username = username, email = email, password = password, access_token = token))
}
if (response.status.isSuccess()) {
val body = response.body<LoginResponse>()
TokenStore.save(appContext, body.token)
resetClient()
ApiResult.Success(body)
} else {
ApiResult.HttpError(
status = response.status.value,
message = when (response.status.value) {
401 -> "Invalid access token"
else -> "Signup failed (${response.status.value})"
}
)
}
} catch (e: Exception) {
Log.e("Chat", "Signup error", e)
ApiResult.NetworkError(e.localizedMessage ?: "Network error")
}
}
fun logout() {
TokenStore.clear(appContext)
resetClient()
}
suspend fun sendMessage(channelId: Int, text: String) {
val userId = TokenStore.getUserId(appContext) ?: return
http.post("${BASE_URL}/api/chat/$channelId") {
contentType(ContentType.Application.Json)
setBody(SendMessage(
user_id = userId,
text = text,
timestamp = System.currentTimeMillis()
))
}
}
fun messageStream(channelId: Int): Flow<Message> = flow {
http.prepareGet("${BASE_URL}/api/events/$channelId").execute { response ->
val channel = response.bodyAsChannel()
while (!channel.isClosedForRead) {
val line = channel.readUTF8Line(256) ?: break
if (line.startsWith("data:")) {
val json = line.removePrefix("data:").trim()
runCatching { Json.decodeFromString<Message>(json) }
.onSuccess { emit(it) }
}
}
}
}
suspend fun getTotpQr(): QrResponse? {
return try {
http.get("${BASE_URL}/api/totp.jpg").body<QrResponse>()
} catch (e: Exception) {
Log.e("Chat", "Error fetching TOTP QR", e)
null
}
}
suspend fun confirmTotp(code: String): Boolean {
return try {
val response = http.post("${BASE_URL}/api/totp") {
contentType(ContentType.Application.Json)
setBody(TOTPSixDigitCode(code))
}
if (response.status.isSuccess()) {
// If confirming TOTP returns a new token (e.g. from partial to full), we should save it
// Assuming confirm might return a LoginResponse if it upgrades the session
runCatching {
val body = response.body<LoginResponse>()
TokenStore.save(appContext, body.token)
resetClient()
}
set2faEnabledLocal(true)
true
} else {
false
}
} catch (e: Exception) {
Log.e("Chat", "Error confirming TOTP", e)
false
}
}
suspend fun verifyTotpLogin(code: String): ApiResult<LoginResponse> {
return try {
val response = http.post("${BASE_URL}/api/totp/verify") {
contentType(ContentType.Application.Json)
setBody(TOTPSixDigitCode(code))
}
if (response.status.isSuccess()) {
val body = response.body<LoginResponse>()
val tok = body.token;
Log.d("Chat", "UID ${getUserIdFromToken(tok)}");
Log.d("Chat", "Token ${getScopeFromToken(tok)}");
TokenStore.save(appContext, body.token)
resetClient()
ApiResult.Success(body)
} else {
val errorText = try { response.body<String>() } catch (e: Exception) { "Unknown error" }
Log.e("Chat", "TOTP verify failed: ${response.status.value} - $errorText")
ApiResult.HttpError(
status = response.status.value,
message = when (response.status.value) {
401 -> "Incorrect code, please try again"
403 -> "Session expired, please log in again"
429 -> "Too many attempts, please wait"
else -> "Verification failed (${response.status.value})"
}
)
}
} catch (e: Exception) {
Log.e("Chat", "TOTP verify network error", e)
ApiResult.NetworkError(e.localizedMessage ?: "Network error")
}
}
suspend fun getTotpStatus(): Boolean {
return try {
val response = http.get("${BASE_URL}/api/totp/status")
if (response.status.isSuccess()) {
val status = response.body<TotpStatus>()
val enabled = status.isEnabled
set2faEnabledLocal(enabled)
enabled
} else {
is2faEnabledLocal()
}
} catch (e: Exception) {
Log.e("Chat", "Error getting TOTP status", e)
is2faEnabledLocal()
}
}
suspend fun disableTotp(): ApiResult<LoginResponse> {
return try {
val response = http.delete("${BASE_URL}/api/totp")
if (response.status.isSuccess()) {
val body = response.body<LoginResponse>()
TokenStore.save(appContext, body.token)
set2faEnabledLocal(false)
resetClient()
ApiResult.Success(body)
} else {
ApiResult.HttpError(response.status.value, "Failed to disable TOTP")
}
} catch (e: Exception) {
Log.e("Chat", "Error disabling TOTP", e)
ApiResult.NetworkError(e.localizedMessage ?: "Network error")
}
}
suspend fun changePassword(old: String, new: String): ApiResult<Unit> {
return try {
val response = http.post("${BASE_URL}/api/settings/password") {
contentType(ContentType.Application.Json)
setBody(PasswordChangeRequest(old, new))
}
if (response.status.isSuccess()) {
ApiResult.Success(Unit)
} else {
ApiResult.HttpError(
response.status.value,
if (response.status.value == 401) "Old password is wrong" else "Password change failed"
)
}
} catch (e: Exception) {
Log.e("Chat", "Error changing password", e)
ApiResult.NetworkError(e.localizedMessage ?: "Network error")
}
}
suspend fun updateDisplayName(name: String?): Boolean {
return try {
val response = http.post("${BASE_URL}/api/settings/display_name") {
contentType(ContentType.Application.Json)
setBody(DisplayNameRequest(name))
}
response.status.isSuccess()
} catch (e: Exception) {
Log.e("Chat", "Error updating display name", e)
false
}
}
}
@@ -0,0 +1,3 @@
package dev.zxq5.chatapp.android.core
const val BASE_URL = "http://zxq5-x1:8000"
@@ -0,0 +1,78 @@
package dev.zxq5.chatapp.android.core.data
import android.content.Context
import android.content.SharedPreferences
import android.util.Base64
import androidx.core.content.edit
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import org.json.JSONObject
// In your ApiClient or a dedicated TokenStore
object TokenStore {
private const val KEY = "auth_token"
private const val TWOFA_KEY = "twofa_enabled"
private fun prefs(context: Context): SharedPreferences {
return EncryptedSharedPreferences.create(
context,
"secure_prefs",
MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build(),
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
}
fun save(context: Context, token: String) =
prefs(context).edit { putString(KEY, token) }
fun get(context: Context): String? =
prefs(context).getString(KEY, null)
fun save2faEnabled(context: Context, enabled: Boolean) =
prefs(context).edit { putBoolean(TWOFA_KEY, enabled) }
fun is2faEnabled(context: Context): Boolean =
prefs(context).getBoolean(TWOFA_KEY, false)
fun clear(context: Context) =
prefs(context).edit { remove(KEY).remove(TWOFA_KEY) }
fun getUserId(context: Context): Int? {
val token = get(context) ?: return null
return getUserIdFromToken(token)
}
fun getUserIdFromToken(token: String): Int? {
return try {
val payload = token.split(".")[1]
// base64url needs padding restored
val padded = payload + "==".take((4 - payload.length % 4) % 4)
val jsonString = String(Base64.decode(padded, Base64.URL_SAFE))
val json = JSONObject(jsonString)
// Handle both standard 'sub' and custom 'user_id'
when {
json.has("sub") -> json.getInt("sub")
json.has("user_id") -> json.getInt("user_id")
else -> null
}
} catch (e: Exception) {
null
}
}
fun getScopeFromToken(token: String): String? {
return try {
val payload = token.split(".")[1]
val padded = payload + "==".take((4 - payload.length % 4) % 4)
val jsonString = String(Base64.decode(padded, Base64.URL_SAFE))
val json = JSONObject(jsonString)
if (json.has("scope")) json.getString("scope") else null
} catch (e: Exception) {
null
}
}
}
@@ -0,0 +1,7 @@
package dev.zxq5.chatapp.android.core.error
sealed class ApiResult<out T> {
data class Success<T>(val data: T) : ApiResult<T>()
data class HttpError(val status: Int, val message: String) : ApiResult<Nothing>()
data class NetworkError(val message: String) : ApiResult<Nothing>()
}
@@ -0,0 +1,35 @@
package dev.zxq5.chatapp.android.data.repository
import dev.zxq5.chatapp.android.api.ApiClient
import dev.zxq5.chatapp.android.core.data.TokenStore
import dev.zxq5.chatapp.android.core.error.ApiResult
//
//class AuthRepository(
// private val apiClient: ApiClient,
// private val tokenStore: TokenStore,
//) {
//
// suspend fun login(username: String, password: String): LoginResult {
//// return when(val result = apiClient.login(username, password)) {
//// is ApiResult.Success -> {
//// tokenStore.save(context = context, result.data.token);
//// }
//// }
// }
//}
sealed class LoginResult {
object Success : LoginResult()
object TotpRequired : LoginResult() // step 1 outcome → go to totp screen
data class TotpError(val message: String) : LoginResult() // step 2 failure → stay on totp screen, show error
data class Error(val message: String) : LoginResult() // general failure → show on login form
}
sealed class AuthState {
object Authenticated : AuthState()
object AwaitingTotp : AuthState()
object Unauthenticated : AuthState()
}
@@ -0,0 +1,325 @@
package dev.zxq5.chatapp.android.model
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dev.zxq5.chatapp.android.api.ApiClient
import dev.zxq5.chatapp.android.core.error.ApiResult
import dev.zxq5.chatapp.android.api.QrResponse
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
enum class AuthMode {
LOGIN, SIGNUP
}
enum class MainScreen {
CHAT, SETTINGS
}
class ChatViewModel : ViewModel() {
private val _messages = MutableStateFlow<List<Message>>(emptyList())
val messages: StateFlow<List<Message>> = _messages
private val _channelId = MutableStateFlow<Int?>(null)
val channelId: StateFlow<Int?> = _channelId
private val _currentUserId = MutableStateFlow<Int?>(null)
val currentUserId: StateFlow<Int?> = _currentUserId
private val _currentScreen = MutableStateFlow(MainScreen.CHAT)
val currentScreen: StateFlow<MainScreen> = _currentScreen
val loginState = MutableStateFlow<LoginState>(LoginState.Idle)
// Tracks whether the user is viewing the Login or Signup screen
val authMode = MutableStateFlow(AuthMode.LOGIN)
// 2FA state
private val _totpQr = MutableStateFlow<QrResponse?>(null)
val totpQr: StateFlow<QrResponse?> = _totpQr
private val _is2faEnabled = MutableStateFlow(false)
val is2faEnabled: StateFlow<Boolean> = _is2faEnabled
private val _totpError = MutableStateFlow<String?>(null)
val totpError: StateFlow<String?> = _totpError
// Settings state
private val _settingsError = MutableStateFlow<String?>(null)
val settingsError: StateFlow<String?> = _settingsError
private val _settingsSuccess = MutableStateFlow<String?>(null)
val settingsSuccess: StateFlow<String?> = _settingsSuccess
fun clearSettingsMessages() {
_settingsError.value = null
_settingsSuccess.value = null
}
fun clearTotpError() {
_totpError.value = null
}
private var streamJob: Job? = null
init {
initAuth(ApiClient.hasToken())
}
fun initAuth(hasToken: Boolean) {
if (hasToken) {
val scope = ApiClient.getTokenScope()
if (scope == TokenScope.TOTP_PENDING) {
loginState.value = LoginState.TwoFactorRequired
} else if (scope == TokenScope.FULL) {
loginState.value = LoginState.Success
_currentUserId.value = ApiClient.getStoredUserId()
_is2faEnabled.value = ApiClient.is2faEnabledLocal()
fetchTotpStatus()
observeChannel()
} else {
loginState.value = LoginState.Error("Unknown token scope: $scope")
}
} else {
loginState.value = LoginState.Idle
}
}
private fun observeChannel() {
// restart stream whenever channel changes
viewModelScope.launch {
_channelId.filterNotNull().collect { id ->
streamJob?.cancel()
_messages.value = emptyList()
streamJob = launch {
ApiClient.messageStream(id)
.catch { e ->
Log.e("Chat", "Stream error", e)
}
.collect { message ->
_messages.update { it + message }
}
}
}
}
}
fun navigateTo(screen: MainScreen) {
_currentScreen.value = screen
if (screen == MainScreen.SETTINGS) {
fetchTotpStatus()
}
}
fun switchChannel(id: Int?) {
_channelId.value = id
}
fun setAuthMode(mode: AuthMode) {
authMode.value = mode
// Clear errors when switching modes
if (loginState.value is LoginState.Error || loginState.value is LoginState.TwoFactorRequired) {
loginState.value = LoginState.Idle
}
}
fun sendMessage(text: String) {
val currentId = _channelId.value ?: return
viewModelScope.launch {
runCatching {
ApiClient.sendMessage(
channelId = currentId,
text = text
)
}.onFailure { e ->
Log.e("Chat", "Send message error", e)
}
}
}
fun login(username: String, password: String) {
viewModelScope.launch {
loginState.value = LoginState.Loading
when (val result = ApiClient.login(username, password)) {
is ApiResult.Success -> {
when (val scope = ApiClient.getTokenScope()) {
TokenScope.FULL -> {
_currentUserId.value = ApiClient.getStoredUserId()
_is2faEnabled.value = ApiClient.is2faEnabledLocal()
loginState.value = LoginState.Success
fetchTotpStatus()
observeChannel()
}
TokenScope.TOTP_PENDING -> {
loginState.value = LoginState.TwoFactorRequired
}
else -> {
loginState.value = LoginState.Error("Unknown token scope: $scope")
}
}
}
is ApiResult.HttpError -> {
loginState.value = LoginState.Error(result.message)
}
is ApiResult.NetworkError -> {
loginState.value = LoginState.Error("Could not reach server: ${result.message}")
}
}
}
}
fun verifyLogin2fa(code: String) {
viewModelScope.launch {
loginState.value = LoginState.Loading
when (val result = ApiClient.verifyTotpLogin(code)) {
is ApiResult.Success -> {
val scope = ApiClient.getTokenScope()
if (scope == TokenScope.FULL) {
_currentUserId.value = ApiClient.getStoredUserId()
_is2faEnabled.value = ApiClient.is2faEnabledLocal()
loginState.value = LoginState.Success
fetchTotpStatus()
observeChannel()
} else {
// token came back but scope is wrong — shouldn't happen
loginState.value = LoginState.Error("Unexpected token scope after verification")
}
}
is ApiResult.HttpError -> {
// stay on TOTP screen but show the error
loginState.value = LoginState.TwoFactorRequired
// use a separate error signal so we don't lose the TOTP state
_totpError.value = result.message
}
is ApiResult.NetworkError -> {
loginState.value = LoginState.TwoFactorRequired
_totpError.value = "Could not reach server: ${result.message}"
}
}
}
}
fun signup(username: String, email: String, password: String, accessToken: String) {
viewModelScope.launch {
loginState.value = LoginState.Loading
try {
when (val result = ApiClient.signup(username, email, password, accessToken)) {
is ApiResult.Success -> {
_currentUserId.value = ApiClient.getStoredUserId()
_is2faEnabled.value = ApiClient.is2faEnabledLocal()
loginState.value = LoginState.Success
observeChannel()
}
is ApiResult.HttpError -> {
loginState.value = LoginState.Error(result.message)
}
is ApiResult.NetworkError -> {
loginState.value = LoginState.Error("Could not reach server: ${result.message}")
}
}
} catch (e: Exception) {
Log.e("Chat", "Signup error", e)
loginState.value = LoginState.Error("Signup failed: ${e.localizedMessage}")
}
}
}
fun logout() {
viewModelScope.launch {
ApiClient.logout()
_currentUserId.value = null
_is2faEnabled.value = false
loginState.value = LoginState.Idle
_messages.value = emptyList()
_channelId.value = null
_currentScreen.value = MainScreen.CHAT
streamJob?.cancel()
}
}
fun fetchTotpQr() {
viewModelScope.launch {
_totpQr.value = ApiClient.getTotpQr()
}
}
fun confirmTotp(code: String) {
viewModelScope.launch {
val success = ApiClient.confirmTotp(code)
if (success) {
_is2faEnabled.value = true
ApiClient.set2faEnabledLocal(true)
_totpQr.value = null
} else {
_totpError.value = "Invalid verification code"
}
}
}
fun fetchTotpStatus() {
viewModelScope.launch {
_is2faEnabled.value = ApiClient.getTotpStatus()
}
}
fun disableTotp() {
viewModelScope.launch {
when (val result = ApiClient.disableTotp()) {
is ApiResult.Success -> {
_is2faEnabled.value = false
_settingsSuccess.value = "2FA disabled successfully"
}
is ApiResult.HttpError -> {
_settingsError.value = result.message
}
is ApiResult.NetworkError -> {
_settingsError.value = "Network error: ${result.message}"
}
}
}
}
fun changePassword(old: String, new: String) {
viewModelScope.launch {
_settingsError.value = null
_settingsSuccess.value = null
when (val result = ApiClient.changePassword(old, new)) {
is ApiResult.Success -> {
_settingsSuccess.value = "Password updated"
}
is ApiResult.HttpError -> {
_settingsError.value = result.message
}
is ApiResult.NetworkError -> {
_settingsError.value = "Network error: ${result.message}"
}
}
}
}
fun updateDisplayName(name: String?) {
viewModelScope.launch {
_settingsError.value = null
_settingsSuccess.value = null
val success = ApiClient.updateDisplayName(name)
if (success) {
_settingsSuccess.value = "Display name updated"
} else {
_settingsError.value = "Failed to update display name"
}
}
}
}
object TokenScope {
const val FULL = "full";
const val TOTP_PENDING = "totp_pending";
}
@@ -0,0 +1,11 @@
package dev.zxq5.chatapp.android.model
import kotlinx.serialization.Serializable
@Serializable
data class LoginRequest(
val username: String,
val password: String
)
@@ -0,0 +1,6 @@
package dev.zxq5.chatapp.android.model
import kotlinx.serialization.Serializable
@Serializable
data class LoginResponse(val token: String)
@@ -0,0 +1,9 @@
package dev.zxq5.chatapp.android.model
sealed class LoginState {
object Idle : LoginState()
object Loading : LoginState()
object Success : LoginState()
object TwoFactorRequired : LoginState()
data class Error(val message: String) : LoginState()
}
@@ -0,0 +1,11 @@
package dev.zxq5.chatapp.android.model
import kotlinx.serialization.Serializable
@Serializable
data class Message(
val user_id: Int,
val display_name: String,
val text: String,
val timestamp: Long
)
@@ -0,0 +1,10 @@
package dev.zxq5.chatapp.android.model
import kotlinx.serialization.Serializable
@Serializable
data class SendMessage(
val user_id: Int,
val text: String,
val timestamp: Long
)
@@ -0,0 +1,14 @@
package dev.zxq5.chatapp.android.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class SignupRequest(
val username: String,
val password: String,
val email: String,
@SerialName("access_token")
val access_token: String
)
@@ -0,0 +1,435 @@
package dev.zxq5.chatapp.android.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.ExitToApp
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Send
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.outlined.ChatBubbleOutline
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.NavigationBarItemDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import dev.zxq5.chatapp.android.model.ChatViewModel
import dev.zxq5.chatapp.android.model.MainScreen
import dev.zxq5.chatapp.android.model.Message
import java.text.DateFormat
import java.util.Date
@Composable
fun ChatScreen(viewModel: ChatViewModel) {
val selectedChannelId by viewModel.channelId.collectAsState()
if (selectedChannelId == null) {
ChannelListScreen(
viewModel = viewModel,
onChannelSelect = { viewModel.switchChannel(it) }
)
} else {
MessageScreen(
channelId = selectedChannelId!!,
viewModel = viewModel,
onBack = { viewModel.switchChannel(null) }
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ChannelListScreen(viewModel: ChatViewModel, onChannelSelect: (Int) -> Unit) {
Scaffold(
containerColor = MaterialTheme.colorScheme.background,
topBar = {
Column {
Spacer(Modifier.height(8.dp))
Text(
"contacts",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
modifier = Modifier.padding(horizontal = 20.dp)
)
TopAppBar(
title = {
Text(
"messages",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface
)
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent,
titleContentColor = MaterialTheme.colorScheme.onSurface
)
)
Text(
"5 channels · end-to-end encrypted",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f),
modifier = Modifier.padding(horizontal = 20.dp, vertical = 2.dp)
)
Spacer(Modifier.height(12.dp))
Row(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.secondary.copy(alpha = 0.2f))
.padding(horizontal = 20.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.size(6.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary)
)
Spacer(Modifier.width(10.dp))
Text(
"global · walkie talkie",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f)
)
Surface(
color = Color.Transparent,
border = border(0.5.dp, MaterialTheme.colorScheme.outlineVariant),
shape = RoundedCornerShape(4.dp)
) {
Text(
"hold to talk",
modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
},
bottomBar = { BottomDock(viewModel) }
) { padding ->
LazyColumn(modifier = Modifier.padding(padding).fillMaxSize()) {
items(10) { i ->
val id = i + 1
ChannelItem(id = id, onClick = { onChannelSelect(id) })
HorizontalDivider(
modifier = Modifier.padding(horizontal = 20.dp),
thickness = 0.5.dp,
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.2f)
)
}
}
}
}
@Composable
fun BottomDock(viewModel: ChatViewModel) {
val currentScreen by viewModel.currentScreen.collectAsState()
NavigationBar(
containerColor = MaterialTheme.colorScheme.background,
tonalElevation = 0.dp,
modifier = Modifier.border(0.5.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.2f), RoundedCornerShape(topStart = 0.dp, topEnd = 0.dp))
) {
NavigationBarItem(
selected = currentScreen == MainScreen.CHAT,
onClick = { viewModel.navigateTo(MainScreen.CHAT) },
icon = { Icon(Icons.Outlined.ChatBubbleOutline, contentDescription = "Chat") },
label = { Text("chat", style = MaterialTheme.typography.labelSmall) },
colors = NavigationBarItemDefaults.colors(
selectedIconColor = MaterialTheme.colorScheme.primary,
unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
indicatorColor = Color.Transparent
)
)
NavigationBarItem(
selected = currentScreen == MainScreen.SETTINGS,
onClick = { viewModel.navigateTo(MainScreen.SETTINGS) },
icon = { Icon(Icons.Outlined.Settings, contentDescription = "Settings") },
label = { Text("settings", style = MaterialTheme.typography.labelSmall) },
colors = NavigationBarItemDefaults.colors(
selectedIconColor = MaterialTheme.colorScheme.primary,
unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
indicatorColor = Color.Transparent
)
)
}
}
@Composable
fun ChannelItem(id: Int, onClick: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 20.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.size(36.dp)
.clip(CircleShape)
.border(0.5.dp, MaterialTheme.colorScheme.outlineVariant, CircleShape),
contentAlignment = Alignment.Center
) {
Text(
"C$id",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = "channel $id",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = "tap to join",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
)
}
Text(
"14:22",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f)
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MessageScreen(channelId: Int, viewModel: ChatViewModel, onBack: () -> Unit) {
val messages by viewModel.messages.collectAsState()
val currentUserId by viewModel.currentUserId.collectAsState()
var input by remember { mutableStateOf("") }
val listState = rememberLazyListState()
LaunchedEffect(messages.size) {
if (messages.isNotEmpty()) {
listState.animateScrollToItem(messages.size - 1)
}
}
Scaffold(
containerColor = MaterialTheme.colorScheme.background,
topBar = {
TopAppBar(
title = {
Column {
Text(
"channel $channelId",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface
)
Text(
"online",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
)
}
},
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back",
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent
)
)
}
) { padding ->
Column(modifier = Modifier.padding(padding).fillMaxSize()) {
LazyColumn(
state = listState,
modifier = Modifier.weight(1f).padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
items(messages) { message ->
MessageBubble(message, currentUserId)
}
item { Spacer(Modifier.height(10.dp)) }
}
Row(
modifier = Modifier
.fillMaxWidth()
.height(IntrinsicSize.Min)
.padding(horizontal = 14.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
IconButton(
onClick = { /* add action */ },
modifier = Modifier
.size(36.dp)
.border(0.5.dp, MaterialTheme.colorScheme.outlineVariant, CircleShape)
) {
Icon(
Icons.Default.Add,
contentDescription = "Add",
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(Modifier.width(8.dp))
Box(
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), RoundedCornerShape(20.dp))
.border(0.5.dp, MaterialTheme.colorScheme.outlineVariant, RoundedCornerShape(20.dp))
.padding(horizontal = 14.dp),
contentAlignment = Alignment.CenterStart
) {
if (input.isEmpty()) {
Text(
"message",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
)
}
BasicTextField(
value = input,
onValueChange = { input = it },
modifier = Modifier.fillMaxWidth(),
textStyle = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.onSurface),
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
keyboardActions = KeyboardActions(onSend = {
if (input.isNotBlank()) {
viewModel.sendMessage(input)
input = ""
}
})
)
}
if (input.isNotBlank()) {
Spacer(Modifier.width(8.dp))
IconButton(
onClick = {
viewModel.sendMessage(input)
input = ""
},
modifier = Modifier
.size(36.dp)
.background(MaterialTheme.colorScheme.primary, CircleShape)
) {
Icon(
Icons.Default.Send,
contentDescription = "Send",
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onPrimary
)
}
}
}
}
}
}
@Composable
fun MessageBubble(message: Message, currentUserId: Int?) {
val time = remember(message.timestamp) {
DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(message.timestamp)).lowercase()
}
val isMe = currentUserId != null && message.user_id == currentUserId
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = if (isMe) Alignment.End else Alignment.Start
) {
Surface(
color = if (isMe) MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.2f),
shape = RoundedCornerShape(
topStart = 14.dp,
topEnd = 14.dp,
bottomStart = if (isMe) 14.dp else 4.dp,
bottomEnd = if (isMe) 4.dp else 14.dp
),
border = border(0.5.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f))
) {
Column(modifier = Modifier.padding(horizontal = 11.dp, vertical = 8.dp)) {
if (!isMe) {
Text(
message.display_name.lowercase(),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.7f),
modifier = Modifier.padding(bottom = 2.dp)
)
}
Text(
text = message.text,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.9f)
)
}
}
Text(
text = time,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f),
modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp)
)
}
}
private fun border(width: androidx.compose.ui.unit.Dp, color: Color) =
androidx.compose.foundation.BorderStroke(width, color)
@@ -0,0 +1,318 @@
package dev.zxq5.chatapp.android.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import dev.zxq5.chatapp.android.model.AuthMode
import dev.zxq5.chatapp.android.model.ChatViewModel
import dev.zxq5.chatapp.android.model.LoginState
@Composable
fun AuthScreen(viewModel: ChatViewModel, onSuccess: () -> Unit) {
val loginState by viewModel.loginState.collectAsState()
val authMode by viewModel.authMode.collectAsState()
val totpError by viewModel.totpError.collectAsState()
var username by remember { mutableStateOf("") }
var email by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var confirmPassword by remember { mutableStateOf("") }
var accessToken by remember { mutableStateOf("") }
var localError by remember { mutableStateOf<String?>(null) }
LaunchedEffect(loginState) {
if (loginState is LoginState.Success) onSuccess()
}
LaunchedEffect(authMode) {
localError = null
}
if (loginState is LoginState.TwoFactorRequired ||
(loginState is LoginState.Loading && totpError != null)) {
TwoFactorLoginScreen(
onVerify = { code -> viewModel.verifyLogin2fa(code) },
onBack = {
viewModel.clearTotpError()
viewModel.setAuthMode(AuthMode.LOGIN)
},
isLoading = loginState is LoginState.Loading,
error = totpError
)
return
}
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.padding(24.dp)
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(Modifier.height(40.dp))
Text(
text = "messenger",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 8.dp)
)
Text(
text = if (authMode == AuthMode.LOGIN) "welcome back" else "create account",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(bottom = 48.dp)
)
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
TextField(
value = username,
onValueChange = { username = it },
label = "username"
)
if (authMode == AuthMode.SIGNUP) {
TextField(
value = email,
onValueChange = { email = it },
label = "email"
)
}
TextField(
value = password,
onValueChange = { password = it },
label = "password",
isPassword = true
)
if (authMode == AuthMode.SIGNUP) {
TextField(
value = confirmPassword,
onValueChange = { confirmPassword = it },
label = "confirm password",
isPassword = true
)
TextField(
value = accessToken,
onValueChange = { accessToken = it },
label = "access token"
)
}
}
Spacer(Modifier.height(32.dp))
Button(
onClick = {
localError = null
if (authMode == AuthMode.LOGIN) {
if (username.isBlank() || password.isBlank()) {
localError = "fill all fields"
return@Button
}
viewModel.login(username, password)
} else {
if (username.isBlank() || email.isBlank() || password.isBlank() || accessToken.isBlank()) {
localError = "fill all fields"
return@Button
}
if (password != confirmPassword) {
localError = "passwords mismatch"
return@Button
}
viewModel.signup(username, email, password, accessToken)
}
},
enabled = loginState !is LoginState.Loading,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary,
disabledContainerColor = MaterialTheme.colorScheme.secondary
)
) {
if (loginState is LoginState.Loading) {
CircularProgressIndicator(modifier = Modifier.size(16.dp), color = MaterialTheme.colorScheme.onPrimary, strokeWidth = 2.dp)
} else {
Text(
if (authMode == AuthMode.LOGIN) "login" else "sign up",
style = MaterialTheme.typography.bodyLarge
)
}
}
val displayError = localError ?: (loginState as? LoginState.Error)?.message
if (displayError != null) {
Text(
text = displayError.lowercase(),
style = MaterialTheme.typography.labelSmall,
color = Color.Red,
modifier = Modifier.padding(top = 16.dp)
)
}
Spacer(Modifier.height(16.dp))
TextButton(
onClick = {
viewModel.setAuthMode(if (authMode == AuthMode.LOGIN) AuthMode.SIGNUP else AuthMode.LOGIN)
}
) {
Text(
if (authMode == AuthMode.LOGIN) "no account? sign up"
else "have account? login",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
@Composable
fun TwoFactorLoginScreen(
onVerify: (String) -> Unit,
onBack: () -> Unit,
isLoading: Boolean,
error: String?
) {
var code by remember { mutableStateOf("") }
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
IconButton(onClick = onBack) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back",
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Text(
"security verification",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(Modifier.height(80.dp))
Text(
"two-factor auth",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onSurface
)
Text(
"enter the 6-digit code from your app",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
modifier = Modifier.padding(top = 8.dp, bottom = 48.dp)
)
OutlinedTextField(
value = code,
onValueChange = { if (it.length <= 6) code = it },
placeholder = { Text("000000", color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f)) },
modifier = Modifier.width(200.dp),
textStyle = MaterialTheme.typography.headlineMedium.copy(
textAlign = TextAlign.Center,
letterSpacing = 8.sp
),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
shape = RoundedCornerShape(8.dp),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = MaterialTheme.colorScheme.primary,
unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant,
focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.1f),
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.1f)
),
singleLine = true
)
if (error != null) {
Text(
text = error.lowercase(),
style = MaterialTheme.typography.labelSmall,
color = Color.Red,
modifier = Modifier.padding(top = 12.dp)
)
} else {
Spacer(Modifier.height(12.dp)) // keep layout stable when no error
}
Spacer(Modifier.height(36.dp))
Button(
onClick = { if (code.length == 6) onVerify(code) },
enabled = code.length == 6 && !isLoading,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary
)
) {
if (isLoading) {
CircularProgressIndicator(modifier = Modifier.size(16.dp), color = MaterialTheme.colorScheme.onPrimary, strokeWidth = 2.dp)
} else {
Text("verify", style = MaterialTheme.typography.bodyLarge)
}
}
}
}
@@ -0,0 +1,368 @@
package dev.zxq5.chatapp.android.ui.components
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import dev.zxq5.chatapp.android.model.ChatViewModel
import dev.zxq5.chatapp.android.model.MainScreen
import android.util.Base64
import android.graphics.BitmapFactory
import androidx.compose.runtime.LaunchedEffect
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(viewModel: ChatViewModel) {
val is2faEnabled by viewModel.is2faEnabled.collectAsState()
val totpQr by viewModel.totpQr.collectAsState()
val settingsError by viewModel.settingsError.collectAsState()
val settingsSuccess by viewModel.settingsSuccess.collectAsState()
var show2faSetup by remember { mutableStateOf(false) }
var displayName by remember { mutableStateOf("") }
var oldPassword by remember { mutableStateOf("") }
var newPassword by remember { mutableStateOf("") }
LaunchedEffect(Unit) {
viewModel.clearSettingsMessages()
viewModel.fetchTotpStatus()
}
Scaffold(
containerColor = MaterialTheme.colorScheme.background,
topBar = {
TopAppBar(
title = {
Text(
"settings",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface
)
},
navigationIcon = {
IconButton(onClick = { viewModel.navigateTo(MainScreen.CHAT) }) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back",
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent
)
)
}
) { padding ->
Column(
modifier = Modifier
.padding(padding)
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(24.dp)
) {
if (settingsError != null) {
Text(settingsError!!, color = Color.Red, style = MaterialTheme.typography.bodySmall)
}
if (settingsSuccess != null) {
Text(settingsSuccess!!, color = Color.Green, style = MaterialTheme.typography.bodySmall)
}
// Profile Section
Column {
Text(
"profile",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
modifier = Modifier.padding(bottom = 12.dp)
)
OutlinedTextField(
value = displayName,
onValueChange = { displayName = it },
label = { Text("display name") },
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
trailingIcon = {
IconButton(onClick = { viewModel.updateDisplayName(displayName.ifBlank { null }) }) {
Icon(Icons.Default.Check, "Save")
}
}
)
}
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.2f))
// Security Section
Column {
Text(
"account security",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
modifier = Modifier.padding(bottom = 12.dp)
)
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
"two-factor authentication",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface
)
Text(
if (is2faEnabled) "enabled" else "disabled",
style = MaterialTheme.typography.labelSmall,
color = if (is2faEnabled) Color.Green.copy(alpha = 0.7f) else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
)
}
if (!is2faEnabled) {
Button(
onClick = {
show2faSetup = true
viewModel.fetchTotpQr()
},
shape = RoundedCornerShape(8.dp),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f),
contentColor = MaterialTheme.colorScheme.onSurface
),
modifier = Modifier.height(32.dp)
) {
Text("setup", style = MaterialTheme.typography.labelSmall)
}
} else {
Button(
onClick = { viewModel.disableTotp() },
shape = RoundedCornerShape(8.dp),
colors = ButtonDefaults.buttonColors(
containerColor = Color.Red.copy(alpha = 0.1f),
contentColor = Color.Red
),
modifier = Modifier.height(32.dp)
) {
Text("disable", style = MaterialTheme.typography.labelSmall)
}
}
}
if (show2faSetup && !is2faEnabled) {
Spacer(Modifier.height(16.dp))
TwoFactorSetup(
qrCodeBase64 = totpQr?.qr_code,
onConfirm = { code ->
viewModel.confirmTotp(code)
},
onCancel = { show2faSetup = false }
)
}
Spacer(Modifier.height(16.dp))
Text("change password", style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(bottom = 8.dp))
OutlinedTextField(
value = oldPassword,
onValueChange = { oldPassword = it },
label = { Text("old password") },
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp)
)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = newPassword,
onValueChange = { newPassword = it },
label = { Text("new password") },
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp)
)
Spacer(Modifier.height(12.dp))
Button(
onClick = {
viewModel.changePassword(oldPassword, newPassword)
oldPassword = ""
newPassword = ""
},
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
enabled = oldPassword.isNotEmpty() && newPassword.isNotEmpty()
) {
Text("update password")
}
}
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.2f))
// Application Section
Column {
Text(
"application",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
modifier = Modifier.padding(bottom = 12.dp)
)
Button(
onClick = { viewModel.logout() },
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
colors = ButtonDefaults.buttonColors(
containerColor = Color.Red.copy(alpha = 0.1f),
contentColor = Color.Red
)
) {
Text("logout", style = MaterialTheme.typography.bodyLarge)
}
}
}
}
}
@Composable
fun TwoFactorSetup(
qrCodeBase64: String?,
onConfirm: (String) -> Unit,
onCancel: () -> Unit
) {
var code by remember { mutableStateOf("") }
Column(
modifier = Modifier
.fillMaxWidth()
.border(0.5.dp, MaterialTheme.colorScheme.outlineVariant, RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.1f), RoundedCornerShape(12.dp))
.padding(20.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
"scan qr code",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(16.dp))
if (qrCodeBase64 != null) {
val bitmap = remember(qrCodeBase64) {
val base64Data = qrCodeBase64.substringAfter("base64,")
val decodedString = Base64.decode(base64Data, Base64.DEFAULT)
BitmapFactory.decodeByteArray(decodedString, 0, decodedString.size)
}
bitmap?.let {
Image(
bitmap = it.asImageBitmap(),
contentDescription = "QR Code",
modifier = Modifier
.size(180.dp)
.clip(RoundedCornerShape(8.dp))
.background(Color.White) // QR codes usually need white background
.padding(8.dp)
)
}
} else {
Box(
modifier = Modifier.size(180.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
}
}
Spacer(Modifier.height(24.dp))
OutlinedTextField(
value = code,
onValueChange = { if (it.length <= 6) code = it },
placeholder = { Text("000000", color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f)) },
modifier = Modifier.width(150.dp),
textStyle = MaterialTheme.typography.headlineMedium.copy(
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
letterSpacing = 4.sp
),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
shape = RoundedCornerShape(8.dp),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = MaterialTheme.colorScheme.primary,
unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant
)
)
Spacer(Modifier.height(24.dp))
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Button(
onClick = onCancel,
modifier = Modifier.weight(1f),
shape = RoundedCornerShape(8.dp),
colors = ButtonDefaults.buttonColors(containerColor = Color.Transparent, contentColor = MaterialTheme.colorScheme.onSurfaceVariant)
) {
Text("cancel", style = MaterialTheme.typography.labelSmall)
}
Button(
onClick = { if (code.length == 6) onConfirm(code) },
enabled = code.length == 6,
modifier = Modifier.weight(1f),
shape = RoundedCornerShape(8.dp)
) {
Text("confirm", style = MaterialTheme.typography.labelSmall)
}
}
}
}
@@ -0,0 +1,45 @@
package dev.zxq5.chatapp.android.ui.components
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
@Composable
fun TextField(
value: String,
onValueChange: (String) -> Unit,
label: String,
isPassword: Boolean = false
) {
OutlinedTextField(
value = value,
onValueChange = onValueChange,
placeholder = {
Text(
label,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
)
},
modifier = Modifier.fillMaxWidth(),
singleLine = true,
textStyle = MaterialTheme.typography.bodyLarge,
visualTransformation = if (isPassword) PasswordVisualTransformation() else androidx.compose.ui.text.input.VisualTransformation.None,
shape = RoundedCornerShape(8.dp),
colors = OutlinedTextFieldDefaults.colors(
focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f),
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f),
focusedBorderColor = MaterialTheme.colorScheme.outlineVariant,
unfocusedBorderColor = MaterialTheme.colorScheme.outline,
focusedTextColor = MaterialTheme.colorScheme.onSurface,
unfocusedTextColor = MaterialTheme.colorScheme.onSurface
)
)
}
@@ -0,0 +1,20 @@
package dev.zxq5.chatapp.android.ui.theme
import androidx.compose.ui.graphics.Color
val Black = Color(0xFF0A0A0A)
val DarkGrey = Color(0xFF0D0D0D)
val Grey = Color(0xFF141414)
val LightGrey = Color(0xFF1E1E1E)
val White = Color(0xFFE8E8E8)
val TextPrimary = Color(0xFFE8E8E8)
val TextSecondary = Color(0xFF888888)
val TextTertiary = Color(0xFF555555)
val TextMuted = Color(0xFF333333)
val Border = Color(0xFF1A1A1A)
val BorderLight = Color(0xFF222222)
val Red = Color(0xFFFF0000)
val Surface = Color(0xFF111111)
@@ -0,0 +1,48 @@
package dev.zxq5.chatapp.android.ui.theme
import android.app.Activity
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat
private val DarkColorScheme = darkColorScheme(
primary = White,
onPrimary = Black,
secondary = LightGrey,
onSecondary = White,
tertiary = Grey,
background = Black,
onBackground = White,
surface = Black,
onSurface = White,
surfaceVariant = Grey,
onSurfaceVariant = TextSecondary,
outline = Border,
outlineVariant = BorderLight
)
@Composable
fun ChatappTheme(
darkTheme: Boolean = true, // Force dark theme for Nothing style
content: @Composable () -> Unit
) {
val colorScheme = DarkColorScheme
val view = LocalView.current
if (!view.isInEditMode) {
SideEffect {
val window = (view.context as Activity).window
window.statusBarColor = Black.toArgb()
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = false
}
}
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
content = content
)
}
@@ -0,0 +1,37 @@
package dev.zxq5.chatapp.android.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
val Typography = Typography(
bodyLarge = TextStyle(
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Normal,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.02.sp
),
titleLarge = TextStyle(
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Medium,
fontSize = 20.sp,
lineHeight = 28.sp,
letterSpacing = (-0.02).sp
),
labelSmall = TextStyle(
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Medium,
fontSize = 10.sp,
lineHeight = 14.sp,
letterSpacing = 0.05.sp
),
headlineMedium = TextStyle(
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Normal,
fontSize = 22.sp,
letterSpacing = (-0.02).sp
)
)
@@ -0,0 +1,170 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector>
@@ -0,0 +1,30 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
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>
@@ -0,0 +1,3 @@
<resources>
<string name="app_name">Chatapp</string>
</resources>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.Chatapp" parent="android:Theme.Material.Light.NoActionBar" />
</resources>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample backup rules file; uncomment and customize as necessary.
See https://developer.android.com/guide/topics/data/autobackup
for details.
Note: This file is ignored for devices older than API 31
See https://developer.android.com/about/versions/12/backup-restore
-->
<full-backup-content>
<!--
<include domain="sharedpref" path="."/>
<exclude domain="sharedpref" path="device.xml"/>
-->
</full-backup-content>
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample data extraction rules file; uncomment and customize as necessary.
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
for details.
-->
<data-extraction-rules>
<cloud-backup>
<!-- TODO: Use <include> and <exclude> to control what is backed up.
<include .../>
<exclude .../>
-->
</cloud-backup>
<!--
<device-transfer>
<include .../>
<exclude .../>
</device-transfer>
-->
</data-extraction-rules>
@@ -0,0 +1,17 @@
package dev.zxq5.chatapp.android
import org.junit.Test
import org.junit.Assert.*
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
}
+6
View File
@@ -0,0 +1,6 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.compose) apply false
alias(libs.plugins.kotlin.serialization) apply false
}
+15
View File
@@ -0,0 +1,15 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. For more details, visit
# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
# org.gradle.parallel=true
# Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official
@@ -0,0 +1,12 @@
#This file is generated by updateDaemonJvm
toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect
toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect
toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect
toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect
toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/73bcfb608d1fde9fb62e462f834a3299/redirect
toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/846ee0d876d26a26f37aa1ce8de73224/redirect
toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect
toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect
toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/9482ddec596298c84656d31d16652665/redirect
toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/39701d92e1756bb2f141eb67cd4c660e/redirect
toolchainVersion=21
+51
View File
@@ -0,0 +1,51 @@
[versions]
agp = "9.1.0"
coreKtx = "1.10.1"
junit = "4.13.2"
junitVersion = "1.1.5"
espressoCore = "3.5.1"
kotlinxCoroutinesAndroid = "1.10.2"
ktorClientAndroidVersion = "2.3.7"
ktorClientContentNegotiation = "3.4.2"
ktorClientAndroid = "3.4.2"
ktorSerializationKotlinxJson = "3.4.2"
kotlinxSerializationJson = "1.10.0"
lifecycleRuntimeKtx = "2.6.1"
activityCompose = "1.8.0"
kotlin = "2.2.10"
composeBom = "2024.09.00"
foundationLayout = "1.10.6"
lifecycleViewmodelCompose = "2.10.0"
securityCrypto = "1.1.0"
composeIconsExtended = "1.7.0"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycleViewmodelCompose" }
androidx-security-crypto = { module = "androidx.security:security-crypto", version.ref = "securityCrypto" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-compose-foundation-layout = { group = "androidx.compose.foundation", name = "foundation-layout", version.ref = "foundationLayout" }
androidx-compose-material-icons-extended = { module = "androidx.compose.material:material-icons-extended", version.ref = "composeIconsExtended" }
kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinxCoroutinesAndroid" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
ktor-client-android = { module = "io.ktor:ktor-client-android", version.ref = "ktorClientAndroid" }
ktor-client-auth = { module = "io.ktor:ktor-client-auth", version.require = "3.4.2" }
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktorClientContentNegotiation" }
ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktorSerializationKotlinxJson" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
Binary file not shown.
+9
View File
@@ -0,0 +1,9 @@
#Tue Mar 31 13:46:27 BST 2026
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionSha256Sum=b266d5ff6b90eada6dc3b20cb090e3731302e553a27c5d3e4df1f0d76beaff06
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable
+251
View File
@@ -0,0 +1,251 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
+94
View File
@@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+27
View File
@@ -0,0 +1,27 @@
pluginManagement {
repositories {
google {
content {
includeGroupByRegex("com\\.android.*")
includeGroupByRegex("com\\.google.*")
includeGroupByRegex("androidx.*")
}
}
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "Chatapp"
include(":app")