1 Commits
Author SHA1 Message Date
JMR-dev 284c873a3d mvp shell 2026-03-22 14:45:37 -05:00
38 changed files with 2682 additions and 26 deletions
+3 -1
View File
@@ -42,4 +42,6 @@ bin/
.vscode/
### Mac OS ###
.DS_Store
.DS_Store
WEBSOCKET_API_SPEC.md
+1538
View File
File diff suppressed because it is too large Load Diff
+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>
+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>
+2 -1
View File
@@ -4,13 +4,14 @@
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
<option name="testRunner" value="CHOOSE_PER_TEST" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleHome" value="" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$" />
</set>
</option>
<option name="resolveExternalAnnotations" value="true" />
</GradleProjectSettings>
</option>
</component>
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="MarkdownSettings">
<option name="previewPanelProviderInfo">
<ProviderInfo name="Compose (experimental)" className="com.intellij.markdown.compose.preview.ComposePanelProvider" />
</option>
</component>
</project>
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectMigrations">
<option name="MigrateToGradleLocalJavaHome">
<set>
<option value="$PROJECT_DIR$" />
</set>
</option>
</component>
</project>
+2 -1
View File
@@ -1,6 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_25" default="true" project-jdk-name="temurin-25" project-jdk-type="JavaSDK">
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/out" />
</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>
+100
View File
@@ -0,0 +1,100 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("com.google.devtools.ksp")
id("com.google.dagger.hilt.android")
id("org.jetbrains.kotlin.plugin.serialization")
kotlin("plugin.compose")
}
android {
namespace = "com.postcard"
compileSdk = 34
defaultConfig {
applicationId = "com.postcard"
minSdk = 26
targetSdk = 34
versionCode = 1
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary = true
}
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
compose = true
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
}
dependencies {
implementation("androidx.core:core-ktx:1.13.1")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.3")
implementation("androidx.activity:activity-compose:1.9.0")
// Compose
implementation(platform("androidx.compose:compose-bom:2024.06.00"))
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-graphics")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3")
implementation("androidx.navigation:navigation-compose:2.7.7")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.3")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.3")
// Room
val roomVersion = "2.6.1"
implementation("androidx.room:room-runtime:$roomVersion")
implementation("androidx.room:room-ktx:$roomVersion")
ksp("androidx.room:room-compiler:$roomVersion")
// Hilt
implementation("com.google.dagger:hilt-android:2.51.1")
ksp("com.google.dagger:hilt-android-compiler:2.51.1")
implementation("androidx.hilt:hilt-navigation-compose:1.2.0")
// Ktor (Using OkHttp engine as Ktor is idiomatic for Kotlin Multiplatform, but works great on Android too)
val ktorVersion = "2.3.12"
implementation("io.ktor:ktor-client-core:$ktorVersion")
implementation("io.ktor:ktor-client-okhttp:$ktorVersion")
implementation("io.ktor:ktor-client-content-negotiation:$ktorVersion")
implementation("io.ktor:ktor-client-websockets:$ktorVersion")
implementation("io.ktor:ktor-client-auth:$ktorVersion")
implementation("io.ktor:ktor-serialization-kotlinx-json:$ktorVersion")
// Serialization
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.1")
// Coil
implementation("io.coil-kt:coil-compose:2.6.0")
// Security (for EncryptedSharedPreferences)
implementation("androidx.security:security-crypto:1.1.0-alpha06")
// Media (ExoPlayer)
implementation("androidx.media3:media3-exoplayer:1.3.1")
implementation("androidx.media3:media3-ui:1.3.1")
// Coroutines
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
}
+30
View File
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<application
android:name=".PostcardApplication"
android:allowBackup="true"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.Postcard"
tools:targetApi="31">
<activity
android:name=".MainActivity"
android:exported="true"
android:windowSoftInputMode="adjustResize"
android:theme="@style/Theme.Postcard">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -0,0 +1,89 @@
package com.postcard
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import com.postcard.ui.auth.AuthViewModel
import com.postcard.ui.auth.LoginScreen
import com.postcard.ui.auth.RegisterScreen
import com.postcard.ui.conversations.ConversationListScreen
import com.postcard.ui.theme.PostcardTheme
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
PostcardTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
PostcardApp()
}
}
}
}
}
@Composable
fun PostcardApp() {
val navController = rememberNavController()
val authViewModel: AuthViewModel = hiltViewModel()
val startDestination = if (authViewModel.isLoggedIn()) "conversations" else "login"
NavHost(navController = navController, startDestination = startDestination) {
composable("login") {
LoginScreen(
onNavigateToRegister = {
navController.navigate("register") {
popUpTo("login") { inclusive = true }
}
},
onLoginSuccess = {
navController.navigate("conversations") {
popUpTo("login") { inclusive = true }
}
}
)
}
composable("register") {
RegisterScreen(
onNavigateToLogin = {
navController.navigate("login") {
popUpTo("register") { inclusive = true }
}
},
onRegisterSuccess = {
navController.navigate("conversations") {
popUpTo("register") { inclusive = true }
}
}
)
}
composable("conversations") {
ConversationListScreen(
onLogout = {
authViewModel.resetState()
authViewModel.logout()
navController.navigate("login") {
popUpTo("conversations") { inclusive = true }
}
}
)
}
}
}
@@ -0,0 +1,7 @@
package com.postcard
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp
class PostcardApplication : Application()
@@ -0,0 +1,41 @@
package com.postcard.data.auth
import android.content.Context
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class AuthTokenManager @Inject constructor(
@ApplicationContext context: Context
) {
private val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
private val sharedPreferences = EncryptedSharedPreferences.create(
context,
"auth_prefs",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
fun saveToken(token: String) {
sharedPreferences.edit().putString(KEY_TOKEN, token).apply()
}
fun getToken(): String? {
return sharedPreferences.getString(KEY_TOKEN, null)
}
fun clearToken() {
sharedPreferences.edit().remove(KEY_TOKEN).apply()
}
companion object {
private const val KEY_TOKEN = "jwt_token"
}
}
@@ -0,0 +1,28 @@
package com.postcard.data.local
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import kotlinx.coroutines.flow.Flow
@Dao
interface ConversationDao {
@Query("SELECT * FROM conversations ORDER BY lastMessageTimestamp DESC")
fun getAllConversations(): Flow<List<ConversationEntity>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertConversations(conversations: List<ConversationEntity>)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertConversation(conversation: ConversationEntity)
@Query("SELECT * FROM conversations WHERE id = :id")
suspend fun getConversationById(id: String): ConversationEntity?
@Query("UPDATE conversations SET lastSyncedSequence = :sequence WHERE id = :id")
suspend fun updateLastSyncedSequence(id: String, sequence: Long)
@Query("DELETE FROM conversations")
suspend fun clearConversations()
}
@@ -0,0 +1,15 @@
package com.postcard.data.local
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "conversations")
data class ConversationEntity(
@PrimaryKey val id: String,
val participantDisplayName: String,
val participantAvatarUrl: String?,
val lastMessagePreview: String?,
val lastMessageTimestamp: Long?,
val unreadCount: Int,
val lastSyncedSequence: Long
)
@@ -0,0 +1,25 @@
package com.postcard.data.local
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import kotlinx.coroutines.flow.Flow
@Dao
interface MessageDao {
@Query("SELECT * FROM messages WHERE conversationId = :conversationId ORDER BY sequence DESC, createdAt DESC")
fun getMessagesForConversation(conversationId: String): Flow<List<MessageEntity>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertMessage(message: MessageEntity)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertMessages(messages: List<MessageEntity>)
@Query("UPDATE messages SET status = :status WHERE clientMsgId = :clientMsgId")
suspend fun updateMessageStatus(clientMsgId: String, status: String)
@Query("DELETE FROM messages")
suspend fun clearMessages()
}
@@ -0,0 +1,18 @@
package com.postcard.data.local
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "messages")
data class MessageEntity(
@PrimaryKey val id: String,
val conversationId: String,
val senderId: String,
val sequence: Long,
val type: String,
val body: String?,
val mediaMetadata: String?, // JSON string
val clientMsgId: String?,
val status: String, // e.g. "sending", "sent", "delivered", "read"
val createdAt: Long
)
@@ -0,0 +1,15 @@
package com.postcard.data.local
import androidx.room.Database
import androidx.room.RoomDatabase
@Database(
entities = [UserEntity::class, ConversationEntity::class, MessageEntity::class],
version = 1,
exportSchema = false
)
abstract class PostcardDatabase : RoomDatabase() {
abstract fun userDao(): UserDao
abstract fun conversationDao(): ConversationDao
abstract fun messageDao(): MessageDao
}
@@ -0,0 +1,22 @@
package com.postcard.data.local
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import kotlinx.coroutines.flow.Flow
@Dao
interface UserDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertUser(user: UserEntity)
@Query("SELECT * FROM users WHERE id = :userId")
suspend fun getUserById(userId: String): UserEntity?
@Query("SELECT * FROM users LIMIT 1")
suspend fun getCurrentUser(): UserEntity?
@Query("DELETE FROM users")
suspend fun clearUsers()
}
@@ -0,0 +1,12 @@
package com.postcard.data.local
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "users")
data class UserEntity(
@PrimaryKey val id: String,
val username: String,
val displayName: String,
val avatarUrl: String?
)
@@ -0,0 +1,77 @@
package com.postcard.data.repository
import com.postcard.data.auth.AuthTokenManager
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.http.ContentType
import io.ktor.http.contentType
import kotlinx.serialization.Serializable
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class AuthRepository @Inject constructor(
private val httpClient: HttpClient,
private val authTokenManager: AuthTokenManager
) {
suspend fun login(request: LoginRequest): Result<AuthResponse> {
return try {
val response: AuthResponse = httpClient.post("/api/auth/login") {
contentType(ContentType.Application.Json)
setBody(request)
}.body()
authTokenManager.saveToken(response.token)
Result.success(response)
} catch (e: Exception) {
Result.failure(e)
}
}
suspend fun register(request: RegisterRequest): Result<AuthResponse> {
return try {
val response: AuthResponse = httpClient.post("/api/auth/register") {
contentType(ContentType.Application.Json)
setBody(request)
}.body()
authTokenManager.saveToken(response.token)
Result.success(response)
} catch (e: Exception) {
Result.failure(e)
}
}
fun logout() {
authTokenManager.clearToken()
}
fun isLoggedIn(): Boolean {
return authTokenManager.getToken() != null
}
}
@Serializable
data class AuthResponse(
val token: String,
val userId: String,
val username: String,
val displayName: String,
val avatarUrl: String? = null
)
@Serializable
data class LoginRequest(
val username: String,
val passwordHash: String // Usually frontend sends hash, but we'll send password based on spec, assuming standard password field for simplicity
)
@Serializable
data class RegisterRequest(
val username: String,
val displayName: String,
val passwordHash: String
)
@@ -0,0 +1,44 @@
package com.postcard.di
import android.content.Context
import androidx.room.Room
import com.postcard.data.local.ConversationDao
import com.postcard.data.local.MessageDao
import com.postcard.data.local.PostcardDatabase
import com.postcard.data.local.UserDao
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {
@Provides
@Singleton
fun providePostcardDatabase(@ApplicationContext context: Context): PostcardDatabase {
return Room.databaseBuilder(
context,
PostcardDatabase::class.java,
"postcard_db"
).fallbackToDestructiveMigration().build()
}
@Provides
fun provideUserDao(database: PostcardDatabase): UserDao {
return database.userDao()
}
@Provides
fun provideConversationDao(database: PostcardDatabase): ConversationDao {
return database.conversationDao()
}
@Provides
fun provideMessageDao(database: PostcardDatabase): MessageDao {
return database.messageDao()
}
}
@@ -0,0 +1,61 @@
package com.postcard.di
import com.postcard.data.auth.AuthTokenManager
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import io.ktor.client.HttpClient
import io.ktor.client.engine.okhttp.OkHttp
import io.ktor.client.plugins.auth.Auth
import io.ktor.client.plugins.auth.providers.BearerTokens
import io.ktor.client.plugins.auth.providers.bearer
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.plugins.defaultRequest
import io.ktor.client.plugins.websocket.WebSockets
import io.ktor.client.request.header
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
private const val BASE_URL = "http://10.0.2.2:8080" // Default Android emulator loopback to host
@Provides
@Singleton
fun provideHttpClient(authTokenManager: AuthTokenManager): HttpClient {
return HttpClient(OkHttp) {
install(ContentNegotiation) {
json(Json {
prettyPrint = true
isLenient = true
ignoreUnknownKeys = true
})
}
install(WebSockets) {
pingInterval = 20_000
}
install(Auth) {
bearer {
loadTokens {
authTokenManager.getToken()?.let { BearerTokens(it, "") }
}
refreshTokens {
// For this PoC, we just clear the token and return null to trigger a 401 error flow
authTokenManager.clearToken()
null
}
}
}
defaultRequest {
url(BASE_URL)
header(HttpHeaders.ContentType, ContentType.Application.Json)
}
}
}
}
@@ -0,0 +1,64 @@
package com.postcard.ui.auth
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.postcard.data.repository.AuthRepository
import com.postcard.data.repository.LoginRequest
import com.postcard.data.repository.RegisterRequest
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class AuthViewModel @Inject constructor(
private val authRepository: AuthRepository
) : ViewModel() {
private val _uiState = MutableStateFlow<AuthState>(AuthState.Idle)
val uiState: StateFlow<AuthState> = _uiState.asStateFlow()
fun login(username: String, passwordHash: String) {
viewModelScope.launch {
_uiState.value = AuthState.Loading
val result = authRepository.login(LoginRequest(username, passwordHash))
if (result.isSuccess) {
_uiState.value = AuthState.Success
} else {
_uiState.value = AuthState.Error(result.exceptionOrNull()?.message ?: "Login failed")
}
}
}
fun register(username: String, displayName: String, passwordHash: String) {
viewModelScope.launch {
_uiState.value = AuthState.Loading
val result = authRepository.register(RegisterRequest(username, displayName, passwordHash))
if (result.isSuccess) {
_uiState.value = AuthState.Success
} else {
_uiState.value = AuthState.Error(result.exceptionOrNull()?.message ?: "Registration failed")
}
}
}
fun isLoggedIn(): Boolean = authRepository.isLoggedIn()
fun logout() {
authRepository.logout()
_uiState.value = AuthState.Idle
}
fun resetState() {
_uiState.value = AuthState.Idle
}
}
sealed class AuthState {
object Idle : AuthState()
object Loading : AuthState()
object Success : AuthState()
data class Error(val message: String) : AuthState()
}
@@ -0,0 +1,83 @@
package com.postcard.ui.auth
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
@Composable
fun LoginScreen(
onNavigateToRegister: () -> Unit,
onLoginSuccess: () -> Unit,
viewModel: AuthViewModel = hiltViewModel()
) {
var username by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
val uiState by viewModel.uiState.collectAsState()
LaunchedEffect(uiState) {
if (uiState is AuthState.Success) {
onLoginSuccess()
}
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text("Login", style = MaterialTheme.typography.headlineMedium)
Spacer(modifier = Modifier.height(16.dp))
OutlinedTextField(
value = username,
onValueChange = { username = it },
label = { Text("Username") },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text("Password") },
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(16.dp))
if (uiState is AuthState.Error) {
Text(
text = (uiState as AuthState.Error).message,
color = MaterialTheme.colorScheme.error
)
Spacer(modifier = Modifier.height(8.dp))
}
Button(
onClick = { viewModel.login(username, password) },
modifier = Modifier.fillMaxWidth(),
enabled = uiState !is AuthState.Loading && username.isNotBlank() && password.isNotBlank()
) {
if (uiState is AuthState.Loading) {
CircularProgressIndicator(modifier = Modifier.size(24.dp), color = MaterialTheme.colorScheme.onPrimary)
} else {
Text("Login")
}
}
Spacer(modifier = Modifier.height(8.dp))
TextButton(onClick = {
viewModel.resetState()
onNavigateToRegister()
}) {
Text("Don't have an account? Register")
}
}
}
@@ -0,0 +1,92 @@
package com.postcard.ui.auth
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
@Composable
fun RegisterScreen(
onNavigateToLogin: () -> Unit,
onRegisterSuccess: () -> Unit,
viewModel: AuthViewModel = hiltViewModel()
) {
var username by remember { mutableStateOf("") }
var displayName by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
val uiState by viewModel.uiState.collectAsState()
LaunchedEffect(uiState) {
if (uiState is AuthState.Success) {
onRegisterSuccess()
}
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text("Register", style = MaterialTheme.typography.headlineMedium)
Spacer(modifier = Modifier.height(16.dp))
OutlinedTextField(
value = username,
onValueChange = { username = it },
label = { Text("Username") },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = displayName,
onValueChange = { displayName = it },
label = { Text("Display Name") },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text("Password") },
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(16.dp))
if (uiState is AuthState.Error) {
Text(
text = (uiState as AuthState.Error).message,
color = MaterialTheme.colorScheme.error
)
Spacer(modifier = Modifier.height(8.dp))
}
Button(
onClick = { viewModel.register(username, displayName, password) },
modifier = Modifier.fillMaxWidth(),
enabled = uiState !is AuthState.Loading && username.isNotBlank() && displayName.isNotBlank() && password.isNotBlank()
) {
if (uiState is AuthState.Loading) {
CircularProgressIndicator(modifier = Modifier.size(24.dp), color = MaterialTheme.colorScheme.onPrimary)
} else {
Text("Register")
}
}
Spacer(modifier = Modifier.height(8.dp))
TextButton(onClick = {
viewModel.resetState()
onNavigateToLogin()
}) {
Text("Already have an account? Login")
}
}
}
@@ -0,0 +1,17 @@
package com.postcard.ui.conversations
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@Composable
fun ConversationListScreen(
onLogout: () -> Unit
) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("Conversation List")
}
}
@@ -0,0 +1,11 @@
package com.postcard.ui.theme
import androidx.compose.ui.graphics.Color
val Purple80 = Color(0xFFD0BCFF)
val PurpleGrey80 = Color(0xFFCCC2DC)
val Pink80 = Color(0xFFEFB8C8)
val Purple40 = Color(0xFF6650a4)
val PurpleGrey40 = Color(0xFF625b71)
val Pink40 = Color(0xFF7D5260)
@@ -0,0 +1,57 @@
package com.postcard.ui.theme
import android.app.Activity
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat
private val DarkColorScheme = darkColorScheme(
primary = Purple80,
secondary = PurpleGrey80,
tertiary = Pink80
)
private val LightColorScheme = lightColorScheme(
primary = Purple40,
secondary = PurpleGrey40,
tertiary = Pink40
)
@Composable
fun PostcardTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = true,
content: @Composable () -> Unit
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
val view = LocalView.current
if (!view.isInEditMode) {
SideEffect {
val window = (view.context as Activity).window
window.statusBarColor = colorScheme.primary.toArgb()
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = darkTheme
}
}
MaterialTheme(
colorScheme = colorScheme,
content = content
)
}
+3
View File
@@ -0,0 +1,3 @@
<resources>
<string name="app_name">Postcard</string>
</resources>
+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.Postcard" parent="android:Theme.Material.Light.NoActionBar">
</style>
</resources>
+6 -20
View File
@@ -1,22 +1,8 @@
plugins {
kotlin("jvm") version "2.3.10"
id("com.android.application") version "8.5.1" apply false
id("org.jetbrains.kotlin.android") version "2.0.20" apply false
id("com.google.dagger.hilt.android") version "2.51.1" apply false
id("com.google.devtools.ksp") version "2.0.20-1.0.24" apply false
kotlin("plugin.compose") version "2.0.20" apply false
id("org.jetbrains.kotlin.plugin.serialization") version "2.0.20" apply false
}
group = "dev.jasonmross"
version = "1.0-SNAPSHOT"
repositories {
mavenCentral()
}
dependencies {
testImplementation(kotlin("test"))
}
kotlin {
jvmToolchain(25)
}
tasks.test {
useJUnitPlatform()
}
+3
View File
@@ -1 +1,4 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
android.nonTransitiveRClass=true
kotlin.code.style=official
+13
View File
@@ -0,0 +1,13 @@
#This file is generated by updateDaemonJvm
toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/536afcd1dff540251f85e5d2c80458cf/redirect
toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/ecd23fd7707c683afbcd6052998cb6a9/redirect
toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/536afcd1dff540251f85e5d2c80458cf/redirect
toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/ecd23fd7707c683afbcd6052998cb6a9/redirect
toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/e99bae143b75f9a10ead10248f02055e/redirect
toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/658299a896470fbb3103ba3a430ee227/redirect
toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/536afcd1dff540251f85e5d2c80458cf/redirect
toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/398ffe3949748bfb1d5636f023d228fd/redirect
toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/248ffb1098f61659502d0c09aa348294/redirect
toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/932015f6361ccaead0c6d9b8717ed96e/redirect
toolchainVendor=JETBRAINS
toolchainVersion=21
+8
View File
@@ -0,0 +1,8 @@
## This file must *NOT* be checked into Version Control Systems,
# as it contains information specific to your local configuration.
#
# Location of the SDK. This is only used by Gradle.
# For customization when using a Version Control System, please read the
# header note.
#Sun Mar 22 11:49:10 CDT 2026
sdk.dir=C\:\\Android
+15 -3
View File
@@ -1,4 +1,16 @@
plugins {
id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
rootProject.name = "postcard-android"
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "postcard-android"
include(":app")