From 284c873a3dfce8f20c8ce5733b1aea5f3ed04977 Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Sun, 22 Mar 2026 14:45:37 -0500 Subject: [PATCH] mvp shell --- .gitignore | 4 +- .idea/caches/deviceStreaming.xml | 1538 +++++++++++++++++ .idea/codeStyles/Project.xml | 123 ++ .idea/codeStyles/codeStyleConfig.xml | 5 + .idea/deviceManager.xml | 13 + .idea/gradle.xml | 3 +- .idea/markdown.xml | 8 + .idea/migrations.xml | 10 + .idea/misc.xml | 3 +- .idea/runConfigurations.xml | 17 + app/build.gradle.kts | 100 ++ app/src/main/AndroidManifest.xml | 30 + .../main/java/com/postcard/MainActivity.kt | 89 + .../java/com/postcard/PostcardApplication.kt | 7 + .../postcard/data/auth/AuthTokenManager.kt | 41 + .../postcard/data/local/ConversationDao.kt | 28 + .../postcard/data/local/ConversationEntity.kt | 15 + .../com/postcard/data/local/MessageDao.kt | 25 + .../com/postcard/data/local/MessageEntity.kt | 18 + .../postcard/data/local/PostcardDatabase.kt | 15 + .../java/com/postcard/data/local/UserDao.kt | 22 + .../com/postcard/data/local/UserEntity.kt | 12 + .../data/repository/AuthRepository.kt | 77 + .../java/com/postcard/di/DatabaseModule.kt | 44 + .../java/com/postcard/di/NetworkModule.kt | 61 + .../com/postcard/ui/auth/AuthViewModel.kt | 64 + .../java/com/postcard/ui/auth/LoginScreen.kt | 83 + .../com/postcard/ui/auth/RegisterScreen.kt | 92 + .../conversations/ConversationListScreen.kt | 17 + .../main/java/com/postcard/ui/theme/Color.kt | 11 + .../main/java/com/postcard/ui/theme/Theme.kt | 57 + app/src/main/res/values/strings.xml | 3 + app/src/main/res/values/themes.xml | 5 + build.gradle.kts | 26 +- gradle.properties | 3 + gradle/gradle-daemon-jvm.properties | 13 + local.properties | 8 + settings.gradle.kts | 18 +- 38 files changed, 2682 insertions(+), 26 deletions(-) create mode 100644 .idea/caches/deviceStreaming.xml create mode 100644 .idea/codeStyles/Project.xml create mode 100644 .idea/codeStyles/codeStyleConfig.xml create mode 100644 .idea/deviceManager.xml create mode 100644 .idea/markdown.xml create mode 100644 .idea/migrations.xml create mode 100644 .idea/runConfigurations.xml create mode 100644 app/build.gradle.kts create mode 100644 app/src/main/AndroidManifest.xml create mode 100644 app/src/main/java/com/postcard/MainActivity.kt create mode 100644 app/src/main/java/com/postcard/PostcardApplication.kt create mode 100644 app/src/main/java/com/postcard/data/auth/AuthTokenManager.kt create mode 100644 app/src/main/java/com/postcard/data/local/ConversationDao.kt create mode 100644 app/src/main/java/com/postcard/data/local/ConversationEntity.kt create mode 100644 app/src/main/java/com/postcard/data/local/MessageDao.kt create mode 100644 app/src/main/java/com/postcard/data/local/MessageEntity.kt create mode 100644 app/src/main/java/com/postcard/data/local/PostcardDatabase.kt create mode 100644 app/src/main/java/com/postcard/data/local/UserDao.kt create mode 100644 app/src/main/java/com/postcard/data/local/UserEntity.kt create mode 100644 app/src/main/java/com/postcard/data/repository/AuthRepository.kt create mode 100644 app/src/main/java/com/postcard/di/DatabaseModule.kt create mode 100644 app/src/main/java/com/postcard/di/NetworkModule.kt create mode 100644 app/src/main/java/com/postcard/ui/auth/AuthViewModel.kt create mode 100644 app/src/main/java/com/postcard/ui/auth/LoginScreen.kt create mode 100644 app/src/main/java/com/postcard/ui/auth/RegisterScreen.kt create mode 100644 app/src/main/java/com/postcard/ui/conversations/ConversationListScreen.kt create mode 100644 app/src/main/java/com/postcard/ui/theme/Color.kt create mode 100644 app/src/main/java/com/postcard/ui/theme/Theme.kt create mode 100644 app/src/main/res/values/strings.xml create mode 100644 app/src/main/res/values/themes.xml create mode 100644 gradle/gradle-daemon-jvm.properties create mode 100644 local.properties diff --git a/.gitignore b/.gitignore index b1dff0d..49e73d5 100644 --- a/.gitignore +++ b/.gitignore @@ -42,4 +42,6 @@ bin/ .vscode/ ### Mac OS ### -.DS_Store \ No newline at end of file +.DS_Store + +WEBSOCKET_API_SPEC.md \ No newline at end of file diff --git a/.idea/caches/deviceStreaming.xml b/.idea/caches/deviceStreaming.xml new file mode 100644 index 0000000..8d6eef7 --- /dev/null +++ b/.idea/caches/deviceStreaming.xml @@ -0,0 +1,1538 @@ + + + + + + \ No newline at end of file diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml new file mode 100644 index 0000000..7643783 --- /dev/null +++ b/.idea/codeStyles/Project.xml @@ -0,0 +1,123 @@ + + + + + + + + + + \ No newline at end of file diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml new file mode 100644 index 0000000..79ee123 --- /dev/null +++ b/.idea/codeStyles/codeStyleConfig.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/.idea/deviceManager.xml b/.idea/deviceManager.xml new file mode 100644 index 0000000..91f9558 --- /dev/null +++ b/.idea/deviceManager.xml @@ -0,0 +1,13 @@ + + + + + + \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml index 2a65317..2ff74fd 100644 --- a/.idea/gradle.xml +++ b/.idea/gradle.xml @@ -4,13 +4,14 @@ diff --git a/.idea/markdown.xml b/.idea/markdown.xml new file mode 100644 index 0000000..c61ea33 --- /dev/null +++ b/.idea/markdown.xml @@ -0,0 +1,8 @@ + + + + + + \ No newline at end of file diff --git a/.idea/migrations.xml b/.idea/migrations.xml new file mode 100644 index 0000000..f8051a6 --- /dev/null +++ b/.idea/migrations.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml index 7a1bab3..d15a481 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -1,6 +1,7 @@ + - + \ No newline at end of file diff --git a/.idea/runConfigurations.xml b/.idea/runConfigurations.xml new file mode 100644 index 0000000..16660f1 --- /dev/null +++ b/.idea/runConfigurations.xml @@ -0,0 +1,17 @@ + + + + + + \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..35c2a13 --- /dev/null +++ b/app/build.gradle.kts @@ -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") +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..3502501 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/com/postcard/MainActivity.kt b/app/src/main/java/com/postcard/MainActivity.kt new file mode 100644 index 0000000..97d9e51 --- /dev/null +++ b/app/src/main/java/com/postcard/MainActivity.kt @@ -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 } + } + } + ) + } + } +} diff --git a/app/src/main/java/com/postcard/PostcardApplication.kt b/app/src/main/java/com/postcard/PostcardApplication.kt new file mode 100644 index 0000000..daf1fe3 --- /dev/null +++ b/app/src/main/java/com/postcard/PostcardApplication.kt @@ -0,0 +1,7 @@ +package com.postcard + +import android.app.Application +import dagger.hilt.android.HiltAndroidApp + +@HiltAndroidApp +class PostcardApplication : Application() diff --git a/app/src/main/java/com/postcard/data/auth/AuthTokenManager.kt b/app/src/main/java/com/postcard/data/auth/AuthTokenManager.kt new file mode 100644 index 0000000..b26b3bc --- /dev/null +++ b/app/src/main/java/com/postcard/data/auth/AuthTokenManager.kt @@ -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" + } +} diff --git a/app/src/main/java/com/postcard/data/local/ConversationDao.kt b/app/src/main/java/com/postcard/data/local/ConversationDao.kt new file mode 100644 index 0000000..36ea4e3 --- /dev/null +++ b/app/src/main/java/com/postcard/data/local/ConversationDao.kt @@ -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> + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertConversations(conversations: List) + + @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() +} diff --git a/app/src/main/java/com/postcard/data/local/ConversationEntity.kt b/app/src/main/java/com/postcard/data/local/ConversationEntity.kt new file mode 100644 index 0000000..85d2bd7 --- /dev/null +++ b/app/src/main/java/com/postcard/data/local/ConversationEntity.kt @@ -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 +) diff --git a/app/src/main/java/com/postcard/data/local/MessageDao.kt b/app/src/main/java/com/postcard/data/local/MessageDao.kt new file mode 100644 index 0000000..3e7f64a --- /dev/null +++ b/app/src/main/java/com/postcard/data/local/MessageDao.kt @@ -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> + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertMessage(message: MessageEntity) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertMessages(messages: List) + + @Query("UPDATE messages SET status = :status WHERE clientMsgId = :clientMsgId") + suspend fun updateMessageStatus(clientMsgId: String, status: String) + + @Query("DELETE FROM messages") + suspend fun clearMessages() +} diff --git a/app/src/main/java/com/postcard/data/local/MessageEntity.kt b/app/src/main/java/com/postcard/data/local/MessageEntity.kt new file mode 100644 index 0000000..459577b --- /dev/null +++ b/app/src/main/java/com/postcard/data/local/MessageEntity.kt @@ -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 +) diff --git a/app/src/main/java/com/postcard/data/local/PostcardDatabase.kt b/app/src/main/java/com/postcard/data/local/PostcardDatabase.kt new file mode 100644 index 0000000..b438e97 --- /dev/null +++ b/app/src/main/java/com/postcard/data/local/PostcardDatabase.kt @@ -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 +} diff --git a/app/src/main/java/com/postcard/data/local/UserDao.kt b/app/src/main/java/com/postcard/data/local/UserDao.kt new file mode 100644 index 0000000..e12bcb0 --- /dev/null +++ b/app/src/main/java/com/postcard/data/local/UserDao.kt @@ -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() +} diff --git a/app/src/main/java/com/postcard/data/local/UserEntity.kt b/app/src/main/java/com/postcard/data/local/UserEntity.kt new file mode 100644 index 0000000..fec1472 --- /dev/null +++ b/app/src/main/java/com/postcard/data/local/UserEntity.kt @@ -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? +) diff --git a/app/src/main/java/com/postcard/data/repository/AuthRepository.kt b/app/src/main/java/com/postcard/data/repository/AuthRepository.kt new file mode 100644 index 0000000..67c2783 --- /dev/null +++ b/app/src/main/java/com/postcard/data/repository/AuthRepository.kt @@ -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 { + 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 { + 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 +) diff --git a/app/src/main/java/com/postcard/di/DatabaseModule.kt b/app/src/main/java/com/postcard/di/DatabaseModule.kt new file mode 100644 index 0000000..decc44c --- /dev/null +++ b/app/src/main/java/com/postcard/di/DatabaseModule.kt @@ -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() + } +} diff --git a/app/src/main/java/com/postcard/di/NetworkModule.kt b/app/src/main/java/com/postcard/di/NetworkModule.kt new file mode 100644 index 0000000..ead6a54 --- /dev/null +++ b/app/src/main/java/com/postcard/di/NetworkModule.kt @@ -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) + } + } + } +} diff --git a/app/src/main/java/com/postcard/ui/auth/AuthViewModel.kt b/app/src/main/java/com/postcard/ui/auth/AuthViewModel.kt new file mode 100644 index 0000000..f3f151e --- /dev/null +++ b/app/src/main/java/com/postcard/ui/auth/AuthViewModel.kt @@ -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.Idle) + val uiState: StateFlow = _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() +} diff --git a/app/src/main/java/com/postcard/ui/auth/LoginScreen.kt b/app/src/main/java/com/postcard/ui/auth/LoginScreen.kt new file mode 100644 index 0000000..13ea223 --- /dev/null +++ b/app/src/main/java/com/postcard/ui/auth/LoginScreen.kt @@ -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") + } + } +} diff --git a/app/src/main/java/com/postcard/ui/auth/RegisterScreen.kt b/app/src/main/java/com/postcard/ui/auth/RegisterScreen.kt new file mode 100644 index 0000000..91fd790 --- /dev/null +++ b/app/src/main/java/com/postcard/ui/auth/RegisterScreen.kt @@ -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") + } + } +} diff --git a/app/src/main/java/com/postcard/ui/conversations/ConversationListScreen.kt b/app/src/main/java/com/postcard/ui/conversations/ConversationListScreen.kt new file mode 100644 index 0000000..8ddeb51 --- /dev/null +++ b/app/src/main/java/com/postcard/ui/conversations/ConversationListScreen.kt @@ -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") + } +} diff --git a/app/src/main/java/com/postcard/ui/theme/Color.kt b/app/src/main/java/com/postcard/ui/theme/Color.kt new file mode 100644 index 0000000..0daa79b --- /dev/null +++ b/app/src/main/java/com/postcard/ui/theme/Color.kt @@ -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) diff --git a/app/src/main/java/com/postcard/ui/theme/Theme.kt b/app/src/main/java/com/postcard/ui/theme/Theme.kt new file mode 100644 index 0000000..fe93c3e --- /dev/null +++ b/app/src/main/java/com/postcard/ui/theme/Theme.kt @@ -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 + ) +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..c95bb42 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + Postcard + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..cadada1 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,5 @@ + + + + diff --git a/build.gradle.kts b/build.gradle.kts index 7fa8bbf..058fb0b 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -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() -} \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index 7fc6f1f..8f2e28c 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1 +1,4 @@ +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +android.useAndroidX=true +android.nonTransitiveRClass=true kotlin.code.style=official diff --git a/gradle/gradle-daemon-jvm.properties b/gradle/gradle-daemon-jvm.properties new file mode 100644 index 0000000..8e2a58b --- /dev/null +++ b/gradle/gradle-daemon-jvm.properties @@ -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 diff --git a/local.properties b/local.properties new file mode 100644 index 0000000..4cd8c65 --- /dev/null +++ b/local.properties @@ -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 diff --git a/settings.gradle.kts b/settings.gradle.kts index 5f7aa41..e8840b0 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -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" \ No newline at end of file +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} +rootProject.name = "postcard-android" +include(":app")