diff --git a/.gitignore b/.gitignore
index 566e06b..27d968a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -24,4 +24,20 @@ hs_err_pid*
replay_pid*
# Kotlin Gradle plugin data, see https://kotlinlang.org/docs/whatsnew20.html#new-directory-for-kotlin-data-in-gradle-projects
-.kotlin/
\ No newline at end of file
+.kotlin/
+
+# Gradle
+.gradle/
+build/
+!gradle/wrapper/gradle-wrapper.jar
+
+# IDE
+.idea/
+*.iml
+
+# Local configuration
+local.properties
+
+# OS files
+.DS_Store
+Thumbs.db
diff --git a/README.md b/README.md
index 9492364..5283604 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,43 @@
# Fuzzel
-Fuzzel is an Android app for Fizzy
+
+Fuzzel is an Android client for [Fizzy](https://fizzy.io), the card-based project management tool from 37signals.
+
+## Building
+
+```bash
+./gradlew assembleDebug
+```
+
+## Architecture
+
+The app follows Clean Architecture with the following layers:
+
+- **Domain** - Business logic, models, and repository interfaces
+- **Data** - API services, DTOs, and repository implementations
+- **Presentation** - ViewModels and Compose UI
+
+## API Integration
+
+The app integrates with the Fizzy API using the following key endpoints:
+
+### Authentication
+- `POST /session` - Request magic link
+- `POST /session/magic_link` - Verify magic link code
+- Personal Access Token support via `GET /my/identity.json`
+
+### Resources
+- **Boards**: CRUD operations at `/boards`
+- **Cards**: Operations use card `number` (not ID) at `/cards/{cardNumber}`
+- **Card Actions**: Separate endpoints for close (`/closure`), triage (`/triage`), priority (`/goldness`), watch (`/watch`)
+- **Tags**: Account-level tags at `/tags`, card taggings at `/cards/{cardNumber}/taggings`
+- **Comments**: Nested under cards at `/cards/{cardNumber}/comments`
+- **Steps**: Nested under cards at `/cards/{cardNumber}/steps`
+- **Notifications**: Mark read via `POST /notifications/{id}/reading`
+
+## Tech Stack
+
+- Kotlin
+- Jetpack Compose
+- Hilt (DI)
+- Retrofit + Moshi
+- Coroutines + Flow
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
new file mode 100644
index 0000000..a2d424e
--- /dev/null
+++ b/app/build.gradle.kts
@@ -0,0 +1,125 @@
+plugins {
+ id("com.android.application")
+ id("org.jetbrains.kotlin.android")
+ id("com.google.dagger.hilt.android")
+ id("com.google.devtools.ksp")
+}
+
+android {
+ namespace = "com.fizzy.android"
+ compileSdk = 34
+
+ defaultConfig {
+ applicationId = "com.fizzy.android"
+ minSdk = 26
+ targetSdk = 34
+ versionCode = 1
+ versionName = "1.0.0"
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ vectorDrawables {
+ useSupportLibrary = true
+ }
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = true
+ isShrinkResources = true
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro"
+ )
+ }
+ debug {
+ isDebuggable = true
+ }
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+
+ kotlinOptions {
+ jvmTarget = "17"
+ }
+
+ buildFeatures {
+ compose = true
+ buildConfig = true
+ }
+
+ composeOptions {
+ kotlinCompilerExtensionVersion = "1.5.8"
+ }
+
+ packaging {
+ resources {
+ excludes += "/META-INF/{AL2.0,LGPL2.1}"
+ }
+ }
+}
+
+dependencies {
+ // Core Android
+ implementation("androidx.core:core-ktx:1.12.0")
+ implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
+ implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0")
+ implementation("androidx.activity:activity-compose:1.8.2")
+
+ // Compose BOM
+ implementation(platform("androidx.compose:compose-bom:2024.02.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.compose.material:material-icons-extended")
+
+ // Navigation
+ implementation("androidx.navigation:navigation-compose:2.7.7")
+
+ // Hilt
+ implementation("com.google.dagger:hilt-android:2.50")
+ ksp("com.google.dagger:hilt-android-compiler:2.50")
+ implementation("androidx.hilt:hilt-navigation-compose:1.1.0")
+
+ // Retrofit + Moshi
+ implementation("com.squareup.retrofit2:retrofit:2.9.0")
+ implementation("com.squareup.retrofit2:converter-moshi:2.9.0")
+ implementation("com.squareup.moshi:moshi-kotlin:1.15.0")
+ ksp("com.squareup.moshi:moshi-kotlin-codegen:1.15.0")
+
+ // OkHttp
+ implementation("com.squareup.okhttp3:okhttp:4.12.0")
+ implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
+
+ // Coroutines
+ implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
+
+ // DataStore
+ implementation("androidx.datastore:datastore-preferences:1.0.0")
+
+ // Security (EncryptedSharedPreferences)
+ implementation("androidx.security:security-crypto:1.1.0-alpha06")
+
+ // Coil for images
+ implementation("io.coil-kt:coil-compose:2.5.0")
+
+ // Splash screen
+ implementation("androidx.core:core-splashscreen:1.0.1")
+
+ // Testing
+ testImplementation("junit:junit:4.13.2")
+ testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3")
+ testImplementation("io.mockk:mockk:1.13.9")
+ testImplementation("app.cash.turbine:turbine:1.0.0")
+
+ androidTestImplementation("androidx.test.ext:junit:1.1.5")
+ androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
+ androidTestImplementation(platform("androidx.compose:compose-bom:2024.02.00"))
+ androidTestImplementation("androidx.compose.ui:ui-test-junit4")
+
+ debugImplementation("androidx.compose.ui:ui-tooling")
+ debugImplementation("androidx.compose.ui:ui-test-manifest")
+}
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
new file mode 100644
index 0000000..d1c1e91
--- /dev/null
+++ b/app/proguard-rules.pro
@@ -0,0 +1,38 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.kts.
+
+# Keep Moshi JSON adapters
+-keep class com.fizzy.android.data.api.dto.** { *; }
+-keepclassmembers class com.fizzy.android.data.api.dto.** { *; }
+
+# Keep Retrofit interfaces
+-keep,allowobfuscation,allowshrinking interface retrofit2.Call
+-keep,allowobfuscation,allowshrinking class retrofit2.Response
+-keep,allowobfuscation,allowshrinking class kotlin.coroutines.Continuation
+
+# Keep Moshi adapters
+-keep class com.squareup.moshi.** { *; }
+-keep interface com.squareup.moshi.** { *; }
+-keepclassmembers class * {
+ @com.squareup.moshi.FromJson *;
+ @com.squareup.moshi.ToJson *;
+}
+
+# Keep Hilt generated components
+-keep class dagger.hilt.** { *; }
+-keep class javax.inject.** { *; }
+-keep class * extends dagger.hilt.android.internal.managers.ComponentSupplier { *; }
+
+# Keep domain models
+-keep class com.fizzy.android.domain.model.** { *; }
+
+# Coroutines
+-keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {}
+-keepnames class kotlinx.coroutines.CoroutineExceptionHandler {}
+
+# OkHttp
+-dontwarn okhttp3.internal.platform.**
+-dontwarn org.conscrypt.**
+-dontwarn org.bouncycastle.**
+-dontwarn org.openjsse.**
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..41d773a
--- /dev/null
+++ b/app/src/main/AndroidManifest.xml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/java/com/fizzy/android/app/FizzyApplication.kt b/app/src/main/java/com/fizzy/android/app/FizzyApplication.kt
new file mode 100644
index 0000000..7d27e2a
--- /dev/null
+++ b/app/src/main/java/com/fizzy/android/app/FizzyApplication.kt
@@ -0,0 +1,7 @@
+package com.fizzy.android.app
+
+import android.app.Application
+import dagger.hilt.android.HiltAndroidApp
+
+@HiltAndroidApp
+class FizzyApplication : Application()
diff --git a/app/src/main/java/com/fizzy/android/app/FizzyNavHost.kt b/app/src/main/java/com/fizzy/android/app/FizzyNavHost.kt
new file mode 100644
index 0000000..dddf8d3
--- /dev/null
+++ b/app/src/main/java/com/fizzy/android/app/FizzyNavHost.kt
@@ -0,0 +1,119 @@
+package com.fizzy.android.app
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.navigation.NavType
+import androidx.navigation.compose.NavHost
+import androidx.navigation.compose.composable
+import androidx.navigation.compose.rememberNavController
+import androidx.navigation.navArgument
+import com.fizzy.android.feature.auth.AuthScreen
+import com.fizzy.android.feature.auth.AuthViewModel
+import com.fizzy.android.feature.boards.BoardListScreen
+import com.fizzy.android.feature.card.CardDetailScreen
+import com.fizzy.android.feature.kanban.KanbanScreen
+import com.fizzy.android.feature.notifications.NotificationsScreen
+import com.fizzy.android.feature.settings.SettingsScreen
+
+sealed class Screen(val route: String) {
+ data object Auth : Screen("auth")
+ data object Boards : Screen("boards")
+ data object Kanban : Screen("kanban/{boardId}") {
+ fun createRoute(boardId: String) = "kanban/$boardId"
+ }
+ data object CardDetail : Screen("card/{cardId}") {
+ fun createRoute(cardId: Long) = "card/$cardId"
+ }
+ data object Notifications : Screen("notifications")
+ data object Settings : Screen("settings")
+}
+
+@Composable
+fun FizzyNavHost() {
+ val navController = rememberNavController()
+ val authViewModel: AuthViewModel = hiltViewModel()
+ val isLoggedIn by authViewModel.isLoggedIn.collectAsState()
+
+ LaunchedEffect(Unit) {
+ authViewModel.initializeAuth()
+ }
+
+ NavHost(
+ navController = navController,
+ startDestination = if (isLoggedIn) Screen.Boards.route else Screen.Auth.route
+ ) {
+ composable(Screen.Auth.route) {
+ AuthScreen(
+ onAuthSuccess = {
+ navController.navigate(Screen.Boards.route) {
+ popUpTo(Screen.Auth.route) { inclusive = true }
+ }
+ }
+ )
+ }
+
+ composable(Screen.Boards.route) {
+ BoardListScreen(
+ onBoardClick = { boardId ->
+ navController.navigate(Screen.Kanban.createRoute(boardId))
+ },
+ onNotificationsClick = {
+ navController.navigate(Screen.Notifications.route)
+ },
+ onSettingsClick = {
+ navController.navigate(Screen.Settings.route)
+ }
+ )
+ }
+
+ composable(
+ route = Screen.Kanban.route,
+ arguments = listOf(navArgument("boardId") { type = NavType.StringType })
+ ) { backStackEntry ->
+ val boardId = backStackEntry.arguments?.getString("boardId") ?: return@composable
+ KanbanScreen(
+ boardId = boardId,
+ onBackClick = { navController.popBackStack() },
+ onCardClick = { cardId ->
+ navController.navigate(Screen.CardDetail.createRoute(cardId))
+ }
+ )
+ }
+
+ composable(
+ route = Screen.CardDetail.route,
+ arguments = listOf(navArgument("cardId") { type = NavType.LongType })
+ ) { backStackEntry ->
+ val cardId = backStackEntry.arguments?.getLong("cardId") ?: return@composable
+ CardDetailScreen(
+ cardId = cardId,
+ onBackClick = { navController.popBackStack() }
+ )
+ }
+
+ composable(Screen.Notifications.route) {
+ NotificationsScreen(
+ onBackClick = { navController.popBackStack() },
+ onNotificationClick = { notification ->
+ notification.cardId?.let { cardId ->
+ navController.navigate(Screen.CardDetail.createRoute(cardId))
+ }
+ }
+ )
+ }
+
+ composable(Screen.Settings.route) {
+ SettingsScreen(
+ onBackClick = { navController.popBackStack() },
+ onLogout = {
+ navController.navigate(Screen.Auth.route) {
+ popUpTo(Screen.Boards.route) { inclusive = true }
+ }
+ }
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/com/fizzy/android/app/MainActivity.kt b/app/src/main/java/com/fizzy/android/app/MainActivity.kt
new file mode 100644
index 0000000..c204130
--- /dev/null
+++ b/app/src/main/java/com/fizzy/android/app/MainActivity.kt
@@ -0,0 +1,50 @@
+package com.fizzy.android.app
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
+import com.fizzy.android.core.ui.theme.FizzyTheme
+import com.fizzy.android.data.local.SettingsStorage
+import com.fizzy.android.data.local.ThemeMode
+import dagger.hilt.android.AndroidEntryPoint
+import javax.inject.Inject
+
+@AndroidEntryPoint
+class MainActivity : ComponentActivity() {
+
+ @Inject
+ lateinit var settingsStorage: SettingsStorage
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ installSplashScreen()
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+
+ setContent {
+ val themeMode by settingsStorage.themeMode.collectAsState(initial = ThemeMode.SYSTEM)
+ val darkTheme = when (themeMode) {
+ ThemeMode.SYSTEM -> isSystemInDarkTheme()
+ ThemeMode.LIGHT -> false
+ ThemeMode.DARK -> true
+ }
+
+ FizzyTheme(darkTheme = darkTheme) {
+ Surface(
+ modifier = Modifier.fillMaxSize(),
+ color = MaterialTheme.colorScheme.background
+ ) {
+ FizzyNavHost()
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/fizzy/android/core/di/DataModule.kt b/app/src/main/java/com/fizzy/android/core/di/DataModule.kt
new file mode 100644
index 0000000..edf730f
--- /dev/null
+++ b/app/src/main/java/com/fizzy/android/core/di/DataModule.kt
@@ -0,0 +1,60 @@
+package com.fizzy.android.core.di
+
+import android.content.Context
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.preferencesDataStore
+import com.fizzy.android.data.local.AccountStorage
+import com.fizzy.android.data.local.AccountStorageImpl
+import com.fizzy.android.data.local.SettingsStorage
+import com.fizzy.android.data.local.SettingsStorageImpl
+import com.fizzy.android.data.repository.*
+import com.fizzy.android.domain.repository.*
+import dagger.Binds
+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
+
+private val Context.dataStore: DataStore by preferencesDataStore(name = "fizzy_settings")
+
+@Module
+@InstallIn(SingletonComponent::class)
+object DataModule {
+
+ @Provides
+ @Singleton
+ fun provideDataStore(@ApplicationContext context: Context): DataStore =
+ context.dataStore
+}
+
+@Module
+@InstallIn(SingletonComponent::class)
+abstract class RepositoryModule {
+
+ @Binds
+ @Singleton
+ abstract fun bindAccountStorage(impl: AccountStorageImpl): AccountStorage
+
+ @Binds
+ @Singleton
+ abstract fun bindSettingsStorage(impl: SettingsStorageImpl): SettingsStorage
+
+ @Binds
+ @Singleton
+ abstract fun bindAuthRepository(impl: AuthRepositoryImpl): AuthRepository
+
+ @Binds
+ @Singleton
+ abstract fun bindBoardRepository(impl: BoardRepositoryImpl): BoardRepository
+
+ @Binds
+ @Singleton
+ abstract fun bindCardRepository(impl: CardRepositoryImpl): CardRepository
+
+ @Binds
+ @Singleton
+ abstract fun bindNotificationRepository(impl: NotificationRepositoryImpl): NotificationRepository
+}
diff --git a/app/src/main/java/com/fizzy/android/core/di/NetworkModule.kt b/app/src/main/java/com/fizzy/android/core/di/NetworkModule.kt
new file mode 100644
index 0000000..f770159
--- /dev/null
+++ b/app/src/main/java/com/fizzy/android/core/di/NetworkModule.kt
@@ -0,0 +1,66 @@
+package com.fizzy.android.core.di
+
+import com.fizzy.android.core.network.AuthInterceptor
+import com.fizzy.android.core.network.InstanceInterceptor
+import com.fizzy.android.data.api.FizzyApiService
+import com.squareup.moshi.Moshi
+import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import okhttp3.OkHttpClient
+import okhttp3.logging.HttpLoggingInterceptor
+import retrofit2.Retrofit
+import retrofit2.converter.moshi.MoshiConverterFactory
+import java.util.concurrent.TimeUnit
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+object NetworkModule {
+
+ @Provides
+ @Singleton
+ fun provideMoshi(): Moshi = Moshi.Builder()
+ .addLast(KotlinJsonAdapterFactory())
+ .build()
+
+ @Provides
+ @Singleton
+ fun provideLoggingInterceptor(): HttpLoggingInterceptor =
+ HttpLoggingInterceptor().apply {
+ level = HttpLoggingInterceptor.Level.BODY
+ }
+
+ @Provides
+ @Singleton
+ fun provideOkHttpClient(
+ loggingInterceptor: HttpLoggingInterceptor,
+ instanceInterceptor: InstanceInterceptor,
+ authInterceptor: AuthInterceptor
+ ): OkHttpClient = OkHttpClient.Builder()
+ .addInterceptor(instanceInterceptor)
+ .addInterceptor(authInterceptor)
+ .addInterceptor(loggingInterceptor)
+ .connectTimeout(30, TimeUnit.SECONDS)
+ .readTimeout(30, TimeUnit.SECONDS)
+ .writeTimeout(30, TimeUnit.SECONDS)
+ .build()
+
+ @Provides
+ @Singleton
+ fun provideRetrofit(
+ okHttpClient: OkHttpClient,
+ moshi: Moshi
+ ): Retrofit = Retrofit.Builder()
+ .baseUrl("https://placeholder.fizzy.com/") // Will be overridden by InstanceInterceptor
+ .client(okHttpClient)
+ .addConverterFactory(MoshiConverterFactory.create(moshi))
+ .build()
+
+ @Provides
+ @Singleton
+ fun provideFizzyApiService(retrofit: Retrofit): FizzyApiService =
+ retrofit.create(FizzyApiService::class.java)
+}
diff --git a/app/src/main/java/com/fizzy/android/core/network/ApiResult.kt b/app/src/main/java/com/fizzy/android/core/network/ApiResult.kt
new file mode 100644
index 0000000..6d79e32
--- /dev/null
+++ b/app/src/main/java/com/fizzy/android/core/network/ApiResult.kt
@@ -0,0 +1,85 @@
+package com.fizzy.android.core.network
+
+import retrofit2.Response
+
+sealed class ApiResult {
+ data class Success(val data: T) : ApiResult()
+ data class Error(val code: Int, val message: String) : ApiResult()
+ data class Exception(val throwable: Throwable) : ApiResult()
+
+ val isSuccess: Boolean get() = this is Success
+ val isError: Boolean get() = this is Error
+ val isException: Boolean get() = this is Exception
+
+ fun getOrNull(): T? = (this as? Success)?.data
+
+ fun getOrThrow(): T = when (this) {
+ is Success -> data
+ is Error -> throw RuntimeException("API Error: $code - $message")
+ is Exception -> throw throwable
+ }
+
+ fun map(transform: (T) -> R): ApiResult = when (this) {
+ is Success -> Success(transform(data))
+ is Error -> this
+ is Exception -> this
+ }
+
+ suspend fun mapSuspend(transform: suspend (T) -> R): ApiResult = when (this) {
+ is Success -> Success(transform(data))
+ is Error -> this
+ is Exception -> this
+ }
+
+ companion object {
+ suspend fun from(block: suspend () -> Response): ApiResult {
+ return try {
+ val response = block()
+ if (response.isSuccessful) {
+ val body = response.body()
+ if (body != null) {
+ Success(body)
+ } else {
+ @Suppress("UNCHECKED_CAST")
+ Success(Unit as T)
+ }
+ } else {
+ Error(response.code(), response.message())
+ }
+ } catch (e: kotlin.Exception) {
+ Exception(e)
+ }
+ }
+ }
+}
+
+inline fun ApiResult.fold(
+ onSuccess: (T) -> R,
+ onError: (Int, String) -> R,
+ onException: (Throwable) -> R
+): R = when (this) {
+ is ApiResult.Success -> onSuccess(data)
+ is ApiResult.Error -> onError(code, message)
+ is ApiResult.Exception -> onException(throwable)
+}
+
+inline fun ApiResult.onSuccess(action: (T) -> Unit): ApiResult {
+ if (this is ApiResult.Success) {
+ action(data)
+ }
+ return this
+}
+
+inline fun ApiResult.onError(action: (Int, String) -> Unit): ApiResult {
+ if (this is ApiResult.Error) {
+ action(code, message)
+ }
+ return this
+}
+
+inline fun ApiResult.onException(action: (Throwable) -> Unit): ApiResult {
+ if (this is ApiResult.Exception) {
+ action(throwable)
+ }
+ return this
+}
diff --git a/app/src/main/java/com/fizzy/android/core/network/AuthInterceptor.kt b/app/src/main/java/com/fizzy/android/core/network/AuthInterceptor.kt
new file mode 100644
index 0000000..41e81c8
--- /dev/null
+++ b/app/src/main/java/com/fizzy/android/core/network/AuthInterceptor.kt
@@ -0,0 +1,50 @@
+package com.fizzy.android.core.network
+
+import android.util.Log
+import okhttp3.Interceptor
+import okhttp3.Response
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class AuthInterceptor @Inject constructor(
+ private val instanceManager: InstanceManager
+) : Interceptor {
+
+ override fun intercept(chain: Interceptor.Chain): Response {
+ val originalRequest = chain.request()
+ val token = instanceManager.getToken()
+
+ val request = if (!token.isNullOrBlank()) {
+ // Try token without Bearer prefix first (some APIs like 37signals use this)
+ val authHeader = if (token.startsWith("Bearer ")) {
+ token
+ } else {
+ "Bearer $token"
+ }
+
+ Log.d("AuthInterceptor", "Request URL: ${originalRequest.url}")
+ Log.d("AuthInterceptor", "Token length: ${token.length}")
+
+ originalRequest.newBuilder()
+ .header("Authorization", authHeader)
+ .header("Accept", "application/json")
+ .header("Content-Type", "application/json")
+ .build()
+ } else {
+ Log.d("AuthInterceptor", "No token available for: ${originalRequest.url}")
+ originalRequest.newBuilder()
+ .header("Accept", "application/json")
+ .header("Content-Type", "application/json")
+ .build()
+ }
+
+ val response = chain.proceed(request)
+
+ if (!response.isSuccessful) {
+ Log.e("AuthInterceptor", "Request failed: ${response.code} - ${response.message}")
+ }
+
+ return response
+ }
+}
diff --git a/app/src/main/java/com/fizzy/android/core/network/InstanceInterceptor.kt b/app/src/main/java/com/fizzy/android/core/network/InstanceInterceptor.kt
new file mode 100644
index 0000000..2266150
--- /dev/null
+++ b/app/src/main/java/com/fizzy/android/core/network/InstanceInterceptor.kt
@@ -0,0 +1,52 @@
+package com.fizzy.android.core.network
+
+import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
+import okhttp3.Interceptor
+import okhttp3.Response
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class InstanceInterceptor @Inject constructor(
+ private val instanceManager: InstanceManager
+) : Interceptor {
+
+ override fun intercept(chain: Interceptor.Chain): Response {
+ val originalRequest = chain.request()
+ val baseUrl = instanceManager.getBaseUrl()
+
+ if (baseUrl == null) {
+ return chain.proceed(originalRequest)
+ }
+
+ val accountSlug = instanceManager.getAccountSlug()
+ val originalPath = originalRequest.url.encodedPath
+
+ // Global paths that don't need account prefix
+ val isGlobalPath = originalPath.startsWith("/my/") ||
+ originalPath.startsWith("/magic_links") ||
+ originalPath.startsWith("/sessions")
+
+ val newUrl = baseUrl.toHttpUrlOrNull()?.let { newBaseUrl ->
+ val urlBuilder = originalRequest.url.newBuilder()
+ .scheme(newBaseUrl.scheme)
+ .host(newBaseUrl.host)
+ .port(newBaseUrl.port)
+
+ // Add account slug prefix for non-global paths
+ if (!isGlobalPath && accountSlug != null) {
+ // Build new path with account slug prefix
+ val newPath = "/$accountSlug$originalPath"
+ urlBuilder.encodedPath(newPath)
+ }
+
+ urlBuilder.build()
+ } ?: originalRequest.url
+
+ val newRequest = originalRequest.newBuilder()
+ .url(newUrl)
+ .build()
+
+ return chain.proceed(newRequest)
+ }
+}
diff --git a/app/src/main/java/com/fizzy/android/core/network/InstanceManager.kt b/app/src/main/java/com/fizzy/android/core/network/InstanceManager.kt
new file mode 100644
index 0000000..44b765b
--- /dev/null
+++ b/app/src/main/java/com/fizzy/android/core/network/InstanceManager.kt
@@ -0,0 +1,54 @@
+package com.fizzy.android.core.network
+
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class InstanceManager @Inject constructor() {
+
+ private val _currentInstance = MutableStateFlow(null)
+ val currentInstance: StateFlow = _currentInstance.asStateFlow()
+
+ private val _currentToken = MutableStateFlow(null)
+ val currentToken: StateFlow = _currentToken.asStateFlow()
+
+ private val _accountSlug = MutableStateFlow(null)
+ val accountSlug: StateFlow = _accountSlug.asStateFlow()
+
+ fun setInstance(baseUrl: String, token: String, slug: String? = null) {
+ val normalizedUrl = normalizeUrl(baseUrl)
+ _currentInstance.value = normalizedUrl
+ _currentToken.value = token
+ _accountSlug.value = slug?.removePrefix("/")
+ }
+
+ fun clearInstance() {
+ _currentInstance.value = null
+ _currentToken.value = null
+ _accountSlug.value = null
+ }
+
+ fun getBaseUrl(): String? = _currentInstance.value
+
+ fun getToken(): String? = _currentToken.value
+
+ fun getAccountSlug(): String? = _accountSlug.value
+
+ private fun normalizeUrl(url: String): String {
+ var normalized = url.trim()
+ if (!normalized.startsWith("http://") && !normalized.startsWith("https://")) {
+ normalized = "https://$normalized"
+ }
+ if (!normalized.endsWith("/")) {
+ normalized = "$normalized/"
+ }
+ return normalized
+ }
+
+ companion object {
+ const val OFFICIAL_INSTANCE = "https://fizzy.com/"
+ }
+}
diff --git a/app/src/main/java/com/fizzy/android/core/ui/components/EmptyState.kt b/app/src/main/java/com/fizzy/android/core/ui/components/EmptyState.kt
new file mode 100644
index 0000000..f2b8fe0
--- /dev/null
+++ b/app/src/main/java/com/fizzy/android/core/ui/components/EmptyState.kt
@@ -0,0 +1,60 @@
+package com.fizzy.android.core.ui.components
+
+import androidx.compose.foundation.layout.*
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+
+@Composable
+fun EmptyState(
+ icon: ImageVector,
+ title: String,
+ description: String? = null,
+ action: @Composable (() -> Unit)? = null,
+ modifier: Modifier = Modifier
+) {
+ Column(
+ modifier = modifier
+ .fillMaxSize()
+ .padding(32.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
+ ) {
+ Icon(
+ imageVector = icon,
+ contentDescription = null,
+ modifier = Modifier.size(64.dp),
+ tint = MaterialTheme.colorScheme.outline
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ Text(
+ text = title,
+ style = MaterialTheme.typography.titleMedium,
+ textAlign = TextAlign.Center,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+
+ if (description != null) {
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = description,
+ style = MaterialTheme.typography.bodyMedium,
+ textAlign = TextAlign.Center,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+
+ if (action != null) {
+ Spacer(modifier = Modifier.height(24.dp))
+ action()
+ }
+ }
+}
diff --git a/app/src/main/java/com/fizzy/android/core/ui/components/ErrorMessage.kt b/app/src/main/java/com/fizzy/android/core/ui/components/ErrorMessage.kt
new file mode 100644
index 0000000..b79a17b
--- /dev/null
+++ b/app/src/main/java/com/fizzy/android/core/ui/components/ErrorMessage.kt
@@ -0,0 +1,86 @@
+package com.fizzy.android.core.ui.components
+
+import androidx.compose.foundation.layout.*
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Error
+import androidx.compose.material.icons.filled.Refresh
+import androidx.compose.material3.*
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+
+@Composable
+fun ErrorMessage(
+ message: String,
+ onRetry: (() -> Unit)? = null,
+ modifier: Modifier = Modifier
+) {
+ Column(
+ modifier = modifier
+ .fillMaxSize()
+ .padding(32.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
+ ) {
+ Icon(
+ imageVector = Icons.Default.Error,
+ contentDescription = null,
+ modifier = Modifier.size(64.dp),
+ tint = MaterialTheme.colorScheme.error
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ Text(
+ text = message,
+ style = MaterialTheme.typography.bodyLarge,
+ textAlign = TextAlign.Center,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+
+ if (onRetry != null) {
+ Spacer(modifier = Modifier.height(24.dp))
+
+ Button(
+ onClick = onRetry,
+ colors = ButtonDefaults.buttonColors(
+ containerColor = MaterialTheme.colorScheme.primary
+ )
+ ) {
+ Icon(
+ imageVector = Icons.Default.Refresh,
+ contentDescription = null,
+ modifier = Modifier.size(18.dp)
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Text("Retry")
+ }
+ }
+ }
+}
+
+@Composable
+fun InlineError(
+ message: String,
+ modifier: Modifier = Modifier
+) {
+ Row(
+ modifier = modifier.padding(horizontal = 16.dp, vertical = 8.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ imageVector = Icons.Default.Error,
+ contentDescription = null,
+ modifier = Modifier.size(16.dp),
+ tint = MaterialTheme.colorScheme.error
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(
+ text = message,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.error
+ )
+ }
+}
diff --git a/app/src/main/java/com/fizzy/android/core/ui/components/LoadingIndicator.kt b/app/src/main/java/com/fizzy/android/core/ui/components/LoadingIndicator.kt
new file mode 100644
index 0000000..4b539ad
--- /dev/null
+++ b/app/src/main/java/com/fizzy/android/core/ui/components/LoadingIndicator.kt
@@ -0,0 +1,37 @@
+package com.fizzy.android.core.ui.components
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+
+@Composable
+fun LoadingIndicator(
+ modifier: Modifier = Modifier
+) {
+ Box(
+ modifier = modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center
+ ) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(48.dp),
+ color = MaterialTheme.colorScheme.primary
+ )
+ }
+}
+
+@Composable
+fun SmallLoadingIndicator(
+ modifier: Modifier = Modifier
+) {
+ CircularProgressIndicator(
+ modifier = modifier.size(24.dp),
+ color = MaterialTheme.colorScheme.primary,
+ strokeWidth = 2.dp
+ )
+}
diff --git a/app/src/main/java/com/fizzy/android/core/ui/theme/Color.kt b/app/src/main/java/com/fizzy/android/core/ui/theme/Color.kt
new file mode 100644
index 0000000..94efd97
--- /dev/null
+++ b/app/src/main/java/com/fizzy/android/core/ui/theme/Color.kt
@@ -0,0 +1,96 @@
+package com.fizzy.android.core.ui.theme
+
+import androidx.compose.ui.graphics.Color
+
+// Primary colors - Fizzy Blue
+val FizzyBlue = Color(0xFF2563EB)
+val FizzyBlueDark = Color(0xFF1D4ED8)
+val FizzyBlueLight = Color(0xFF60A5FA)
+
+// Secondary colors - Teal/Green
+val FizzyTeal = Color(0xFF10B981)
+val FizzyTealDark = Color(0xFF059669)
+val FizzyTealLight = Color(0xFF34D399)
+
+// Status colors
+val FizzyGold = Color(0xFFF59E0B) // Priority
+val FizzyOrange = Color(0xFFF97316) // Triage
+val FizzyRed = Color(0xFFEF4444) // Closed/Error
+val FizzyPurple = Color(0xFF8B5CF6) // Deferred
+val FizzyGreen = Color(0xFF22C55E) // Success
+
+// Neutral colors - Light theme
+val NeutralWhite = Color(0xFFFFFFFF)
+val NeutralGray50 = Color(0xFFF9FAFB)
+val NeutralGray100 = Color(0xFFF3F4F6)
+val NeutralGray200 = Color(0xFFE5E7EB)
+val NeutralGray300 = Color(0xFFD1D5DB)
+val NeutralGray400 = Color(0xFF9CA3AF)
+val NeutralGray500 = Color(0xFF6B7280)
+val NeutralGray600 = Color(0xFF4B5563)
+val NeutralGray700 = Color(0xFF374151)
+val NeutralGray800 = Color(0xFF1F2937)
+val NeutralGray900 = Color(0xFF111827)
+val NeutralBlack = Color(0xFF000000)
+
+// Material 3 Light Scheme
+val md_theme_light_primary = FizzyBlue
+val md_theme_light_onPrimary = NeutralWhite
+val md_theme_light_primaryContainer = Color(0xFFD6E4FF)
+val md_theme_light_onPrimaryContainer = Color(0xFF001A41)
+val md_theme_light_secondary = FizzyTeal
+val md_theme_light_onSecondary = NeutralWhite
+val md_theme_light_secondaryContainer = Color(0xFFB4F1DF)
+val md_theme_light_onSecondaryContainer = Color(0xFF00201A)
+val md_theme_light_tertiary = FizzyPurple
+val md_theme_light_onTertiary = NeutralWhite
+val md_theme_light_tertiaryContainer = Color(0xFFEADDFF)
+val md_theme_light_onTertiaryContainer = Color(0xFF21005D)
+val md_theme_light_error = FizzyRed
+val md_theme_light_errorContainer = Color(0xFFFFDAD6)
+val md_theme_light_onError = NeutralWhite
+val md_theme_light_onErrorContainer = Color(0xFF410002)
+val md_theme_light_background = NeutralGray50
+val md_theme_light_onBackground = NeutralGray900
+val md_theme_light_surface = NeutralWhite
+val md_theme_light_onSurface = NeutralGray900
+val md_theme_light_surfaceVariant = NeutralGray100
+val md_theme_light_onSurfaceVariant = NeutralGray700
+val md_theme_light_outline = NeutralGray400
+val md_theme_light_inverseOnSurface = NeutralGray50
+val md_theme_light_inverseSurface = NeutralGray800
+val md_theme_light_inversePrimary = FizzyBlueLight
+val md_theme_light_surfaceTint = FizzyBlue
+val md_theme_light_outlineVariant = NeutralGray200
+val md_theme_light_scrim = NeutralBlack
+
+// Material 3 Dark Scheme
+val md_theme_dark_primary = FizzyBlueLight
+val md_theme_dark_onPrimary = Color(0xFF002E6A)
+val md_theme_dark_primaryContainer = FizzyBlueDark
+val md_theme_dark_onPrimaryContainer = Color(0xFFD6E4FF)
+val md_theme_dark_secondary = FizzyTealLight
+val md_theme_dark_onSecondary = Color(0xFF00382E)
+val md_theme_dark_secondaryContainer = FizzyTealDark
+val md_theme_dark_onSecondaryContainer = Color(0xFFB4F1DF)
+val md_theme_dark_tertiary = Color(0xFFCFBCFF)
+val md_theme_dark_onTertiary = Color(0xFF381E72)
+val md_theme_dark_tertiaryContainer = Color(0xFF4F378B)
+val md_theme_dark_onTertiaryContainer = Color(0xFFEADDFF)
+val md_theme_dark_error = Color(0xFFFFB4AB)
+val md_theme_dark_errorContainer = Color(0xFF93000A)
+val md_theme_dark_onError = Color(0xFF690005)
+val md_theme_dark_onErrorContainer = Color(0xFFFFDAD6)
+val md_theme_dark_background = NeutralGray900
+val md_theme_dark_onBackground = NeutralGray100
+val md_theme_dark_surface = NeutralGray800
+val md_theme_dark_onSurface = NeutralGray100
+val md_theme_dark_surfaceVariant = NeutralGray700
+val md_theme_dark_onSurfaceVariant = NeutralGray300
+val md_theme_dark_outline = NeutralGray500
+val md_theme_dark_inverseOnSurface = NeutralGray900
+val md_theme_dark_inverseSurface = NeutralGray100
+val md_theme_dark_inversePrimary = FizzyBlue
+val md_theme_dark_surfaceTint = FizzyBlueLight
+val md_theme_dark_outlineVariant = NeutralGray600
+val md_theme_dark_scrim = NeutralBlack
diff --git a/app/src/main/java/com/fizzy/android/core/ui/theme/Theme.kt b/app/src/main/java/com/fizzy/android/core/ui/theme/Theme.kt
new file mode 100644
index 0000000..620d276
--- /dev/null
+++ b/app/src/main/java/com/fizzy/android/core/ui/theme/Theme.kt
@@ -0,0 +1,111 @@
+package com.fizzy.android.core.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 LightColorScheme = lightColorScheme(
+ primary = md_theme_light_primary,
+ onPrimary = md_theme_light_onPrimary,
+ primaryContainer = md_theme_light_primaryContainer,
+ onPrimaryContainer = md_theme_light_onPrimaryContainer,
+ secondary = md_theme_light_secondary,
+ onSecondary = md_theme_light_onSecondary,
+ secondaryContainer = md_theme_light_secondaryContainer,
+ onSecondaryContainer = md_theme_light_onSecondaryContainer,
+ tertiary = md_theme_light_tertiary,
+ onTertiary = md_theme_light_onTertiary,
+ tertiaryContainer = md_theme_light_tertiaryContainer,
+ onTertiaryContainer = md_theme_light_onTertiaryContainer,
+ error = md_theme_light_error,
+ errorContainer = md_theme_light_errorContainer,
+ onError = md_theme_light_onError,
+ onErrorContainer = md_theme_light_onErrorContainer,
+ background = md_theme_light_background,
+ onBackground = md_theme_light_onBackground,
+ surface = md_theme_light_surface,
+ onSurface = md_theme_light_onSurface,
+ surfaceVariant = md_theme_light_surfaceVariant,
+ onSurfaceVariant = md_theme_light_onSurfaceVariant,
+ outline = md_theme_light_outline,
+ inverseOnSurface = md_theme_light_inverseOnSurface,
+ inverseSurface = md_theme_light_inverseSurface,
+ inversePrimary = md_theme_light_inversePrimary,
+ surfaceTint = md_theme_light_surfaceTint,
+ outlineVariant = md_theme_light_outlineVariant,
+ scrim = md_theme_light_scrim
+)
+
+private val DarkColorScheme = darkColorScheme(
+ primary = md_theme_dark_primary,
+ onPrimary = md_theme_dark_onPrimary,
+ primaryContainer = md_theme_dark_primaryContainer,
+ onPrimaryContainer = md_theme_dark_onPrimaryContainer,
+ secondary = md_theme_dark_secondary,
+ onSecondary = md_theme_dark_onSecondary,
+ secondaryContainer = md_theme_dark_secondaryContainer,
+ onSecondaryContainer = md_theme_dark_onSecondaryContainer,
+ tertiary = md_theme_dark_tertiary,
+ onTertiary = md_theme_dark_onTertiary,
+ tertiaryContainer = md_theme_dark_tertiaryContainer,
+ onTertiaryContainer = md_theme_dark_onTertiaryContainer,
+ error = md_theme_dark_error,
+ errorContainer = md_theme_dark_errorContainer,
+ onError = md_theme_dark_onError,
+ onErrorContainer = md_theme_dark_onErrorContainer,
+ background = md_theme_dark_background,
+ onBackground = md_theme_dark_onBackground,
+ surface = md_theme_dark_surface,
+ onSurface = md_theme_dark_onSurface,
+ surfaceVariant = md_theme_dark_surfaceVariant,
+ onSurfaceVariant = md_theme_dark_onSurfaceVariant,
+ outline = md_theme_dark_outline,
+ inverseOnSurface = md_theme_dark_inverseOnSurface,
+ inverseSurface = md_theme_dark_inverseSurface,
+ inversePrimary = md_theme_dark_inversePrimary,
+ surfaceTint = md_theme_dark_surfaceTint,
+ outlineVariant = md_theme_dark_outlineVariant,
+ scrim = md_theme_dark_scrim
+)
+
+@Composable
+fun FizzyTheme(
+ darkTheme: Boolean = isSystemInDarkTheme(),
+ dynamicColor: Boolean = false,
+ 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.background.toArgb()
+ WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme
+ }
+ }
+
+ MaterialTheme(
+ colorScheme = colorScheme,
+ typography = Typography,
+ content = content
+ )
+}
diff --git a/app/src/main/java/com/fizzy/android/core/ui/theme/Type.kt b/app/src/main/java/com/fizzy/android/core/ui/theme/Type.kt
new file mode 100644
index 0000000..67816d5
--- /dev/null
+++ b/app/src/main/java/com/fizzy/android/core/ui/theme/Type.kt
@@ -0,0 +1,115 @@
+package com.fizzy.android.core.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(
+ displayLarge = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Normal,
+ fontSize = 57.sp,
+ lineHeight = 64.sp,
+ letterSpacing = (-0.25).sp
+ ),
+ displayMedium = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Normal,
+ fontSize = 45.sp,
+ lineHeight = 52.sp,
+ letterSpacing = 0.sp
+ ),
+ displaySmall = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Normal,
+ fontSize = 36.sp,
+ lineHeight = 44.sp,
+ letterSpacing = 0.sp
+ ),
+ headlineLarge = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.SemiBold,
+ fontSize = 32.sp,
+ lineHeight = 40.sp,
+ letterSpacing = 0.sp
+ ),
+ headlineMedium = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.SemiBold,
+ fontSize = 28.sp,
+ lineHeight = 36.sp,
+ letterSpacing = 0.sp
+ ),
+ headlineSmall = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.SemiBold,
+ fontSize = 24.sp,
+ lineHeight = 32.sp,
+ letterSpacing = 0.sp
+ ),
+ titleLarge = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.SemiBold,
+ fontSize = 22.sp,
+ lineHeight = 28.sp,
+ letterSpacing = 0.sp
+ ),
+ titleMedium = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Medium,
+ fontSize = 16.sp,
+ lineHeight = 24.sp,
+ letterSpacing = 0.15.sp
+ ),
+ titleSmall = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Medium,
+ fontSize = 14.sp,
+ lineHeight = 20.sp,
+ letterSpacing = 0.1.sp
+ ),
+ bodyLarge = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Normal,
+ fontSize = 16.sp,
+ lineHeight = 24.sp,
+ letterSpacing = 0.5.sp
+ ),
+ bodyMedium = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Normal,
+ fontSize = 14.sp,
+ lineHeight = 20.sp,
+ letterSpacing = 0.25.sp
+ ),
+ bodySmall = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Normal,
+ fontSize = 12.sp,
+ lineHeight = 16.sp,
+ letterSpacing = 0.4.sp
+ ),
+ labelLarge = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Medium,
+ fontSize = 14.sp,
+ lineHeight = 20.sp,
+ letterSpacing = 0.1.sp
+ ),
+ labelMedium = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Medium,
+ fontSize = 12.sp,
+ lineHeight = 16.sp,
+ letterSpacing = 0.5.sp
+ ),
+ labelSmall = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Medium,
+ fontSize = 11.sp,
+ lineHeight = 16.sp,
+ letterSpacing = 0.5.sp
+ )
+)
diff --git a/app/src/main/java/com/fizzy/android/data/api/FizzyApiService.kt b/app/src/main/java/com/fizzy/android/data/api/FizzyApiService.kt
new file mode 100644
index 0000000..c641052
--- /dev/null
+++ b/app/src/main/java/com/fizzy/android/data/api/FizzyApiService.kt
@@ -0,0 +1,243 @@
+package com.fizzy.android.data.api
+
+import com.fizzy.android.data.api.dto.*
+import retrofit2.Response
+import retrofit2.http.*
+
+interface FizzyApiService {
+
+ // ==================== Auth ====================
+
+ @POST("session")
+ suspend fun requestMagicLink(@Body request: RequestMagicLinkRequest): Response
+
+ @POST("session/magic_link")
+ suspend fun verifyMagicLink(@Body request: VerifyMagicLinkRequest): Response
+
+ @GET("my/identity.json")
+ suspend fun getCurrentIdentity(): Response
+
+ // Legacy alias
+ @GET("my/identity.json")
+ suspend fun getCurrentUser(): Response
+
+ // ==================== Users ====================
+
+ @GET("users")
+ suspend fun getUsers(): Response>
+
+ @GET("users/{userId}")
+ suspend fun getUser(@Path("userId") userId: String): Response
+
+ // ==================== Boards ====================
+
+ @GET("boards.json")
+ suspend fun getBoards(): Response
+
+ @GET("boards/{boardId}.json")
+ suspend fun getBoard(@Path("boardId") boardId: String): Response
+
+ @POST("boards.json")
+ suspend fun createBoard(@Body request: CreateBoardRequest): Response
+
+ @PUT("boards/{boardId}")
+ suspend fun updateBoard(
+ @Path("boardId") boardId: String,
+ @Body request: UpdateBoardRequest
+ ): Response
+
+ @DELETE("boards/{boardId}.json")
+ suspend fun deleteBoard(@Path("boardId") boardId: String): Response
+
+ // ==================== Columns ====================
+
+ @GET("boards/{boardId}/columns.json")
+ suspend fun getColumns(@Path("boardId") boardId: String): Response
+
+ @POST("boards/{boardId}/columns.json")
+ suspend fun createColumn(
+ @Path("boardId") boardId: String,
+ @Body request: CreateColumnRequest
+ ): Response
+
+ @PUT("boards/{boardId}/columns/{columnId}")
+ suspend fun updateColumn(
+ @Path("boardId") boardId: String,
+ @Path("columnId") columnId: String,
+ @Body request: UpdateColumnRequest
+ ): Response
+
+ @DELETE("boards/{boardId}/columns/{columnId}.json")
+ suspend fun deleteColumn(
+ @Path("boardId") boardId: String,
+ @Path("columnId") columnId: String
+ ): Response
+
+ // ==================== Cards ====================
+
+ @GET("cards.json")
+ suspend fun getCards(@Query("board_ids[]") boardId: String? = null): Response
+
+ @GET("cards/{cardNumber}.json")
+ suspend fun getCard(@Path("cardNumber") cardNumber: Int): Response
+
+ @POST("boards/{boardId}/cards.json")
+ suspend fun createCard(
+ @Path("boardId") boardId: String,
+ @Body request: CreateCardRequest
+ ): Response
+
+ @PUT("cards/{cardNumber}")
+ suspend fun updateCard(
+ @Path("cardNumber") cardNumber: Int,
+ @Body request: UpdateCardRequest
+ ): Response
+
+ @DELETE("cards/{cardNumber}.json")
+ suspend fun deleteCard(@Path("cardNumber") cardNumber: Int): Response
+
+ // ==================== Card Actions ====================
+
+ // Close/Reopen
+ @POST("cards/{cardNumber}/closure")
+ suspend fun closeCard(@Path("cardNumber") cardNumber: Int): Response
+
+ @DELETE("cards/{cardNumber}/closure")
+ suspend fun reopenCard(@Path("cardNumber") cardNumber: Int): Response
+
+ // Not Now (defer/put aside)
+ @POST("cards/{cardNumber}/not_now")
+ suspend fun markCardNotNow(@Path("cardNumber") cardNumber: Int): Response
+
+ // Triage (move to column)
+ @POST("cards/{cardNumber}/triage")
+ suspend fun triageCard(
+ @Path("cardNumber") cardNumber: Int,
+ @Body request: TriageCardRequest
+ ): Response
+
+ @DELETE("cards/{cardNumber}/triage")
+ suspend fun untriageCard(@Path("cardNumber") cardNumber: Int): Response
+
+ // Priority (golden)
+ @POST("cards/{cardNumber}/goldness")
+ suspend fun markCardGolden(@Path("cardNumber") cardNumber: Int): Response
+
+ @DELETE("cards/{cardNumber}/goldness")
+ suspend fun unmarkCardGolden(@Path("cardNumber") cardNumber: Int): Response
+
+ // Watch
+ @POST("cards/{cardNumber}/watch")
+ suspend fun watchCard(@Path("cardNumber") cardNumber: Int): Response
+
+ @DELETE("cards/{cardNumber}/watch")
+ suspend fun unwatchCard(@Path("cardNumber") cardNumber: Int): Response
+
+ // ==================== Assignments ====================
+
+ @POST("cards/{cardNumber}/assignments")
+ suspend fun addAssignment(
+ @Path("cardNumber") cardNumber: Int,
+ @Body request: AssignmentRequest
+ ): Response
+
+ // Note: Removing assignment may require PUT cards/{cardNumber} with updated assignee list
+ // or a separate endpoint - documentation unclear
+
+ // ==================== Tags (Account Level) ====================
+
+ @GET("tags")
+ suspend fun getTags(): Response>
+
+ // ==================== Taggings (Card Tags) ====================
+
+ @POST("cards/{cardNumber}/taggings")
+ suspend fun addTagging(
+ @Path("cardNumber") cardNumber: Int,
+ @Body request: TaggingRequest
+ ): Response
+
+ @DELETE("cards/{cardNumber}/taggings/{taggingId}")
+ suspend fun removeTagging(
+ @Path("cardNumber") cardNumber: Int,
+ @Path("taggingId") taggingId: String
+ ): Response
+
+ // ==================== Steps ====================
+
+ @GET("cards/{cardNumber}/steps.json")
+ suspend fun getSteps(@Path("cardNumber") cardNumber: Int): Response
+
+ @POST("cards/{cardNumber}/steps.json")
+ suspend fun createStep(
+ @Path("cardNumber") cardNumber: Int,
+ @Body request: CreateStepRequest
+ ): Response
+
+ @PUT("cards/{cardNumber}/steps/{stepId}")
+ suspend fun updateStep(
+ @Path("cardNumber") cardNumber: Int,
+ @Path("stepId") stepId: String,
+ @Body request: UpdateStepRequest
+ ): Response
+
+ @DELETE("cards/{cardNumber}/steps/{stepId}.json")
+ suspend fun deleteStep(
+ @Path("cardNumber") cardNumber: Int,
+ @Path("stepId") stepId: String
+ ): Response
+
+ // ==================== Comments ====================
+
+ @GET("cards/{cardNumber}/comments.json")
+ suspend fun getComments(@Path("cardNumber") cardNumber: Int): Response
+
+ @POST("cards/{cardNumber}/comments.json")
+ suspend fun createComment(
+ @Path("cardNumber") cardNumber: Int,
+ @Body request: CreateCommentRequest
+ ): Response
+
+ @PUT("cards/{cardNumber}/comments/{commentId}")
+ suspend fun updateComment(
+ @Path("cardNumber") cardNumber: Int,
+ @Path("commentId") commentId: String,
+ @Body request: UpdateCommentRequest
+ ): Response
+
+ @DELETE("cards/{cardNumber}/comments/{commentId}.json")
+ suspend fun deleteComment(
+ @Path("cardNumber") cardNumber: Int,
+ @Path("commentId") commentId: String
+ ): Response
+
+ // ==================== Reactions ====================
+
+ @POST("cards/{cardNumber}/comments/{commentId}/reactions")
+ suspend fun addReaction(
+ @Path("cardNumber") cardNumber: Int,
+ @Path("commentId") commentId: String,
+ @Body request: CreateReactionRequest
+ ): Response
+
+ @DELETE("cards/{cardNumber}/comments/{commentId}/reactions/{reactionId}")
+ suspend fun removeReaction(
+ @Path("cardNumber") cardNumber: Int,
+ @Path("commentId") commentId: String,
+ @Path("reactionId") reactionId: String
+ ): Response
+
+ // ==================== Notifications ====================
+
+ @GET("notifications.json")
+ suspend fun getNotifications(): Response
+
+ @POST("notifications/{notificationId}/reading")
+ suspend fun markNotificationRead(@Path("notificationId") notificationId: String): Response
+
+ @DELETE("notifications/{notificationId}/reading")
+ suspend fun markNotificationUnread(@Path("notificationId") notificationId: String): Response
+
+ @POST("notifications/bulk_reading")
+ suspend fun markAllNotificationsRead(): Response
+}
diff --git a/app/src/main/java/com/fizzy/android/data/api/dto/AuthDto.kt b/app/src/main/java/com/fizzy/android/data/api/dto/AuthDto.kt
new file mode 100644
index 0000000..8270ffa
--- /dev/null
+++ b/app/src/main/java/com/fizzy/android/data/api/dto/AuthDto.kt
@@ -0,0 +1,34 @@
+package com.fizzy.android.data.api.dto
+
+import com.squareup.moshi.Json
+import com.squareup.moshi.JsonClass
+
+// Request magic link - Fizzy API uses email_address, not email
+@JsonClass(generateAdapter = true)
+data class RequestMagicLinkRequest(
+ @Json(name = "email_address") val emailAddress: String
+)
+
+// Response contains pending_authentication_token for magic link flow
+@JsonClass(generateAdapter = true)
+data class RequestMagicLinkResponse(
+ @Json(name = "pending_authentication_token") val pendingAuthenticationToken: String
+)
+
+// Verify magic link - send the code received via email
+@JsonClass(generateAdapter = true)
+data class VerifyMagicLinkRequest(
+ @Json(name = "code") val code: String
+)
+
+// Verify response returns session_token
+@JsonClass(generateAdapter = true)
+data class VerifyMagicLinkResponse(
+ @Json(name = "session_token") val sessionToken: String
+)
+
+// For Personal Access Token authentication (alternative to magic link)
+@JsonClass(generateAdapter = true)
+data class PersonalAccessTokenRequest(
+ @Json(name = "token") val token: String
+)
diff --git a/app/src/main/java/com/fizzy/android/data/api/dto/BoardDto.kt b/app/src/main/java/com/fizzy/android/data/api/dto/BoardDto.kt
new file mode 100644
index 0000000..6038753
--- /dev/null
+++ b/app/src/main/java/com/fizzy/android/data/api/dto/BoardDto.kt
@@ -0,0 +1,102 @@
+package com.fizzy.android.data.api.dto
+
+import com.fizzy.android.domain.model.Board
+import com.fizzy.android.domain.model.User
+import com.squareup.moshi.Json
+import com.squareup.moshi.JsonClass
+import java.time.Instant
+
+@JsonClass(generateAdapter = true)
+data class BoardDto(
+ @Json(name = "id") val id: String,
+ @Json(name = "name") val name: String,
+ @Json(name = "description") val description: String? = null,
+ @Json(name = "created_at") val createdAt: String,
+ @Json(name = "updated_at") val updatedAt: String? = null,
+ @Json(name = "cards_count") val cardsCount: Int = 0,
+ @Json(name = "columns_count") val columnsCount: Int = 0,
+ @Json(name = "creator") val creator: CreatorDto? = null,
+ @Json(name = "all_access") val allAccess: Boolean = false,
+ @Json(name = "url") val url: String? = null
+)
+
+// Creator in board response has different structure than User
+@JsonClass(generateAdapter = true)
+data class CreatorDto(
+ @Json(name = "id") val id: String,
+ @Json(name = "name") val name: String,
+ @Json(name = "role") val role: String? = null,
+ @Json(name = "active") val active: Boolean? = null,
+ @Json(name = "email_address") val emailAddress: String? = null,
+ @Json(name = "avatar_url") val avatarUrl: String? = null,
+ @Json(name = "url") val url: String? = null
+)
+
+// API returns direct array/object, not wrapped
+typealias BoardsResponse = List
+typealias BoardResponse = BoardDto
+
+// Wrapped request for creating boards (Fizzy API requires nested object)
+@JsonClass(generateAdapter = true)
+data class CreateBoardRequest(
+ @Json(name = "board") val board: BoardData
+)
+
+@JsonClass(generateAdapter = true)
+data class BoardData(
+ @Json(name = "name") val name: String,
+ @Json(name = "all_access") val allAccess: Boolean? = null
+)
+
+// Wrapped request for updating boards
+@JsonClass(generateAdapter = true)
+data class UpdateBoardRequest(
+ @Json(name = "board") val board: UpdateBoardData
+)
+
+@JsonClass(generateAdapter = true)
+data class UpdateBoardData(
+ @Json(name = "name") val name: String? = null,
+ @Json(name = "all_access") val allAccess: Boolean? = null
+)
+
+fun BoardDto.toDomain(): Board = Board(
+ id = id,
+ name = name,
+ description = description,
+ createdAt = Instant.parse(createdAt),
+ updatedAt = updatedAt?.let { Instant.parse(it) },
+ cardsCount = cardsCount,
+ columnsCount = columnsCount,
+ creator = creator?.toUser(),
+ allAccess = allAccess,
+ url = url
+)
+
+fun CreatorDto.toUser(): User = User(
+ id = 0L, // Fizzy uses string IDs
+ name = name,
+ email = emailAddress ?: "",
+ avatarUrl = avatarUrl,
+ admin = role == "owner" || role == "admin"
+)
+
+// Helper function to create CreateBoardRequest with nested structure
+fun createBoardRequest(name: String, allAccess: Boolean? = null): CreateBoardRequest {
+ return CreateBoardRequest(
+ board = BoardData(
+ name = name,
+ allAccess = allAccess
+ )
+ )
+}
+
+// Helper function to create UpdateBoardRequest with nested structure
+fun updateBoardRequest(name: String? = null, allAccess: Boolean? = null): UpdateBoardRequest {
+ return UpdateBoardRequest(
+ board = UpdateBoardData(
+ name = name,
+ allAccess = allAccess
+ )
+ )
+}
diff --git a/app/src/main/java/com/fizzy/android/data/api/dto/CardDto.kt b/app/src/main/java/com/fizzy/android/data/api/dto/CardDto.kt
new file mode 100644
index 0000000..d8abdaa
--- /dev/null
+++ b/app/src/main/java/com/fizzy/android/data/api/dto/CardDto.kt
@@ -0,0 +1,153 @@
+package com.fizzy.android.data.api.dto
+
+import android.util.Log
+import com.fizzy.android.domain.model.Card
+import com.fizzy.android.domain.model.CardStatus
+import com.squareup.moshi.Json
+import com.squareup.moshi.JsonClass
+import java.time.Instant
+
+private const val TAG = "CardDto"
+
+@JsonClass(generateAdapter = true)
+data class CardDto(
+ @Json(name = "id") val id: String,
+ @Json(name = "number") val number: Int = 0,
+ @Json(name = "title") val title: String,
+ @Json(name = "description") val description: String? = null,
+ @Json(name = "position") val position: Int = 0,
+ @Json(name = "column") val column: CardColumnDto? = null,
+ @Json(name = "board") val board: CardBoardDto? = null,
+ @Json(name = "status") val status: String = "active",
+ @Json(name = "golden") val golden: Boolean = false,
+ @Json(name = "closed") val closed: Boolean = false,
+ @Json(name = "created_at") val createdAt: String = "",
+ @Json(name = "last_active_at") val lastActiveAt: String? = null,
+ @Json(name = "creator") val creator: UserDto? = null,
+ @Json(name = "assignees") val assignees: List? = null,
+ @Json(name = "tags") val tags: List? = null,
+ @Json(name = "steps") val steps: List? = null,
+ @Json(name = "url") val url: String? = null,
+ @Json(name = "comments_url") val commentsUrl: String? = null
+)
+
+@JsonClass(generateAdapter = true)
+data class CardColumnDto(
+ @Json(name = "id") val id: String,
+ @Json(name = "name") val name: String? = null,
+ @Json(name = "color") val color: ColumnColorDto? = null,
+ @Json(name = "created_at") val createdAt: String? = null
+)
+
+@JsonClass(generateAdapter = true)
+data class CardBoardDto(
+ @Json(name = "id") val id: String,
+ @Json(name = "name") val name: String? = null,
+ @Json(name = "all_access") val allAccess: Boolean = false,
+ @Json(name = "created_at") val createdAt: String? = null,
+ @Json(name = "url") val url: String? = null,
+ @Json(name = "creator") val creator: UserDto? = null
+)
+
+// API returns direct array, not wrapped
+typealias CardsResponse = List
+typealias CardResponse = CardDto
+
+// Wrapped request for creating cards (Fizzy API requires nested object)
+@JsonClass(generateAdapter = true)
+data class CreateCardRequest(
+ @Json(name = "card") val card: CardData
+)
+
+@JsonClass(generateAdapter = true)
+data class CardData(
+ @Json(name = "title") val title: String,
+ @Json(name = "description") val description: String? = null,
+ @Json(name = "status") val status: String? = null,
+ @Json(name = "column_id") val columnId: String? = null,
+ @Json(name = "tag_ids") val tagIds: List? = null
+)
+
+// Wrapped request for updating cards
+@JsonClass(generateAdapter = true)
+data class UpdateCardRequest(
+ @Json(name = "card") val card: UpdateCardData
+)
+
+@JsonClass(generateAdapter = true)
+data class UpdateCardData(
+ @Json(name = "title") val title: String? = null,
+ @Json(name = "description") val description: String? = null,
+ @Json(name = "column_id") val columnId: String? = null,
+ @Json(name = "position") val position: Int? = null
+)
+
+// Triage card to a specific column
+@JsonClass(generateAdapter = true)
+data class TriageCardRequest(
+ @Json(name = "column_id") val columnId: String
+)
+
+// Add assignment to a card
+@JsonClass(generateAdapter = true)
+data class AssignmentRequest(
+ @Json(name = "assignee_id") val assigneeId: String
+)
+
+fun CardDto.toDomain(): Card {
+ Log.d(TAG, "toDomain: title='$title', column=$column, columnId=${column?.id}")
+ return Card(
+ id = number.toLong(), // Use number for card identification (used in URLs)
+ title = title,
+ description = description,
+ position = position,
+ columnId = column?.id ?: "",
+ boardId = board?.id ?: "",
+ status = when {
+ closed -> CardStatus.CLOSED
+ status.lowercase() == "triaged" -> CardStatus.TRIAGED
+ status.lowercase() == "deferred" -> CardStatus.DEFERRED
+ else -> CardStatus.ACTIVE
+ },
+ priority = golden,
+ watching = false, // Not in API response
+ triageAt = null, // Not directly in API response
+ deferUntil = null, // Not directly in API response
+ createdAt = if (createdAt.isNotEmpty()) Instant.parse(createdAt) else Instant.now(),
+ updatedAt = lastActiveAt?.let { Instant.parse(it) } ?: Instant.now(),
+ creator = creator?.toDomain(),
+ assignees = assignees?.map { it.toDomain() } ?: emptyList(),
+ tags = tags?.map { it.toDomain() } ?: emptyList(),
+ stepsTotal = steps?.size ?: 0,
+ stepsCompleted = steps?.count { it.completed } ?: 0,
+ commentsCount = 0 // Not in list response
+ )
+}
+
+// Helper function to create CreateCardRequest with nested structure
+fun createCardRequest(title: String, description: String?, columnId: String): CreateCardRequest {
+ return CreateCardRequest(
+ card = CardData(
+ title = title,
+ description = description,
+ columnId = columnId
+ )
+ )
+}
+
+// Helper function to create UpdateCardRequest with nested structure
+fun updateCardRequest(
+ title: String? = null,
+ description: String? = null,
+ columnId: String? = null,
+ position: Int? = null
+): UpdateCardRequest {
+ return UpdateCardRequest(
+ card = UpdateCardData(
+ title = title,
+ description = description,
+ columnId = columnId,
+ position = position
+ )
+ )
+}
diff --git a/app/src/main/java/com/fizzy/android/data/api/dto/ColumnDto.kt b/app/src/main/java/com/fizzy/android/data/api/dto/ColumnDto.kt
new file mode 100644
index 0000000..db8c1bb
--- /dev/null
+++ b/app/src/main/java/com/fizzy/android/data/api/dto/ColumnDto.kt
@@ -0,0 +1,84 @@
+package com.fizzy.android.data.api.dto
+
+import com.fizzy.android.domain.model.Column
+import com.squareup.moshi.Json
+import com.squareup.moshi.JsonClass
+
+@JsonClass(generateAdapter = true)
+data class ColumnDto(
+ @Json(name = "id") val id: String,
+ @Json(name = "name") val name: String,
+ @Json(name = "position") val position: Int = 0,
+ @Json(name = "board_id") val boardId: String = "",
+ @Json(name = "cards") val cards: List? = null,
+ @Json(name = "cards_count") val cardsCount: Int = 0,
+ @Json(name = "color") val color: ColumnColorDto? = null,
+ @Json(name = "created_at") val createdAt: String? = null
+)
+
+@JsonClass(generateAdapter = true)
+data class ColumnColorDto(
+ @Json(name = "name") val name: String,
+ @Json(name = "value") val value: String
+)
+
+// API returns direct arrays, not wrapped
+typealias ColumnsResponse = List
+typealias ColumnResponse = ColumnDto
+
+// Wrapped request for creating columns (Fizzy API requires nested object)
+@JsonClass(generateAdapter = true)
+data class CreateColumnRequest(
+ @Json(name = "column") val column: ColumnData
+)
+
+@JsonClass(generateAdapter = true)
+data class ColumnData(
+ @Json(name = "name") val name: String,
+ @Json(name = "color") val color: String? = null,
+ @Json(name = "position") val position: Int? = null
+)
+
+// Wrapped request for updating columns
+@JsonClass(generateAdapter = true)
+data class UpdateColumnRequest(
+ @Json(name = "column") val column: UpdateColumnData
+)
+
+@JsonClass(generateAdapter = true)
+data class UpdateColumnData(
+ @Json(name = "name") val name: String? = null,
+ @Json(name = "color") val color: String? = null,
+ @Json(name = "position") val position: Int? = null
+)
+
+fun ColumnDto.toDomain(): Column = Column(
+ id = id,
+ name = name,
+ position = position,
+ boardId = boardId,
+ cards = cards?.map { it.toDomain() } ?: emptyList(),
+ cardsCount = cardsCount
+)
+
+// Helper function to create CreateColumnRequest with nested structure
+fun createColumnRequest(name: String, color: String? = null, position: Int? = null): CreateColumnRequest {
+ return CreateColumnRequest(
+ column = ColumnData(
+ name = name,
+ color = color,
+ position = position
+ )
+ )
+}
+
+// Helper function to create UpdateColumnRequest with nested structure
+fun updateColumnRequest(name: String? = null, color: String? = null, position: Int? = null): UpdateColumnRequest {
+ return UpdateColumnRequest(
+ column = UpdateColumnData(
+ name = name,
+ color = color,
+ position = position
+ )
+ )
+}
diff --git a/app/src/main/java/com/fizzy/android/data/api/dto/CommentDto.kt b/app/src/main/java/com/fizzy/android/data/api/dto/CommentDto.kt
new file mode 100644
index 0000000..68e58bf
--- /dev/null
+++ b/app/src/main/java/com/fizzy/android/data/api/dto/CommentDto.kt
@@ -0,0 +1,100 @@
+package com.fizzy.android.data.api.dto
+
+import com.fizzy.android.domain.model.Comment
+import com.fizzy.android.domain.model.Reaction
+import com.squareup.moshi.Json
+import com.squareup.moshi.JsonClass
+import java.time.Instant
+
+@JsonClass(generateAdapter = true)
+data class CommentDto(
+ @Json(name = "id") val id: String,
+ @Json(name = "content") val content: String,
+ @Json(name = "card_id") val cardId: String? = null,
+ @Json(name = "author") val author: UserDto? = null,
+ @Json(name = "creator") val creator: UserDto? = null,
+ @Json(name = "created_at") val createdAt: String,
+ @Json(name = "updated_at") val updatedAt: String? = null,
+ @Json(name = "reactions") val reactions: List? = null
+)
+
+@JsonClass(generateAdapter = true)
+data class ReactionDto(
+ @Json(name = "id") val id: String? = null,
+ @Json(name = "content") val content: String? = null,
+ @Json(name = "emoji") val emoji: String? = null,
+ @Json(name = "count") val count: Int = 1,
+ @Json(name = "users") val users: List? = null,
+ @Json(name = "reacted_by_me") val reactedByMe: Boolean = false,
+ @Json(name = "creator") val creator: UserDto? = null
+)
+
+// API returns direct arrays, not wrapped
+typealias CommentsResponse = List
+typealias CommentResponse = CommentDto
+
+// Wrapped request for creating comments (Fizzy API requires nested object)
+@JsonClass(generateAdapter = true)
+data class CreateCommentRequest(
+ @Json(name = "comment") val comment: CommentData
+)
+
+@JsonClass(generateAdapter = true)
+data class CommentData(
+ @Json(name = "body") val body: String
+)
+
+// Wrapped request for updating comments
+@JsonClass(generateAdapter = true)
+data class UpdateCommentRequest(
+ @Json(name = "comment") val comment: CommentData
+)
+
+// Wrapped request for creating reactions
+@JsonClass(generateAdapter = true)
+data class CreateReactionRequest(
+ @Json(name = "reaction") val reaction: ReactionData
+)
+
+@JsonClass(generateAdapter = true)
+data class ReactionData(
+ @Json(name = "content") val content: String
+)
+
+fun ReactionDto.toDomain(): Reaction = Reaction(
+ emoji = content ?: emoji ?: "",
+ count = count,
+ users = users?.map { it.toDomain() } ?: emptyList(),
+ reactedByMe = reactedByMe
+)
+
+fun CommentDto.toDomain(): Comment = Comment(
+ id = id.toLongOrNull() ?: 0L,
+ content = content,
+ cardId = cardId?.toLongOrNull() ?: 0L,
+ author = (author ?: creator)?.toDomain() ?: throw IllegalStateException("Comment must have author or creator"),
+ createdAt = Instant.parse(createdAt),
+ updatedAt = updatedAt?.let { Instant.parse(it) } ?: Instant.parse(createdAt),
+ reactions = reactions?.map { it.toDomain() } ?: emptyList()
+)
+
+// Helper function to create CreateCommentRequest with nested structure
+fun createCommentRequest(content: String): CreateCommentRequest {
+ return CreateCommentRequest(
+ comment = CommentData(body = content)
+ )
+}
+
+// Helper function to create UpdateCommentRequest with nested structure
+fun updateCommentRequest(content: String): UpdateCommentRequest {
+ return UpdateCommentRequest(
+ comment = CommentData(body = content)
+ )
+}
+
+// Helper function to create CreateReactionRequest with nested structure
+fun createReactionRequest(emoji: String): CreateReactionRequest {
+ return CreateReactionRequest(
+ reaction = ReactionData(content = emoji)
+ )
+}
diff --git a/app/src/main/java/com/fizzy/android/data/api/dto/IdentityDto.kt b/app/src/main/java/com/fizzy/android/data/api/dto/IdentityDto.kt
new file mode 100644
index 0000000..a4ee904
--- /dev/null
+++ b/app/src/main/java/com/fizzy/android/data/api/dto/IdentityDto.kt
@@ -0,0 +1,69 @@
+package com.fizzy.android.data.api.dto
+
+import com.fizzy.android.domain.model.User
+import com.squareup.moshi.Json
+import com.squareup.moshi.JsonClass
+
+/**
+ * Response from GET /my/identity.json
+ * Example:
+ * {
+ * "accounts": [{
+ * "id": "03ff9o0317vymjugkux4h9wkj",
+ * "name": "Paweł's Fizzy",
+ * "slug": "/0000001",
+ * "created_at": "2026-01-18T02:57:58.339Z",
+ * "user": {
+ * "id": "03ff9o042qaxblmwrxdgcs0fk",
+ * "name": "Paweł",
+ * "role": "owner",
+ * "active": true,
+ * "email_address": "pawel@orzech.me",
+ * "created_at": "2026-01-18T02:57:58.597Z",
+ * "url": "https://kanban.orzech.me/users/03ff9o042qaxblmwrxdgcs0fk"
+ * }
+ * }]
+ * }
+ */
+@JsonClass(generateAdapter = true)
+data class IdentityDto(
+ @Json(name = "accounts") val accounts: List? = null
+)
+
+@JsonClass(generateAdapter = true)
+data class IdentityAccountDto(
+ @Json(name = "id") val id: String,
+ @Json(name = "name") val name: String,
+ @Json(name = "slug") val slug: String? = null,
+ @Json(name = "created_at") val createdAt: String? = null,
+ @Json(name = "user") val user: IdentityUserDto? = null
+)
+
+@JsonClass(generateAdapter = true)
+data class IdentityUserDto(
+ @Json(name = "id") val id: String,
+ @Json(name = "name") val name: String,
+ @Json(name = "role") val role: String? = null,
+ @Json(name = "active") val active: Boolean? = null,
+ @Json(name = "email_address") val emailAddress: String? = null,
+ @Json(name = "avatar_url") val avatarUrl: String? = null,
+ @Json(name = "url") val url: String? = null
+)
+
+fun IdentityDto.toUser(): User {
+ val firstAccount = accounts?.firstOrNull()
+ val user = firstAccount?.user
+ return User(
+ id = 0L, // Fizzy uses string IDs, we'll use 0 as placeholder
+ name = user?.name ?: "Unknown",
+ email = user?.emailAddress ?: "",
+ avatarUrl = user?.avatarUrl,
+ admin = user?.role == "owner" || user?.role == "admin"
+ )
+}
+
+fun IdentityDto.getUserId(): String? = accounts?.firstOrNull()?.user?.id
+
+fun IdentityDto.getAccountId(): String? = accounts?.firstOrNull()?.id
+
+fun IdentityDto.getAccountSlug(): String? = accounts?.firstOrNull()?.slug
diff --git a/app/src/main/java/com/fizzy/android/data/api/dto/NotificationDto.kt b/app/src/main/java/com/fizzy/android/data/api/dto/NotificationDto.kt
new file mode 100644
index 0000000..7e972dd
--- /dev/null
+++ b/app/src/main/java/com/fizzy/android/data/api/dto/NotificationDto.kt
@@ -0,0 +1,58 @@
+package com.fizzy.android.data.api.dto
+
+import com.fizzy.android.domain.model.Notification
+import com.fizzy.android.domain.model.NotificationType
+import com.squareup.moshi.Json
+import com.squareup.moshi.JsonClass
+import java.time.Instant
+
+@JsonClass(generateAdapter = true)
+data class NotificationDto(
+ @Json(name = "id") val id: String,
+ @Json(name = "read") val read: Boolean,
+ @Json(name = "read_at") val readAt: String? = null,
+ @Json(name = "created_at") val createdAt: String,
+ @Json(name = "title") val title: String,
+ @Json(name = "body") val body: String,
+ @Json(name = "type") val type: String? = null,
+ @Json(name = "creator") val creator: UserDto? = null,
+ @Json(name = "card") val card: NotificationCardDto? = null,
+ @Json(name = "url") val url: String? = null
+)
+
+@JsonClass(generateAdapter = true)
+data class NotificationCardDto(
+ @Json(name = "id") val id: String,
+ @Json(name = "title") val title: String,
+ @Json(name = "status") val status: String? = null,
+ @Json(name = "url") val url: String? = null
+)
+
+// API returns notifications list with unread count
+@JsonClass(generateAdapter = true)
+data class NotificationsResponse(
+ @Json(name = "notifications") val notifications: List,
+ @Json(name = "unread_count") val unreadCount: Int = 0
+)
+
+fun NotificationDto.toDomain(): Notification = Notification(
+ id = id.toLongOrNull() ?: 0L,
+ type = when (type?.lowercase()) {
+ "card_assigned" -> NotificationType.CARD_ASSIGNED
+ "card_mentioned" -> NotificationType.CARD_MENTIONED
+ "card_commented" -> NotificationType.CARD_COMMENTED
+ "card_moved" -> NotificationType.CARD_MOVED
+ "card_updated" -> NotificationType.CARD_UPDATED
+ "step_completed" -> NotificationType.STEP_COMPLETED
+ "reaction_added" -> NotificationType.REACTION_ADDED
+ "board_shared" -> NotificationType.BOARD_SHARED
+ else -> NotificationType.OTHER
+ },
+ title = title,
+ body = body,
+ read = read,
+ createdAt = Instant.parse(createdAt),
+ cardId = card?.id?.toLongOrNull(),
+ boardId = null, // Not directly in new API structure
+ actor = creator?.toDomain()
+)
diff --git a/app/src/main/java/com/fizzy/android/data/api/dto/StepDto.kt b/app/src/main/java/com/fizzy/android/data/api/dto/StepDto.kt
new file mode 100644
index 0000000..1bfe8a3
--- /dev/null
+++ b/app/src/main/java/com/fizzy/android/data/api/dto/StepDto.kt
@@ -0,0 +1,78 @@
+package com.fizzy.android.data.api.dto
+
+import com.fizzy.android.domain.model.Step
+import com.squareup.moshi.Json
+import com.squareup.moshi.JsonClass
+import java.time.Instant
+
+@JsonClass(generateAdapter = true)
+data class StepDto(
+ @Json(name = "id") val id: String,
+ @Json(name = "content") val content: String,
+ @Json(name = "completed") val completed: Boolean,
+ @Json(name = "position") val position: Int = 0,
+ @Json(name = "card_id") val cardId: String? = null,
+ @Json(name = "completed_by") val completedBy: UserDto? = null,
+ @Json(name = "completed_at") val completedAt: String? = null
+)
+
+// API returns direct arrays, not wrapped
+typealias StepsResponse = List
+typealias StepResponse = StepDto
+
+// Wrapped request for creating steps (Fizzy API requires nested object)
+@JsonClass(generateAdapter = true)
+data class CreateStepRequest(
+ @Json(name = "step") val step: StepData
+)
+
+@JsonClass(generateAdapter = true)
+data class StepData(
+ @Json(name = "content") val content: String,
+ @Json(name = "completed") val completed: Boolean? = null
+)
+
+// Wrapped request for updating steps
+@JsonClass(generateAdapter = true)
+data class UpdateStepRequest(
+ @Json(name = "step") val step: UpdateStepData
+)
+
+@JsonClass(generateAdapter = true)
+data class UpdateStepData(
+ @Json(name = "content") val content: String? = null,
+ @Json(name = "completed") val completed: Boolean? = null,
+ @Json(name = "position") val position: Int? = null
+)
+
+fun StepDto.toDomain(): Step = Step(
+ id = id.toLongOrNull() ?: 0L,
+ description = content, // Map content to description in domain model
+ completed = completed,
+ position = position,
+ cardId = cardId?.toLongOrNull() ?: 0L,
+ completedBy = completedBy?.toDomain(),
+ completedAt = completedAt?.let { Instant.parse(it) }
+)
+
+// Helper function to create CreateStepRequest with nested structure
+fun createStepRequest(content: String): CreateStepRequest {
+ return CreateStepRequest(
+ step = StepData(content = content)
+ )
+}
+
+// Helper function to create UpdateStepRequest with nested structure
+fun updateStepRequest(
+ content: String? = null,
+ completed: Boolean? = null,
+ position: Int? = null
+): UpdateStepRequest {
+ return UpdateStepRequest(
+ step = UpdateStepData(
+ content = content,
+ completed = completed,
+ position = position
+ )
+ )
+}
diff --git a/app/src/main/java/com/fizzy/android/data/api/dto/TagDto.kt b/app/src/main/java/com/fizzy/android/data/api/dto/TagDto.kt
new file mode 100644
index 0000000..e530d8a
--- /dev/null
+++ b/app/src/main/java/com/fizzy/android/data/api/dto/TagDto.kt
@@ -0,0 +1,35 @@
+package com.fizzy.android.data.api.dto
+
+import com.fizzy.android.domain.model.Tag
+import com.squareup.moshi.Json
+import com.squareup.moshi.JsonClass
+
+@JsonClass(generateAdapter = true)
+data class TagDto(
+ @Json(name = "id") val id: String,
+ @Json(name = "title") val title: String? = null,
+ @Json(name = "name") val name: String? = null,
+ @Json(name = "color") val color: String? = null
+)
+
+// API returns direct array for account-level tags
+typealias TagsResponse = List
+
+// Tagging request - add tag to card by tag title
+@JsonClass(generateAdapter = true)
+data class TaggingRequest(
+ @Json(name = "tag_title") val tagTitle: String
+)
+
+// Tagging DTO - represents a tag association on a card
+@JsonClass(generateAdapter = true)
+data class TaggingDto(
+ @Json(name = "id") val id: String,
+ @Json(name = "tag") val tag: TagDto
+)
+
+fun TagDto.toDomain(): Tag = Tag(
+ id = id.toLongOrNull() ?: 0L,
+ name = title ?: name ?: "",
+ color = color ?: "#808080"
+)
diff --git a/app/src/main/java/com/fizzy/android/data/api/dto/UserDto.kt b/app/src/main/java/com/fizzy/android/data/api/dto/UserDto.kt
new file mode 100644
index 0000000..649e732
--- /dev/null
+++ b/app/src/main/java/com/fizzy/android/data/api/dto/UserDto.kt
@@ -0,0 +1,26 @@
+package com.fizzy.android.data.api.dto
+
+import com.fizzy.android.domain.model.User
+import com.squareup.moshi.Json
+import com.squareup.moshi.JsonClass
+
+@JsonClass(generateAdapter = true)
+data class UserDto(
+ @Json(name = "id") val id: String,
+ @Json(name = "name") val name: String,
+ @Json(name = "email_address") val emailAddress: String? = null,
+ @Json(name = "avatar_url") val avatarUrl: String? = null,
+ @Json(name = "role") val role: String? = null,
+ @Json(name = "active") val active: Boolean = true,
+ @Json(name = "admin") val admin: Boolean = false,
+ @Json(name = "created_at") val createdAt: String? = null,
+ @Json(name = "url") val url: String? = null
+)
+
+fun UserDto.toDomain(): User = User(
+ id = id.hashCode().toLong(), // Convert string ID to long for domain model
+ name = name,
+ email = emailAddress ?: "",
+ avatarUrl = avatarUrl,
+ admin = admin || role == "owner"
+)
diff --git a/app/src/main/java/com/fizzy/android/data/local/AccountStorage.kt b/app/src/main/java/com/fizzy/android/data/local/AccountStorage.kt
new file mode 100644
index 0000000..4e7dfc2
--- /dev/null
+++ b/app/src/main/java/com/fizzy/android/data/local/AccountStorage.kt
@@ -0,0 +1,178 @@
+package com.fizzy.android.data.local
+
+import android.content.Context
+import android.content.SharedPreferences
+import androidx.security.crypto.EncryptedSharedPreferences
+import androidx.security.crypto.MasterKey
+import com.fizzy.android.domain.model.Account
+import com.squareup.moshi.Moshi
+import com.squareup.moshi.Types
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.map
+import javax.inject.Inject
+import javax.inject.Singleton
+
+interface AccountStorage {
+ val accounts: Flow>
+ val activeAccount: Flow
+
+ suspend fun addAccount(account: Account)
+ suspend fun removeAccount(accountId: String)
+ suspend fun setActiveAccount(accountId: String)
+ suspend fun updateAccount(account: Account)
+ suspend fun getAccount(accountId: String): Account?
+ suspend fun getActiveAccount(): Account?
+ suspend fun getAllAccounts(): List
+ suspend fun clearAll()
+}
+
+@Singleton
+class AccountStorageImpl @Inject constructor(
+ @ApplicationContext private val context: Context,
+ private val moshi: Moshi
+) : AccountStorage {
+
+ private val masterKey = MasterKey.Builder(context)
+ .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
+ .build()
+
+ private val prefs: SharedPreferences = EncryptedSharedPreferences.create(
+ context,
+ "fizzy_accounts",
+ masterKey,
+ EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
+ EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
+ )
+
+ private val accountListType = Types.newParameterizedType(List::class.java, AccountData::class.java)
+ private val accountAdapter = moshi.adapter>(accountListType)
+
+ private val _accountsFlow = MutableStateFlow>(loadAccounts())
+ override val accounts: Flow> = _accountsFlow.asStateFlow()
+
+ override val activeAccount: Flow = _accountsFlow.map { accounts ->
+ accounts.find { it.isActive }
+ }
+
+ private fun loadAccounts(): List {
+ val json = prefs.getString(KEY_ACCOUNTS, null) ?: return emptyList()
+ return try {
+ accountAdapter.fromJson(json)?.map { it.toAccount() } ?: emptyList()
+ } catch (e: Exception) {
+ emptyList()
+ }
+ }
+
+ private fun saveAccounts(accounts: List) {
+ val data = accounts.map { AccountData.fromAccount(it) }
+ val json = accountAdapter.toJson(data)
+ prefs.edit().putString(KEY_ACCOUNTS, json).apply()
+ _accountsFlow.value = accounts
+ }
+
+ override suspend fun addAccount(account: Account) {
+ val currentAccounts = _accountsFlow.value.toMutableList()
+
+ // Deactivate all existing accounts
+ val updatedAccounts = currentAccounts.map { it.copy(isActive = false) }.toMutableList()
+
+ // Add new account as active
+ updatedAccounts.add(account.copy(isActive = true))
+
+ saveAccounts(updatedAccounts)
+ }
+
+ override suspend fun removeAccount(accountId: String) {
+ val currentAccounts = _accountsFlow.value.toMutableList()
+ val wasActive = currentAccounts.find { it.id == accountId }?.isActive == true
+
+ currentAccounts.removeAll { it.id == accountId }
+
+ // If removed account was active, activate the first remaining account
+ if (wasActive && currentAccounts.isNotEmpty()) {
+ currentAccounts[0] = currentAccounts[0].copy(isActive = true)
+ }
+
+ saveAccounts(currentAccounts)
+ }
+
+ override suspend fun setActiveAccount(accountId: String) {
+ val currentAccounts = _accountsFlow.value.map { account ->
+ account.copy(isActive = account.id == accountId)
+ }
+ saveAccounts(currentAccounts)
+ }
+
+ override suspend fun updateAccount(account: Account) {
+ val currentAccounts = _accountsFlow.value.map { existing ->
+ if (existing.id == account.id) account else existing
+ }
+ saveAccounts(currentAccounts)
+ }
+
+ override suspend fun getAccount(accountId: String): Account? {
+ return _accountsFlow.value.find { it.id == accountId }
+ }
+
+ override suspend fun getActiveAccount(): Account? {
+ return _accountsFlow.value.find { it.isActive }
+ }
+
+ override suspend fun getAllAccounts(): List {
+ return _accountsFlow.value
+ }
+
+ override suspend fun clearAll() {
+ prefs.edit().clear().apply()
+ _accountsFlow.value = emptyList()
+ }
+
+ companion object {
+ private const val KEY_ACCOUNTS = "accounts"
+ }
+}
+
+// Internal data class for JSON serialization
+private data class AccountData(
+ val id: String,
+ val instanceUrl: String,
+ val email: String,
+ val token: String,
+ val userName: String,
+ val userId: Long,
+ val avatarUrl: String?,
+ val isActive: Boolean,
+ val fizzyAccountId: String? = null,
+ val fizzyAccountSlug: String? = null
+) {
+ fun toAccount() = Account(
+ id = id,
+ instanceUrl = instanceUrl,
+ email = email,
+ token = token,
+ userName = userName,
+ userId = userId,
+ avatarUrl = avatarUrl,
+ isActive = isActive,
+ fizzyAccountId = fizzyAccountId,
+ fizzyAccountSlug = fizzyAccountSlug
+ )
+
+ companion object {
+ fun fromAccount(account: Account) = AccountData(
+ id = account.id,
+ instanceUrl = account.instanceUrl,
+ email = account.email,
+ token = account.token,
+ userName = account.userName,
+ userId = account.userId,
+ avatarUrl = account.avatarUrl,
+ isActive = account.isActive,
+ fizzyAccountId = account.fizzyAccountId,
+ fizzyAccountSlug = account.fizzyAccountSlug
+ )
+ }
+}
diff --git a/app/src/main/java/com/fizzy/android/data/local/SettingsStorage.kt b/app/src/main/java/com/fizzy/android/data/local/SettingsStorage.kt
new file mode 100644
index 0000000..1919fcf
--- /dev/null
+++ b/app/src/main/java/com/fizzy/android/data/local/SettingsStorage.kt
@@ -0,0 +1,46 @@
+package com.fizzy.android.data.local
+
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.stringPreferencesKey
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+import javax.inject.Inject
+import javax.inject.Singleton
+
+enum class ThemeMode {
+ SYSTEM,
+ LIGHT,
+ DARK
+}
+
+interface SettingsStorage {
+ val themeMode: Flow
+ suspend fun setThemeMode(mode: ThemeMode)
+}
+
+@Singleton
+class SettingsStorageImpl @Inject constructor(
+ private val dataStore: DataStore
+) : SettingsStorage {
+
+ override val themeMode: Flow = dataStore.data.map { preferences ->
+ val value = preferences[KEY_THEME_MODE] ?: ThemeMode.SYSTEM.name
+ try {
+ ThemeMode.valueOf(value)
+ } catch (e: Exception) {
+ ThemeMode.SYSTEM
+ }
+ }
+
+ override suspend fun setThemeMode(mode: ThemeMode) {
+ dataStore.edit { preferences ->
+ preferences[KEY_THEME_MODE] = mode.name
+ }
+ }
+
+ companion object {
+ private val KEY_THEME_MODE = stringPreferencesKey("theme_mode")
+ }
+}
diff --git a/app/src/main/java/com/fizzy/android/data/repository/AuthRepositoryImpl.kt b/app/src/main/java/com/fizzy/android/data/repository/AuthRepositoryImpl.kt
new file mode 100644
index 0000000..06774bb
--- /dev/null
+++ b/app/src/main/java/com/fizzy/android/data/repository/AuthRepositoryImpl.kt
@@ -0,0 +1,179 @@
+package com.fizzy.android.data.repository
+
+import com.fizzy.android.core.network.ApiResult
+import com.fizzy.android.core.network.InstanceManager
+import com.fizzy.android.data.api.FizzyApiService
+import com.fizzy.android.data.api.dto.RequestMagicLinkRequest
+import com.fizzy.android.data.api.dto.VerifyMagicLinkRequest
+import com.fizzy.android.data.api.dto.getAccountId
+import com.fizzy.android.data.api.dto.getAccountSlug
+import com.fizzy.android.data.api.dto.toDomain
+import com.fizzy.android.data.api.dto.toUser
+import com.fizzy.android.data.local.AccountStorage
+import com.fizzy.android.domain.model.Account
+import com.fizzy.android.domain.model.User
+import com.fizzy.android.domain.repository.AuthRepository
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+import java.util.UUID
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class AuthRepositoryImpl @Inject constructor(
+ private val apiService: FizzyApiService,
+ private val accountStorage: AccountStorage,
+ private val instanceManager: InstanceManager
+) : AuthRepository {
+
+ override val isLoggedIn: Flow = accountStorage.activeAccount.map { it != null }
+
+ override val currentAccount: Flow = accountStorage.activeAccount
+
+ override val allAccounts: Flow> = accountStorage.accounts
+
+ // Store pending authentication token for magic link verification
+ private var pendingAuthToken: String? = null
+ private var pendingEmail: String? = null
+
+ override suspend fun requestMagicLink(instanceUrl: String, email: String): ApiResult {
+ // Temporarily set the instance for this request
+ instanceManager.setInstance(instanceUrl, "")
+
+ return ApiResult.from {
+ apiService.requestMagicLink(RequestMagicLinkRequest(emailAddress = email))
+ }.map { response ->
+ // Store the pending auth token and email for verification step
+ pendingAuthToken = response.pendingAuthenticationToken
+ pendingEmail = email
+ }
+ }
+
+ override suspend fun verifyMagicLink(instanceUrl: String, email: String, code: String): ApiResult {
+ instanceManager.setInstance(instanceUrl, "")
+
+ return ApiResult.from {
+ apiService.verifyMagicLink(VerifyMagicLinkRequest(code = code))
+ }.mapSuspend { response ->
+ val token = response.sessionToken
+
+ // Set the token to fetch user identity
+ instanceManager.setInstance(instanceUrl, token)
+
+ // Fetch user identity with the new token
+ val identityResult = ApiResult.from { apiService.getCurrentIdentity() }
+ val identity = when (identityResult) {
+ is ApiResult.Success -> identityResult.data
+ is ApiResult.Error -> throw Exception("Failed to fetch identity: ${identityResult.message}")
+ is ApiResult.Exception -> throw identityResult.throwable
+ }
+
+ val user = identity.toUser()
+ val accountId = identity.getAccountId()
+ val accountSlug = identity.getAccountSlug()
+
+ val account = Account(
+ id = UUID.randomUUID().toString(),
+ instanceUrl = instanceManager.getBaseUrl() ?: instanceUrl,
+ email = pendingEmail ?: email,
+ token = token,
+ userName = user.name,
+ userId = user.id,
+ avatarUrl = user.avatarUrl,
+ isActive = true,
+ fizzyAccountId = accountId,
+ fizzyAccountSlug = accountSlug
+ )
+
+ instanceManager.setInstance(account.instanceUrl, account.token, accountSlug)
+ accountStorage.addAccount(account)
+
+ // Clear pending state
+ pendingAuthToken = null
+ pendingEmail = null
+
+ account
+ }
+ }
+
+ override suspend fun loginWithToken(instanceUrl: String, token: String): ApiResult {
+ instanceManager.setInstance(instanceUrl, token)
+
+ val result = ApiResult.from {
+ apiService.getCurrentIdentity()
+ }
+
+ return when (result) {
+ is ApiResult.Success -> {
+ val identity = result.data
+ val domainUser = identity.toUser()
+ val accountId = identity.getAccountId()
+ val accountSlug = identity.getAccountSlug()
+
+ android.util.Log.d("AuthRepository", "Identity loaded: ${domainUser.email}, accountId: $accountId, slug: $accountSlug")
+
+ val account = Account(
+ id = UUID.randomUUID().toString(),
+ instanceUrl = instanceManager.getBaseUrl() ?: instanceUrl,
+ email = domainUser.email,
+ token = token,
+ userName = domainUser.name,
+ userId = domainUser.id,
+ avatarUrl = domainUser.avatarUrl,
+ isActive = true,
+ fizzyAccountId = accountId,
+ fizzyAccountSlug = accountSlug
+ )
+
+ // Update instance manager with account slug for API calls
+ instanceManager.setInstance(account.instanceUrl, account.token, accountSlug)
+ accountStorage.addAccount(account)
+ ApiResult.Success(account)
+ }
+ is ApiResult.Error -> {
+ android.util.Log.e("AuthRepository", "Token login failed: ${result.code} - ${result.message}")
+ ApiResult.Error(result.code, "Auth failed (${result.code}): ${result.message}")
+ }
+ is ApiResult.Exception -> {
+ android.util.Log.e("AuthRepository", "Token login exception", result.throwable)
+ ApiResult.Exception(result.throwable)
+ }
+ }
+ }
+
+ override suspend fun getCurrentUser(): ApiResult {
+ return ApiResult.from {
+ apiService.getCurrentUser()
+ }.map { it.toDomain() }
+ }
+
+ override suspend fun switchAccount(accountId: String) {
+ val account = accountStorage.getAccount(accountId) ?: return
+ accountStorage.setActiveAccount(accountId)
+ instanceManager.setInstance(account.instanceUrl, account.token, account.fizzyAccountSlug)
+ }
+
+ override suspend fun logout(accountId: String) {
+ accountStorage.removeAccount(accountId)
+
+ // If there's another account, switch to it
+ val remainingAccount = accountStorage.getActiveAccount()
+ if (remainingAccount != null) {
+ instanceManager.setInstance(remainingAccount.instanceUrl, remainingAccount.token)
+ } else {
+ instanceManager.clearInstance()
+ }
+ }
+
+ override suspend fun logoutAll() {
+ accountStorage.clearAll()
+ instanceManager.clearInstance()
+ }
+
+ override suspend fun initializeActiveAccount() {
+ val activeAccount = accountStorage.getActiveAccount()
+ if (activeAccount != null) {
+ instanceManager.setInstance(activeAccount.instanceUrl, activeAccount.token, activeAccount.fizzyAccountSlug)
+ }
+ }
+}
diff --git a/app/src/main/java/com/fizzy/android/data/repository/BoardRepositoryImpl.kt b/app/src/main/java/com/fizzy/android/data/repository/BoardRepositoryImpl.kt
new file mode 100644
index 0000000..094cdac
--- /dev/null
+++ b/app/src/main/java/com/fizzy/android/data/repository/BoardRepositoryImpl.kt
@@ -0,0 +1,200 @@
+package com.fizzy.android.data.repository
+
+import com.fizzy.android.core.network.ApiResult
+import com.fizzy.android.data.api.FizzyApiService
+import com.fizzy.android.data.api.dto.*
+import com.fizzy.android.domain.model.Board
+import com.fizzy.android.domain.model.Column
+import com.fizzy.android.domain.model.Tag
+import com.fizzy.android.domain.model.User
+import com.fizzy.android.domain.repository.BoardRepository
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class BoardRepositoryImpl @Inject constructor(
+ private val apiService: FizzyApiService
+) : BoardRepository {
+
+ private val _boardsFlow = MutableStateFlow>(emptyList())
+
+ override fun observeBoards(): Flow> = _boardsFlow.asStateFlow()
+
+ override suspend fun getBoards(): ApiResult> {
+ return ApiResult.from {
+ apiService.getBoards()
+ }.map { response ->
+ val boards = response.map { it.toDomain() }
+ _boardsFlow.value = boards
+ boards
+ }
+ }
+
+ override suspend fun getBoard(boardId: String): ApiResult {
+ return ApiResult.from {
+ apiService.getBoard(boardId)
+ }.map { response ->
+ response.toDomain()
+ }
+ }
+
+ override suspend fun createBoard(name: String, description: String?): ApiResult {
+ val response = apiService.createBoard(createBoardRequest(name))
+ return if (response.isSuccessful) {
+ // Parse board ID from Location header: /0000001/boards/{id}.json
+ val location = response.headers()["Location"]
+ val boardId = location?.substringAfterLast("/boards/")?.removeSuffix(".json")
+
+ if (boardId != null) {
+ // Refresh the boards list and return the new board
+ val boardsResult = getBoards()
+ if (boardsResult is ApiResult.Success) {
+ val newBoard = boardsResult.data.find { it.id == boardId }
+ if (newBoard != null) {
+ ApiResult.Success(newBoard)
+ } else {
+ ApiResult.Error(0, "Board created but not found in list")
+ }
+ } else {
+ ApiResult.Error(0, "Board created but failed to refresh list")
+ }
+ } else {
+ ApiResult.Error(0, "Board created but no ID in response")
+ }
+ } else {
+ ApiResult.Error(response.code(), response.message())
+ }
+ }
+
+ override suspend fun updateBoard(boardId: String, name: String?, description: String?): ApiResult {
+ val response = apiService.updateBoard(boardId, updateBoardRequest(name))
+ return if (response.isSuccessful) {
+ // API returns 204 No Content, so refetch the board
+ val boardResult = getBoard(boardId)
+ if (boardResult is ApiResult.Success) {
+ val updatedBoard = boardResult.data
+ _boardsFlow.value = _boardsFlow.value.map {
+ if (it.id == boardId) updatedBoard else it
+ }
+ ApiResult.Success(updatedBoard)
+ } else {
+ boardResult
+ }
+ } else {
+ ApiResult.Error(response.code(), response.message())
+ }
+ }
+
+ override suspend fun deleteBoard(boardId: String): ApiResult {
+ return ApiResult.from {
+ apiService.deleteBoard(boardId)
+ }.map {
+ _boardsFlow.value = _boardsFlow.value.filter { it.id != boardId }
+ }
+ }
+
+ override suspend fun getColumns(boardId: String): ApiResult> {
+ return ApiResult.from {
+ apiService.getColumns(boardId)
+ }.map { response ->
+ response.map { it.toDomain() }.sortedBy { it.position }
+ }
+ }
+
+ override suspend fun createColumn(boardId: String, name: String, position: Int?): ApiResult {
+ val response = apiService.createColumn(boardId, createColumnRequest(name, position = position))
+ return if (response.isSuccessful) {
+ // API returns 201 with empty body, parse column ID from Location header
+ val location = response.headers()["Location"]
+ val columnId = location?.substringAfterLast("/columns/")?.removeSuffix(".json")
+
+ if (columnId != null) {
+ // Refetch the columns and return the new one
+ val columnsResult = getColumns(boardId)
+ if (columnsResult is ApiResult.Success) {
+ val newColumn = columnsResult.data.find { it.id == columnId }
+ if (newColumn != null) {
+ ApiResult.Success(newColumn)
+ } else {
+ ApiResult.Error(0, "Column created but not found in list")
+ }
+ } else {
+ ApiResult.Error(0, "Column created but failed to refresh list")
+ }
+ } else {
+ ApiResult.Error(0, "Column created but no ID in response")
+ }
+ } else {
+ ApiResult.Error(response.code(), response.message())
+ }
+ }
+
+ override suspend fun updateColumn(
+ boardId: String,
+ columnId: String,
+ name: String?,
+ position: Int?
+ ): ApiResult {
+ val response = apiService.updateColumn(boardId, columnId, updateColumnRequest(name, position = position))
+ return if (response.isSuccessful) {
+ // API returns 204 No Content, so refetch the columns
+ val columnsResult = getColumns(boardId)
+ if (columnsResult is ApiResult.Success) {
+ val updatedColumn = columnsResult.data.find { it.id == columnId }
+ if (updatedColumn != null) {
+ ApiResult.Success(updatedColumn)
+ } else {
+ ApiResult.Error(0, "Column updated but not found in list")
+ }
+ } else {
+ ApiResult.Error(0, "Column updated but failed to refresh list")
+ }
+ } else {
+ ApiResult.Error(response.code(), response.message())
+ }
+ }
+
+ override suspend fun deleteColumn(boardId: String, columnId: String): ApiResult {
+ return ApiResult.from {
+ apiService.deleteColumn(boardId, columnId)
+ }
+ }
+
+ // Tags are now at account level, not board level
+ override suspend fun getTags(boardId: String): ApiResult> {
+ return ApiResult.from {
+ apiService.getTags()
+ }.map { response ->
+ response.map { it.toDomain() }
+ }
+ }
+
+ override suspend fun createTag(boardId: String, name: String, color: String): ApiResult {
+ // Note: Tag creation at account level is not currently supported in the API service
+ // This would need a new endpoint like POST /tags with wrapped request
+ android.util.Log.w("BoardRepository", "createTag: Account-level tag creation not implemented")
+ return ApiResult.Error(501, "Tag creation at account level not implemented")
+ }
+
+ override suspend fun deleteTag(boardId: String, tagId: Long): ApiResult {
+ // Note: Tag deletion at account level is not currently supported in the API service
+ // This would need a new endpoint like DELETE /tags/{tagId}
+ android.util.Log.w("BoardRepository", "deleteTag: Account-level tag deletion not implemented")
+ return ApiResult.Error(501, "Tag deletion at account level not implemented")
+ }
+
+ override suspend fun getBoardUsers(boardId: String): ApiResult> {
+ return ApiResult.from {
+ apiService.getUsers()
+ }.map { users ->
+ users.map { it.toDomain() }
+ }
+ }
+
+ override suspend fun refreshBoards() {
+ getBoards()
+ }
+}
diff --git a/app/src/main/java/com/fizzy/android/data/repository/CardRepositoryImpl.kt b/app/src/main/java/com/fizzy/android/data/repository/CardRepositoryImpl.kt
new file mode 100644
index 0000000..da9a400
--- /dev/null
+++ b/app/src/main/java/com/fizzy/android/data/repository/CardRepositoryImpl.kt
@@ -0,0 +1,384 @@
+package com.fizzy.android.data.repository
+
+import android.util.Log
+import com.fizzy.android.core.network.ApiResult
+import com.fizzy.android.data.api.FizzyApiService
+import com.fizzy.android.data.api.dto.*
+import com.fizzy.android.domain.model.Card
+import com.fizzy.android.domain.model.Comment
+import com.fizzy.android.domain.model.Step
+import com.fizzy.android.domain.repository.CardRepository
+import java.time.LocalDate
+import javax.inject.Inject
+import javax.inject.Singleton
+
+private const val TAG = "CardRepositoryImpl"
+
+@Singleton
+class CardRepositoryImpl @Inject constructor(
+ private val apiService: FizzyApiService
+) : CardRepository {
+
+ override suspend fun getBoardCards(boardId: String): ApiResult> {
+ val result = ApiResult.from {
+ apiService.getCards(boardId)
+ }
+ Log.d(TAG, "getBoardCards result: $result")
+ when (result) {
+ is ApiResult.Success -> Log.d(TAG, "getBoardCards success: ${result.data.size} cards")
+ is ApiResult.Error -> Log.e(TAG, "getBoardCards error: ${result.code} - ${result.message}")
+ is ApiResult.Exception -> Log.e(TAG, "getBoardCards exception", result.throwable)
+ }
+ return result.map { response ->
+ response.map { it.toDomain() }.sortedBy { it.position }
+ }
+ }
+
+ override suspend fun getCard(cardId: Long): ApiResult {
+ return ApiResult.from {
+ apiService.getCard(cardId.toInt())
+ }.map { response ->
+ response.toDomain()
+ }
+ }
+
+ override suspend fun createCard(
+ boardId: String,
+ columnId: String,
+ title: String,
+ description: String?
+ ): ApiResult {
+ val response = apiService.createCard(boardId, createCardRequest(title, description, columnId))
+ return if (response.isSuccessful) {
+ // API returns 201 with empty body, parse card number from Location header
+ val location = response.headers()["Location"]
+ val cardNumberStr = location?.substringAfterLast("/cards/")?.removeSuffix(".json")
+ val cardNumber = cardNumberStr?.toIntOrNull()
+
+ if (cardNumber != null) {
+ // Fetch the new card
+ getCard(cardNumber.toLong())
+ } else {
+ ApiResult.Error(0, "Card created but no number in response")
+ }
+ } else {
+ ApiResult.Error(response.code(), response.message())
+ }
+ }
+
+ override suspend fun updateCard(cardId: Long, title: String?, description: String?): ApiResult {
+ val response = apiService.updateCard(cardId.toInt(), updateCardRequest(title = title, description = description))
+ return if (response.isSuccessful) {
+ // API returns 204 No Content, so refetch the card
+ getCard(cardId)
+ } else {
+ ApiResult.Error(response.code(), response.message())
+ }
+ }
+
+ override suspend fun deleteCard(cardId: Long): ApiResult {
+ return ApiResult.from {
+ apiService.deleteCard(cardId.toInt())
+ }
+ }
+
+ override suspend fun moveCard(cardId: Long, columnId: String, position: Int): ApiResult {
+ // Use triage endpoint to move card to a column
+ val response = apiService.triageCard(cardId.toInt(), TriageCardRequest(columnId))
+ return if (response.isSuccessful) {
+ // If we also need to update position, do it separately
+ if (position > 0) {
+ apiService.updateCard(cardId.toInt(), updateCardRequest(columnId = columnId, position = position))
+ }
+ getCard(cardId)
+ } else {
+ ApiResult.Error(response.code(), response.message())
+ }
+ }
+
+ override suspend fun closeCard(cardId: Long): ApiResult {
+ val response = apiService.closeCard(cardId.toInt())
+ return if (response.isSuccessful) {
+ getCard(cardId)
+ } else {
+ ApiResult.Error(response.code(), response.message())
+ }
+ }
+
+ override suspend fun reopenCard(cardId: Long): ApiResult {
+ val response = apiService.reopenCard(cardId.toInt())
+ return if (response.isSuccessful) {
+ getCard(cardId)
+ } else {
+ ApiResult.Error(response.code(), response.message())
+ }
+ }
+
+ override suspend fun triageCard(cardId: Long, date: LocalDate): ApiResult {
+ // Note: Fizzy API triage takes column_id, not date
+ // This may need to be adjusted based on actual API behavior
+ val response = apiService.markCardNotNow(cardId.toInt())
+ return if (response.isSuccessful) {
+ getCard(cardId)
+ } else {
+ ApiResult.Error(response.code(), response.message())
+ }
+ }
+
+ override suspend fun deferCard(cardId: Long, date: LocalDate): ApiResult {
+ // Use not_now endpoint for deferring
+ val response = apiService.markCardNotNow(cardId.toInt())
+ return if (response.isSuccessful) {
+ getCard(cardId)
+ } else {
+ ApiResult.Error(response.code(), response.message())
+ }
+ }
+
+ override suspend fun togglePriority(cardId: Long, priority: Boolean): ApiResult {
+ val response = if (priority) {
+ apiService.markCardGolden(cardId.toInt())
+ } else {
+ apiService.unmarkCardGolden(cardId.toInt())
+ }
+ return if (response.isSuccessful) {
+ getCard(cardId)
+ } else {
+ ApiResult.Error(response.code(), response.message())
+ }
+ }
+
+ override suspend fun toggleWatch(cardId: Long, watching: Boolean): ApiResult {
+ val response = if (watching) {
+ apiService.watchCard(cardId.toInt())
+ } else {
+ apiService.unwatchCard(cardId.toInt())
+ }
+ return if (response.isSuccessful) {
+ getCard(cardId)
+ } else {
+ ApiResult.Error(response.code(), response.message())
+ }
+ }
+
+ override suspend fun addAssignee(cardId: Long, userId: Long): ApiResult {
+ val response = apiService.addAssignment(cardId.toInt(), AssignmentRequest(userId.toString()))
+ return if (response.isSuccessful) {
+ getCard(cardId)
+ } else {
+ ApiResult.Error(response.code(), response.message())
+ }
+ }
+
+ override suspend fun removeAssignee(cardId: Long, userId: Long): ApiResult {
+ // Note: The Fizzy API may not have a direct endpoint for removing assignees
+ // This might need to be done via PUT cards/{cardNumber} with updated assignee list
+ // For now, refetch the card (this is a stub that needs actual API clarification)
+ Log.w(TAG, "removeAssignee: API endpoint unclear, operation may not work correctly")
+ return getCard(cardId)
+ }
+
+ override suspend fun addTag(cardId: Long, tagId: Long): ApiResult {
+ // Note: Fizzy API uses tag_title for taggings, not tag_id
+ // This might need a separate getTags call to get the title first
+ // For now, assuming the tagId is actually the tag title or we have it cached
+ Log.w(TAG, "addTag: Using tagId as tag title - may need adjustment")
+ val response = apiService.addTagging(cardId.toInt(), TaggingRequest(tagId.toString()))
+ return if (response.isSuccessful) {
+ getCard(cardId)
+ } else {
+ ApiResult.Error(response.code(), response.message())
+ }
+ }
+
+ override suspend fun removeTag(cardId: Long, tagId: Long): ApiResult {
+ // Fizzy API uses taggingId to remove, not tagId
+ // This needs the tagging ID from the card's tags list
+ val response = apiService.removeTagging(cardId.toInt(), tagId.toString())
+ return if (response.isSuccessful) {
+ getCard(cardId)
+ } else {
+ ApiResult.Error(response.code(), response.message())
+ }
+ }
+
+ override suspend fun getSteps(cardId: Long): ApiResult> {
+ return ApiResult.from {
+ apiService.getSteps(cardId.toInt())
+ }.map { response ->
+ response.map { it.toDomain() }.sortedBy { it.position }
+ }
+ }
+
+ override suspend fun createStep(cardId: Long, description: String): ApiResult {
+ val response = apiService.createStep(cardId.toInt(), createStepRequest(description))
+ return if (response.isSuccessful) {
+ // API returns 201 with empty body, parse step ID from Location header
+ val location = response.headers()["Location"]
+ val stepIdStr = location?.substringAfterLast("/steps/")?.removeSuffix(".json")
+ val stepId = stepIdStr?.toLongOrNull()
+
+ if (stepId != null) {
+ // Refetch the steps and return the new one
+ val stepsResult = getSteps(cardId)
+ if (stepsResult is ApiResult.Success) {
+ val newStep = stepsResult.data.find { it.id == stepId }
+ if (newStep != null) {
+ ApiResult.Success(newStep)
+ } else {
+ ApiResult.Error(0, "Step created but not found in list")
+ }
+ } else {
+ ApiResult.Error(0, "Step created but failed to refresh list")
+ }
+ } else {
+ ApiResult.Error(0, "Step created but no ID in response")
+ }
+ } else {
+ ApiResult.Error(response.code(), response.message())
+ }
+ }
+
+ override suspend fun updateStep(
+ cardId: Long,
+ stepId: Long,
+ description: String?,
+ completed: Boolean?,
+ position: Int?
+ ): ApiResult {
+ val response = apiService.updateStep(
+ cardId.toInt(),
+ stepId.toString(),
+ updateStepRequest(content = description, completed = completed, position = position)
+ )
+ return if (response.isSuccessful) {
+ // API returns 204 No Content, so refetch the steps
+ val stepsResult = getSteps(cardId)
+ if (stepsResult is ApiResult.Success) {
+ val updatedStep = stepsResult.data.find { it.id == stepId }
+ if (updatedStep != null) {
+ ApiResult.Success(updatedStep)
+ } else {
+ ApiResult.Error(0, "Step updated but not found in list")
+ }
+ } else {
+ ApiResult.Error(0, "Step updated but failed to refresh list")
+ }
+ } else {
+ ApiResult.Error(response.code(), response.message())
+ }
+ }
+
+ override suspend fun deleteStep(cardId: Long, stepId: Long): ApiResult {
+ return ApiResult.from {
+ apiService.deleteStep(cardId.toInt(), stepId.toString())
+ }
+ }
+
+ override suspend fun getComments(cardId: Long): ApiResult> {
+ return ApiResult.from {
+ apiService.getComments(cardId.toInt())
+ }.map { response ->
+ response.map { it.toDomain() }
+ }
+ }
+
+ override suspend fun createComment(cardId: Long, content: String): ApiResult {
+ val response = apiService.createComment(cardId.toInt(), createCommentRequest(content))
+ return if (response.isSuccessful) {
+ // API returns 201 with empty body, parse comment ID from Location header
+ val location = response.headers()["Location"]
+ val commentIdStr = location?.substringAfterLast("/comments/")?.removeSuffix(".json")
+ val commentId = commentIdStr?.toLongOrNull()
+
+ if (commentId != null) {
+ // Refetch the comments and return the new one
+ val commentsResult = getComments(cardId)
+ if (commentsResult is ApiResult.Success) {
+ val newComment = commentsResult.data.find { it.id == commentId }
+ if (newComment != null) {
+ ApiResult.Success(newComment)
+ } else {
+ ApiResult.Error(0, "Comment created but not found in list")
+ }
+ } else {
+ ApiResult.Error(0, "Comment created but failed to refresh list")
+ }
+ } else {
+ ApiResult.Error(0, "Comment created but no ID in response")
+ }
+ } else {
+ ApiResult.Error(response.code(), response.message())
+ }
+ }
+
+ override suspend fun updateComment(cardId: Long, commentId: Long, content: String): ApiResult {
+ val response = apiService.updateComment(cardId.toInt(), commentId.toString(), updateCommentRequest(content))
+ return if (response.isSuccessful) {
+ // API returns 204 No Content, so refetch the comments
+ val commentsResult = getComments(cardId)
+ if (commentsResult is ApiResult.Success) {
+ val updatedComment = commentsResult.data.find { it.id == commentId }
+ if (updatedComment != null) {
+ ApiResult.Success(updatedComment)
+ } else {
+ ApiResult.Error(0, "Comment updated but not found in list")
+ }
+ } else {
+ ApiResult.Error(0, "Comment updated but failed to refresh list")
+ }
+ } else {
+ ApiResult.Error(response.code(), response.message())
+ }
+ }
+
+ override suspend fun deleteComment(cardId: Long, commentId: Long): ApiResult {
+ return ApiResult.from {
+ apiService.deleteComment(cardId.toInt(), commentId.toString())
+ }
+ }
+
+ override suspend fun addReaction(cardId: Long, commentId: Long, emoji: String): ApiResult {
+ val response = apiService.addReaction(cardId.toInt(), commentId.toString(), createReactionRequest(emoji))
+ return if (response.isSuccessful) {
+ // Refetch comments and find the updated one
+ val commentsResult = getComments(cardId)
+ if (commentsResult is ApiResult.Success) {
+ val comment = commentsResult.data.find { it.id == commentId }
+ if (comment != null) {
+ ApiResult.Success(comment)
+ } else {
+ ApiResult.Error(0, "Reaction added but comment not found")
+ }
+ } else {
+ ApiResult.Error(0, "Reaction added but failed to refresh comments")
+ }
+ } else {
+ ApiResult.Error(response.code(), response.message())
+ }
+ }
+
+ override suspend fun removeReaction(cardId: Long, commentId: Long, emoji: String): ApiResult {
+ // Note: Fizzy API removes by reactionId, not emoji
+ // This needs the reaction ID from the comment's reactions list
+ // For now, treating emoji as reactionId (needs proper implementation)
+ Log.w(TAG, "removeReaction: Using emoji as reactionId - may need adjustment")
+ val response = apiService.removeReaction(cardId.toInt(), commentId.toString(), emoji)
+ return if (response.isSuccessful) {
+ // Refetch comments and find the updated one
+ val commentsResult = getComments(cardId)
+ if (commentsResult is ApiResult.Success) {
+ val comment = commentsResult.data.find { it.id == commentId }
+ if (comment != null) {
+ ApiResult.Success(comment)
+ } else {
+ ApiResult.Error(0, "Reaction removed but comment not found")
+ }
+ } else {
+ ApiResult.Error(0, "Reaction removed but failed to refresh comments")
+ }
+ } else {
+ ApiResult.Error(response.code(), response.message())
+ }
+ }
+}
diff --git a/app/src/main/java/com/fizzy/android/data/repository/NotificationRepositoryImpl.kt b/app/src/main/java/com/fizzy/android/data/repository/NotificationRepositoryImpl.kt
new file mode 100644
index 0000000..661e79f
--- /dev/null
+++ b/app/src/main/java/com/fizzy/android/data/repository/NotificationRepositoryImpl.kt
@@ -0,0 +1,46 @@
+package com.fizzy.android.data.repository
+
+import com.fizzy.android.core.network.ApiResult
+import com.fizzy.android.data.api.FizzyApiService
+import com.fizzy.android.data.api.dto.toDomain
+import com.fizzy.android.domain.model.Notification
+import com.fizzy.android.domain.repository.NotificationRepository
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class NotificationRepositoryImpl @Inject constructor(
+ private val apiService: FizzyApiService
+) : NotificationRepository {
+
+ private val _unreadCount = MutableStateFlow(0)
+ override val unreadCount: Flow = _unreadCount.asStateFlow()
+
+ override suspend fun getNotifications(): ApiResult> {
+ return ApiResult.from {
+ apiService.getNotifications()
+ }.map { response ->
+ _unreadCount.value = response.unreadCount
+ response.notifications.map { it.toDomain() }
+ }
+ }
+
+ override suspend fun markAsRead(notificationId: Long): ApiResult {
+ return ApiResult.from {
+ apiService.markNotificationRead(notificationId.toString())
+ }.map {
+ _unreadCount.value = (_unreadCount.value - 1).coerceAtLeast(0)
+ }
+ }
+
+ override suspend fun markAllAsRead(): ApiResult {
+ return ApiResult.from {
+ apiService.markAllNotificationsRead()
+ }.map {
+ _unreadCount.value = 0
+ }
+ }
+}
diff --git a/app/src/main/java/com/fizzy/android/domain/model/Account.kt b/app/src/main/java/com/fizzy/android/domain/model/Account.kt
new file mode 100644
index 0000000..29b7842
--- /dev/null
+++ b/app/src/main/java/com/fizzy/android/domain/model/Account.kt
@@ -0,0 +1,23 @@
+package com.fizzy.android.domain.model
+
+data class Account(
+ val id: String,
+ val instanceUrl: String,
+ val email: String,
+ val token: String,
+ val userName: String,
+ val userId: Long,
+ val avatarUrl: String? = null,
+ val isActive: Boolean = false,
+ val fizzyAccountId: String? = null,
+ val fizzyAccountSlug: String? = null
+) {
+ val displayName: String
+ get() = "$userName ($instanceUrl)"
+
+ val instanceHost: String
+ get() = instanceUrl
+ .removePrefix("https://")
+ .removePrefix("http://")
+ .removeSuffix("/")
+}
diff --git a/app/src/main/java/com/fizzy/android/domain/model/Board.kt b/app/src/main/java/com/fizzy/android/domain/model/Board.kt
new file mode 100644
index 0000000..f0c0c56
--- /dev/null
+++ b/app/src/main/java/com/fizzy/android/domain/model/Board.kt
@@ -0,0 +1,16 @@
+package com.fizzy.android.domain.model
+
+import java.time.Instant
+
+data class Board(
+ val id: String,
+ val name: String,
+ val description: String? = null,
+ val createdAt: Instant,
+ val updatedAt: Instant? = null,
+ val cardsCount: Int = 0,
+ val columnsCount: Int = 0,
+ val creator: User? = null,
+ val allAccess: Boolean = false,
+ val url: String? = null
+)
diff --git a/app/src/main/java/com/fizzy/android/domain/model/Card.kt b/app/src/main/java/com/fizzy/android/domain/model/Card.kt
new file mode 100644
index 0000000..03a4505
--- /dev/null
+++ b/app/src/main/java/com/fizzy/android/domain/model/Card.kt
@@ -0,0 +1,42 @@
+package com.fizzy.android.domain.model
+
+import java.time.Instant
+import java.time.LocalDate
+
+data class Card(
+ val id: Long,
+ val title: String,
+ val description: String? = null,
+ val position: Int,
+ val columnId: String,
+ val boardId: String,
+ val status: CardStatus = CardStatus.ACTIVE,
+ val priority: Boolean = false,
+ val watching: Boolean = false,
+ val triageAt: LocalDate? = null,
+ val deferUntil: LocalDate? = null,
+ val createdAt: Instant,
+ val updatedAt: Instant,
+ val creator: User? = null,
+ val assignees: List = emptyList(),
+ val tags: List = emptyList(),
+ val stepsTotal: Int = 0,
+ val stepsCompleted: Int = 0,
+ val commentsCount: Int = 0
+) {
+ val hasSteps: Boolean
+ get() = stepsTotal > 0
+
+ val stepsProgress: Float
+ get() = if (stepsTotal > 0) stepsCompleted.toFloat() / stepsTotal else 0f
+
+ val stepsDisplay: String
+ get() = "$stepsCompleted/$stepsTotal"
+}
+
+enum class CardStatus {
+ ACTIVE,
+ CLOSED,
+ TRIAGED,
+ DEFERRED
+}
diff --git a/app/src/main/java/com/fizzy/android/domain/model/Column.kt b/app/src/main/java/com/fizzy/android/domain/model/Column.kt
new file mode 100644
index 0000000..c5a2640
--- /dev/null
+++ b/app/src/main/java/com/fizzy/android/domain/model/Column.kt
@@ -0,0 +1,10 @@
+package com.fizzy.android.domain.model
+
+data class Column(
+ val id: String,
+ val name: String,
+ val position: Int,
+ val boardId: String,
+ val cards: List = emptyList(),
+ val cardsCount: Int = 0
+)
diff --git a/app/src/main/java/com/fizzy/android/domain/model/Comment.kt b/app/src/main/java/com/fizzy/android/domain/model/Comment.kt
new file mode 100644
index 0000000..2354e05
--- /dev/null
+++ b/app/src/main/java/com/fizzy/android/domain/model/Comment.kt
@@ -0,0 +1,20 @@
+package com.fizzy.android.domain.model
+
+import java.time.Instant
+
+data class Comment(
+ val id: Long,
+ val content: String,
+ val cardId: Long,
+ val author: User,
+ val createdAt: Instant,
+ val updatedAt: Instant,
+ val reactions: List = emptyList()
+)
+
+data class Reaction(
+ val emoji: String,
+ val count: Int,
+ val users: List = emptyList(),
+ val reactedByMe: Boolean = false
+)
diff --git a/app/src/main/java/com/fizzy/android/domain/model/Notification.kt b/app/src/main/java/com/fizzy/android/domain/model/Notification.kt
new file mode 100644
index 0000000..df90f1a
--- /dev/null
+++ b/app/src/main/java/com/fizzy/android/domain/model/Notification.kt
@@ -0,0 +1,27 @@
+package com.fizzy.android.domain.model
+
+import java.time.Instant
+
+data class Notification(
+ val id: Long,
+ val type: NotificationType,
+ val title: String,
+ val body: String,
+ val read: Boolean,
+ val createdAt: Instant,
+ val cardId: Long? = null,
+ val boardId: Long? = null,
+ val actor: User? = null
+)
+
+enum class NotificationType {
+ CARD_ASSIGNED,
+ CARD_MENTIONED,
+ CARD_COMMENTED,
+ CARD_MOVED,
+ CARD_UPDATED,
+ STEP_COMPLETED,
+ REACTION_ADDED,
+ BOARD_SHARED,
+ OTHER
+}
diff --git a/app/src/main/java/com/fizzy/android/domain/model/Step.kt b/app/src/main/java/com/fizzy/android/domain/model/Step.kt
new file mode 100644
index 0000000..d26566b
--- /dev/null
+++ b/app/src/main/java/com/fizzy/android/domain/model/Step.kt
@@ -0,0 +1,13 @@
+package com.fizzy.android.domain.model
+
+import java.time.Instant
+
+data class Step(
+ val id: Long,
+ val description: String,
+ val completed: Boolean,
+ val position: Int,
+ val cardId: Long,
+ val completedBy: User? = null,
+ val completedAt: Instant? = null
+)
diff --git a/app/src/main/java/com/fizzy/android/domain/model/Tag.kt b/app/src/main/java/com/fizzy/android/domain/model/Tag.kt
new file mode 100644
index 0000000..4513543
--- /dev/null
+++ b/app/src/main/java/com/fizzy/android/domain/model/Tag.kt
@@ -0,0 +1,24 @@
+package com.fizzy.android.domain.model
+
+import androidx.compose.ui.graphics.Color
+
+data class Tag(
+ val id: Long,
+ val name: String,
+ val color: String
+) {
+ val backgroundColor: Color
+ get() = try {
+ Color(android.graphics.Color.parseColor(color))
+ } catch (e: Exception) {
+ Color(0xFF6B7280) // Default gray
+ }
+
+ val textColor: Color
+ get() {
+ // Calculate luminance and return white or black
+ val bgColor = backgroundColor
+ val luminance = 0.299 * bgColor.red + 0.587 * bgColor.green + 0.114 * bgColor.blue
+ return if (luminance > 0.5) Color.Black else Color.White
+ }
+}
diff --git a/app/src/main/java/com/fizzy/android/domain/model/User.kt b/app/src/main/java/com/fizzy/android/domain/model/User.kt
new file mode 100644
index 0000000..1b945d6
--- /dev/null
+++ b/app/src/main/java/com/fizzy/android/domain/model/User.kt
@@ -0,0 +1,9 @@
+package com.fizzy.android.domain.model
+
+data class User(
+ val id: Long,
+ val name: String,
+ val email: String,
+ val avatarUrl: String? = null,
+ val admin: Boolean = false
+)
diff --git a/app/src/main/java/com/fizzy/android/domain/repository/AuthRepository.kt b/app/src/main/java/com/fizzy/android/domain/repository/AuthRepository.kt
new file mode 100644
index 0000000..10c554d
--- /dev/null
+++ b/app/src/main/java/com/fizzy/android/domain/repository/AuthRepository.kt
@@ -0,0 +1,23 @@
+package com.fizzy.android.domain.repository
+
+import com.fizzy.android.core.network.ApiResult
+import com.fizzy.android.domain.model.Account
+import com.fizzy.android.domain.model.User
+import kotlinx.coroutines.flow.Flow
+
+interface AuthRepository {
+ val isLoggedIn: Flow
+ val currentAccount: Flow
+ val allAccounts: Flow>
+
+ suspend fun requestMagicLink(instanceUrl: String, email: String): ApiResult
+ suspend fun verifyMagicLink(instanceUrl: String, email: String, code: String): ApiResult
+ suspend fun loginWithToken(instanceUrl: String, token: String): ApiResult
+ suspend fun getCurrentUser(): ApiResult
+
+ suspend fun switchAccount(accountId: String)
+ suspend fun logout(accountId: String)
+ suspend fun logoutAll()
+
+ suspend fun initializeActiveAccount()
+}
diff --git a/app/src/main/java/com/fizzy/android/domain/repository/BoardRepository.kt b/app/src/main/java/com/fizzy/android/domain/repository/BoardRepository.kt
new file mode 100644
index 0000000..29ba043
--- /dev/null
+++ b/app/src/main/java/com/fizzy/android/domain/repository/BoardRepository.kt
@@ -0,0 +1,31 @@
+package com.fizzy.android.domain.repository
+
+import com.fizzy.android.core.network.ApiResult
+import com.fizzy.android.domain.model.Board
+import com.fizzy.android.domain.model.Column
+import com.fizzy.android.domain.model.Tag
+import com.fizzy.android.domain.model.User
+import kotlinx.coroutines.flow.Flow
+
+interface BoardRepository {
+ fun observeBoards(): Flow>
+
+ suspend fun getBoards(): ApiResult>
+ suspend fun getBoard(boardId: String): ApiResult
+ suspend fun createBoard(name: String, description: String?): ApiResult
+ suspend fun updateBoard(boardId: String, name: String?, description: String?): ApiResult
+ suspend fun deleteBoard(boardId: String): ApiResult
+
+ suspend fun getColumns(boardId: String): ApiResult>
+ suspend fun createColumn(boardId: String, name: String, position: Int?): ApiResult
+ suspend fun updateColumn(boardId: String, columnId: String, name: String?, position: Int?): ApiResult
+ suspend fun deleteColumn(boardId: String, columnId: String): ApiResult
+
+ suspend fun getTags(boardId: String): ApiResult>
+ suspend fun createTag(boardId: String, name: String, color: String): ApiResult
+ suspend fun deleteTag(boardId: String, tagId: Long): ApiResult
+
+ suspend fun getBoardUsers(boardId: String): ApiResult>
+
+ suspend fun refreshBoards()
+}
diff --git a/app/src/main/java/com/fizzy/android/domain/repository/CardRepository.kt b/app/src/main/java/com/fizzy/android/domain/repository/CardRepository.kt
new file mode 100644
index 0000000..4e61e8e
--- /dev/null
+++ b/app/src/main/java/com/fizzy/android/domain/repository/CardRepository.kt
@@ -0,0 +1,48 @@
+package com.fizzy.android.domain.repository
+
+import com.fizzy.android.core.network.ApiResult
+import com.fizzy.android.domain.model.Card
+import com.fizzy.android.domain.model.Comment
+import com.fizzy.android.domain.model.Step
+import java.time.LocalDate
+
+interface CardRepository {
+ suspend fun getBoardCards(boardId: String): ApiResult>
+ suspend fun getCard(cardId: Long): ApiResult
+ suspend fun createCard(boardId: String, columnId: String, title: String, description: String?): ApiResult
+ suspend fun updateCard(cardId: Long, title: String?, description: String?): ApiResult
+ suspend fun deleteCard(cardId: Long): ApiResult
+ suspend fun moveCard(cardId: Long, columnId: String, position: Int): ApiResult
+
+ // Card actions
+ suspend fun closeCard(cardId: Long): ApiResult
+ suspend fun reopenCard(cardId: Long): ApiResult
+ suspend fun triageCard(cardId: Long, date: LocalDate): ApiResult
+ suspend fun deferCard(cardId: Long, date: LocalDate): ApiResult
+ suspend fun togglePriority(cardId: Long, priority: Boolean): ApiResult
+ suspend fun toggleWatch(cardId: Long, watching: Boolean): ApiResult
+
+ // Assignees
+ suspend fun addAssignee(cardId: Long, userId: Long): ApiResult
+ suspend fun removeAssignee(cardId: Long, userId: Long): ApiResult
+
+ // Tags
+ suspend fun addTag(cardId: Long, tagId: Long): ApiResult
+ suspend fun removeTag(cardId: Long, tagId: Long): ApiResult
+
+ // Steps
+ suspend fun getSteps(cardId: Long): ApiResult>
+ suspend fun createStep(cardId: Long, description: String): ApiResult
+ suspend fun updateStep(cardId: Long, stepId: Long, description: String?, completed: Boolean?, position: Int?): ApiResult
+ suspend fun deleteStep(cardId: Long, stepId: Long): ApiResult
+
+ // Comments
+ suspend fun getComments(cardId: Long): ApiResult>
+ suspend fun createComment(cardId: Long, content: String): ApiResult
+ suspend fun updateComment(cardId: Long, commentId: Long, content: String): ApiResult
+ suspend fun deleteComment(cardId: Long, commentId: Long): ApiResult
+
+ // Reactions
+ suspend fun addReaction(cardId: Long, commentId: Long, emoji: String): ApiResult
+ suspend fun removeReaction(cardId: Long, commentId: Long, emoji: String): ApiResult
+}
diff --git a/app/src/main/java/com/fizzy/android/domain/repository/NotificationRepository.kt b/app/src/main/java/com/fizzy/android/domain/repository/NotificationRepository.kt
new file mode 100644
index 0000000..3a21607
--- /dev/null
+++ b/app/src/main/java/com/fizzy/android/domain/repository/NotificationRepository.kt
@@ -0,0 +1,13 @@
+package com.fizzy.android.domain.repository
+
+import com.fizzy.android.core.network.ApiResult
+import com.fizzy.android.domain.model.Notification
+import kotlinx.coroutines.flow.Flow
+
+interface NotificationRepository {
+ val unreadCount: Flow
+
+ suspend fun getNotifications(): ApiResult>
+ suspend fun markAsRead(notificationId: Long): ApiResult
+ suspend fun markAllAsRead(): ApiResult
+}
diff --git a/app/src/main/java/com/fizzy/android/feature/auth/AuthScreen.kt b/app/src/main/java/com/fizzy/android/feature/auth/AuthScreen.kt
new file mode 100644
index 0000000..bdab1f6
--- /dev/null
+++ b/app/src/main/java/com/fizzy/android/feature/auth/AuthScreen.kt
@@ -0,0 +1,536 @@
+package com.fizzy.android.feature.auth
+
+import androidx.activity.compose.BackHandler
+import androidx.compose.animation.*
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.text.KeyboardActions
+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.*
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.platform.LocalFocusManager
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.text.input.PasswordVisualTransformation
+import androidx.compose.ui.text.input.VisualTransformation
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.hilt.navigation.compose.hiltViewModel
+import com.fizzy.android.core.ui.components.SmallLoadingIndicator
+import kotlinx.coroutines.flow.collectLatest
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun AuthScreen(
+ onAuthSuccess: () -> Unit,
+ viewModel: AuthViewModel = hiltViewModel()
+) {
+ val uiState by viewModel.uiState.collectAsState()
+
+ LaunchedEffect(Unit) {
+ viewModel.events.collectLatest { event ->
+ when (event) {
+ is AuthEvent.AuthSuccess -> onAuthSuccess()
+ is AuthEvent.ShowError -> { /* Error shown in UI state */ }
+ }
+ }
+ }
+
+ BackHandler(enabled = uiState.step != AuthStep.INSTANCE_SELECTION) {
+ viewModel.goBack()
+ }
+
+ Scaffold(
+ topBar = {
+ if (uiState.step != AuthStep.INSTANCE_SELECTION) {
+ TopAppBar(
+ title = { },
+ navigationIcon = {
+ IconButton(onClick = { viewModel.goBack() }) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Filled.ArrowBack,
+ contentDescription = "Back"
+ )
+ }
+ }
+ )
+ }
+ }
+ ) { paddingValues ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(paddingValues)
+ .verticalScroll(rememberScrollState())
+ .padding(24.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Spacer(modifier = Modifier.height(32.dp))
+
+ // Logo
+ Icon(
+ imageVector = Icons.Default.ViewKanban,
+ contentDescription = null,
+ modifier = Modifier.size(80.dp),
+ tint = MaterialTheme.colorScheme.primary
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ Text(
+ text = "Fizzy",
+ style = MaterialTheme.typography.headlineLarge,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.primary
+ )
+
+ Text(
+ text = "Kanban boards, simplified",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+
+ Spacer(modifier = Modifier.height(48.dp))
+
+ AnimatedContent(
+ targetState = uiState.step,
+ transitionSpec = {
+ slideInHorizontally { width -> width } + fadeIn() togetherWith
+ slideOutHorizontally { width -> -width } + fadeOut()
+ },
+ label = "auth_step"
+ ) { step ->
+ when (step) {
+ AuthStep.INSTANCE_SELECTION -> InstanceSelectionContent(
+ instanceUrl = uiState.instanceUrl,
+ onInstanceUrlChange = viewModel::onInstanceUrlChange,
+ onUseOfficial = viewModel::useOfficialInstance,
+ onContinue = viewModel::onContinueWithInstance,
+ error = uiState.error
+ )
+ AuthStep.EMAIL_INPUT -> EmailInputContent(
+ email = uiState.email,
+ onEmailChange = viewModel::onEmailChange,
+ onContinue = viewModel::requestMagicLink,
+ onToggleMethod = viewModel::toggleAuthMethod,
+ isLoading = uiState.isLoading,
+ error = uiState.error
+ )
+ AuthStep.CODE_VERIFICATION -> CodeVerificationContent(
+ email = uiState.email,
+ code = uiState.code,
+ onCodeChange = viewModel::onCodeChange,
+ onVerify = viewModel::verifyCode,
+ onResend = viewModel::requestMagicLink,
+ isLoading = uiState.isLoading,
+ error = uiState.error
+ )
+ AuthStep.PERSONAL_TOKEN -> PersonalTokenContent(
+ token = uiState.token,
+ onTokenChange = viewModel::onTokenChange,
+ onLogin = viewModel::loginWithToken,
+ onToggleMethod = viewModel::toggleAuthMethod,
+ isLoading = uiState.isLoading,
+ error = uiState.error
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun InstanceSelectionContent(
+ instanceUrl: String,
+ onInstanceUrlChange: (String) -> Unit,
+ onUseOfficial: () -> Unit,
+ onContinue: () -> Unit,
+ error: String?
+) {
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Text(
+ text = "Choose your instance",
+ style = MaterialTheme.typography.titleLarge,
+ textAlign = TextAlign.Center
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ Text(
+ text = "Connect to the official Fizzy service or your self-hosted instance",
+ style = MaterialTheme.typography.bodyMedium,
+ textAlign = TextAlign.Center,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+
+ Spacer(modifier = Modifier.height(32.dp))
+
+ Button(
+ onClick = onUseOfficial,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Icon(Icons.Default.Cloud, contentDescription = null)
+ Spacer(modifier = Modifier.width(8.dp))
+ Text("Use fizzy.com")
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
+
+ Text(
+ text = "Or connect to self-hosted",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ OutlinedTextField(
+ value = instanceUrl,
+ onValueChange = onInstanceUrlChange,
+ label = { Text("Instance URL") },
+ placeholder = { Text("https://fizzy.example.com") },
+ leadingIcon = { Icon(Icons.Default.Language, contentDescription = null) },
+ singleLine = true,
+ modifier = Modifier.fillMaxWidth(),
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.Uri,
+ imeAction = ImeAction.Go
+ ),
+ keyboardActions = KeyboardActions(onGo = { onContinue() }),
+ isError = error != null
+ )
+
+ if (error != null) {
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = error,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.error
+ )
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ OutlinedButton(
+ onClick = onContinue,
+ modifier = Modifier.fillMaxWidth(),
+ enabled = instanceUrl.isNotBlank()
+ ) {
+ Text("Continue")
+ }
+ }
+}
+
+@Composable
+private fun EmailInputContent(
+ email: String,
+ onEmailChange: (String) -> Unit,
+ onContinue: () -> Unit,
+ onToggleMethod: () -> Unit,
+ isLoading: Boolean,
+ error: String?
+) {
+ val focusManager = LocalFocusManager.current
+ val focusRequester = remember { FocusRequester() }
+
+ LaunchedEffect(Unit) {
+ focusRequester.requestFocus()
+ }
+
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Text(
+ text = "Sign in with email",
+ style = MaterialTheme.typography.titleLarge,
+ textAlign = TextAlign.Center
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ Text(
+ text = "We'll send you a magic link to sign in",
+ style = MaterialTheme.typography.bodyMedium,
+ textAlign = TextAlign.Center,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+
+ Spacer(modifier = Modifier.height(32.dp))
+
+ OutlinedTextField(
+ value = email,
+ onValueChange = onEmailChange,
+ label = { Text("Email address") },
+ placeholder = { Text("you@example.com") },
+ leadingIcon = { Icon(Icons.Default.Email, contentDescription = null) },
+ singleLine = true,
+ modifier = Modifier
+ .fillMaxWidth()
+ .focusRequester(focusRequester),
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.Email,
+ imeAction = ImeAction.Go
+ ),
+ keyboardActions = KeyboardActions(
+ onGo = {
+ focusManager.clearFocus()
+ onContinue()
+ }
+ ),
+ isError = error != null,
+ enabled = !isLoading
+ )
+
+ if (error != null) {
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = error,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.error
+ )
+ }
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ Button(
+ onClick = {
+ focusManager.clearFocus()
+ onContinue()
+ },
+ modifier = Modifier.fillMaxWidth(),
+ enabled = email.isNotBlank() && !isLoading
+ ) {
+ if (isLoading) {
+ SmallLoadingIndicator()
+ } else {
+ Text("Send Magic Link")
+ }
+ }
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ TextButton(onClick = onToggleMethod) {
+ Text("Use Personal Access Token instead")
+ }
+ }
+}
+
+@Composable
+private fun CodeVerificationContent(
+ email: String,
+ code: String,
+ onCodeChange: (String) -> Unit,
+ onVerify: () -> Unit,
+ onResend: () -> Unit,
+ isLoading: Boolean,
+ error: String?
+) {
+ val focusManager = LocalFocusManager.current
+ val focusRequester = remember { FocusRequester() }
+
+ LaunchedEffect(Unit) {
+ focusRequester.requestFocus()
+ }
+
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Text(
+ text = "Enter your code",
+ style = MaterialTheme.typography.titleLarge,
+ textAlign = TextAlign.Center
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ Text(
+ text = "We sent a 6-character code to\n$email",
+ style = MaterialTheme.typography.bodyMedium,
+ textAlign = TextAlign.Center,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+
+ Spacer(modifier = Modifier.height(32.dp))
+
+ OutlinedTextField(
+ value = code,
+ onValueChange = onCodeChange,
+ label = { Text("Verification code") },
+ placeholder = { Text("XXXXXX") },
+ leadingIcon = { Icon(Icons.Default.Key, contentDescription = null) },
+ singleLine = true,
+ modifier = Modifier
+ .fillMaxWidth()
+ .focusRequester(focusRequester),
+ textStyle = MaterialTheme.typography.headlineSmall.copy(
+ textAlign = TextAlign.Center,
+ letterSpacing = 8.sp
+ ),
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.Text,
+ imeAction = ImeAction.Go
+ ),
+ keyboardActions = KeyboardActions(
+ onGo = {
+ focusManager.clearFocus()
+ onVerify()
+ }
+ ),
+ isError = error != null,
+ enabled = !isLoading
+ )
+
+ if (error != null) {
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = error,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.error
+ )
+ }
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ Button(
+ onClick = {
+ focusManager.clearFocus()
+ onVerify()
+ },
+ modifier = Modifier.fillMaxWidth(),
+ enabled = code.length == 6 && !isLoading
+ ) {
+ if (isLoading) {
+ SmallLoadingIndicator()
+ } else {
+ Text("Verify")
+ }
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ TextButton(onClick = onResend, enabled = !isLoading) {
+ Text("Resend code")
+ }
+ }
+}
+
+@Composable
+private fun PersonalTokenContent(
+ token: String,
+ onTokenChange: (String) -> Unit,
+ onLogin: () -> Unit,
+ onToggleMethod: () -> Unit,
+ isLoading: Boolean,
+ error: String?
+) {
+ var showToken by remember { mutableStateOf(false) }
+ val focusManager = LocalFocusManager.current
+ val focusRequester = remember { FocusRequester() }
+
+ LaunchedEffect(Unit) {
+ focusRequester.requestFocus()
+ }
+
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Text(
+ text = "Personal Access Token",
+ style = MaterialTheme.typography.titleLarge,
+ textAlign = TextAlign.Center
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ Text(
+ text = "Enter your Personal Access Token to sign in",
+ style = MaterialTheme.typography.bodyMedium,
+ textAlign = TextAlign.Center,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+
+ Spacer(modifier = Modifier.height(32.dp))
+
+ OutlinedTextField(
+ value = token,
+ onValueChange = onTokenChange,
+ label = { Text("Access Token") },
+ leadingIcon = { Icon(Icons.Default.VpnKey, contentDescription = null) },
+ trailingIcon = {
+ IconButton(onClick = { showToken = !showToken }) {
+ Icon(
+ imageVector = if (showToken) Icons.Default.VisibilityOff else Icons.Default.Visibility,
+ contentDescription = if (showToken) "Hide token" else "Show token"
+ )
+ }
+ },
+ singleLine = true,
+ modifier = Modifier
+ .fillMaxWidth()
+ .focusRequester(focusRequester),
+ visualTransformation = if (showToken) VisualTransformation.None else PasswordVisualTransformation(),
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.Password,
+ imeAction = ImeAction.Go
+ ),
+ keyboardActions = KeyboardActions(
+ onGo = {
+ focusManager.clearFocus()
+ onLogin()
+ }
+ ),
+ isError = error != null,
+ enabled = !isLoading
+ )
+
+ if (error != null) {
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = error,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.error
+ )
+ }
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ Button(
+ onClick = {
+ focusManager.clearFocus()
+ onLogin()
+ },
+ modifier = Modifier.fillMaxWidth(),
+ enabled = token.isNotBlank() && !isLoading
+ ) {
+ if (isLoading) {
+ SmallLoadingIndicator()
+ } else {
+ Text("Sign In")
+ }
+ }
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ TextButton(onClick = onToggleMethod) {
+ Text("Use Magic Link instead")
+ }
+ }
+}
diff --git a/app/src/main/java/com/fizzy/android/feature/auth/AuthViewModel.kt b/app/src/main/java/com/fizzy/android/feature/auth/AuthViewModel.kt
new file mode 100644
index 0000000..7a8b841
--- /dev/null
+++ b/app/src/main/java/com/fizzy/android/feature/auth/AuthViewModel.kt
@@ -0,0 +1,243 @@
+package com.fizzy.android.feature.auth
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.fizzy.android.core.network.ApiResult
+import com.fizzy.android.core.network.InstanceManager
+import com.fizzy.android.domain.repository.AuthRepository
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.*
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+data class AuthUiState(
+ val step: AuthStep = AuthStep.INSTANCE_SELECTION,
+ val instanceUrl: String = InstanceManager.OFFICIAL_INSTANCE,
+ val email: String = "",
+ val code: String = "",
+ val token: String = "",
+ val isLoading: Boolean = false,
+ val error: String? = null,
+ val usePersonalToken: Boolean = false
+)
+
+enum class AuthStep {
+ INSTANCE_SELECTION,
+ EMAIL_INPUT,
+ CODE_VERIFICATION,
+ PERSONAL_TOKEN
+}
+
+sealed class AuthEvent {
+ data object AuthSuccess : AuthEvent()
+ data class ShowError(val message: String) : AuthEvent()
+}
+
+@HiltViewModel
+class AuthViewModel @Inject constructor(
+ private val authRepository: AuthRepository
+) : ViewModel() {
+
+ private val _uiState = MutableStateFlow(AuthUiState())
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ private val _events = MutableSharedFlow()
+ val events: SharedFlow = _events.asSharedFlow()
+
+ val isLoggedIn: StateFlow = authRepository.isLoggedIn
+ .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
+
+ fun initializeAuth() {
+ viewModelScope.launch {
+ authRepository.initializeActiveAccount()
+ }
+ }
+
+ fun onInstanceUrlChange(url: String) {
+ _uiState.update { it.copy(instanceUrl = url, error = null) }
+ }
+
+ fun onEmailChange(email: String) {
+ _uiState.update { it.copy(email = email, error = null) }
+ }
+
+ fun onCodeChange(code: String) {
+ _uiState.update { it.copy(code = code.uppercase().take(6), error = null) }
+ }
+
+ fun onTokenChange(token: String) {
+ _uiState.update { it.copy(token = token, error = null) }
+ }
+
+ fun useOfficialInstance() {
+ _uiState.update {
+ it.copy(
+ instanceUrl = InstanceManager.OFFICIAL_INSTANCE,
+ step = AuthStep.EMAIL_INPUT,
+ error = null
+ )
+ }
+ }
+
+ fun useSelfHosted() {
+ _uiState.update {
+ it.copy(
+ instanceUrl = "",
+ error = null
+ )
+ }
+ }
+
+ fun onContinueWithInstance() {
+ val url = _uiState.value.instanceUrl.trim()
+ if (url.isBlank()) {
+ _uiState.update { it.copy(error = "Please enter a valid instance URL") }
+ return
+ }
+ _uiState.update { it.copy(step = AuthStep.EMAIL_INPUT, error = null) }
+ }
+
+ fun toggleAuthMethod() {
+ _uiState.update {
+ it.copy(
+ usePersonalToken = !it.usePersonalToken,
+ step = if (!it.usePersonalToken) AuthStep.PERSONAL_TOKEN else AuthStep.EMAIL_INPUT,
+ error = null
+ )
+ }
+ }
+
+ fun requestMagicLink() {
+ val email = _uiState.value.email.trim()
+ if (email.isBlank() || !email.contains("@")) {
+ _uiState.update { it.copy(error = "Please enter a valid email address") }
+ return
+ }
+
+ viewModelScope.launch {
+ _uiState.update { it.copy(isLoading = true, error = null) }
+
+ when (val result = authRepository.requestMagicLink(_uiState.value.instanceUrl, email)) {
+ is ApiResult.Success -> {
+ _uiState.update {
+ it.copy(
+ isLoading = false,
+ step = AuthStep.CODE_VERIFICATION
+ )
+ }
+ }
+ is ApiResult.Error -> {
+ _uiState.update {
+ it.copy(
+ isLoading = false,
+ error = "Failed to send magic link: ${result.message}"
+ )
+ }
+ }
+ is ApiResult.Exception -> {
+ _uiState.update {
+ it.copy(
+ isLoading = false,
+ error = "Network error. Please check your connection."
+ )
+ }
+ }
+ }
+ }
+ }
+
+ fun verifyCode() {
+ val code = _uiState.value.code.trim()
+ if (code.length != 6) {
+ _uiState.update { it.copy(error = "Please enter the 6-character code") }
+ return
+ }
+
+ viewModelScope.launch {
+ _uiState.update { it.copy(isLoading = true, error = null) }
+
+ when (val result = authRepository.verifyMagicLink(
+ _uiState.value.instanceUrl,
+ _uiState.value.email,
+ code
+ )) {
+ is ApiResult.Success -> {
+ _uiState.update { it.copy(isLoading = false) }
+ _events.emit(AuthEvent.AuthSuccess)
+ }
+ is ApiResult.Error -> {
+ _uiState.update {
+ it.copy(
+ isLoading = false,
+ error = if (result.code == 401) "Invalid code. Please try again." else "Verification failed: ${result.message}"
+ )
+ }
+ }
+ is ApiResult.Exception -> {
+ _uiState.update {
+ it.copy(
+ isLoading = false,
+ error = "Network error. Please check your connection."
+ )
+ }
+ }
+ }
+ }
+ }
+
+ fun loginWithToken() {
+ val token = _uiState.value.token.trim()
+ if (token.isBlank()) {
+ _uiState.update { it.copy(error = "Please enter your personal access token") }
+ return
+ }
+
+ viewModelScope.launch {
+ _uiState.update { it.copy(isLoading = true, error = null) }
+
+ when (val result = authRepository.loginWithToken(_uiState.value.instanceUrl, token)) {
+ is ApiResult.Success -> {
+ _uiState.update { it.copy(isLoading = false) }
+ _events.emit(AuthEvent.AuthSuccess)
+ }
+ is ApiResult.Error -> {
+ _uiState.update {
+ it.copy(
+ isLoading = false,
+ error = result.message ?: "Invalid token or authentication failed"
+ )
+ }
+ }
+ is ApiResult.Exception -> {
+ _uiState.update {
+ it.copy(
+ isLoading = false,
+ error = "Network error. Please check your connection."
+ )
+ }
+ }
+ }
+ }
+ }
+
+ fun goBack() {
+ _uiState.update { state ->
+ when (state.step) {
+ AuthStep.EMAIL_INPUT, AuthStep.PERSONAL_TOKEN -> state.copy(
+ step = AuthStep.INSTANCE_SELECTION,
+ error = null
+ )
+ AuthStep.CODE_VERIFICATION -> state.copy(
+ step = AuthStep.EMAIL_INPUT,
+ code = "",
+ error = null
+ )
+ else -> state
+ }
+ }
+ }
+
+ fun clearError() {
+ _uiState.update { it.copy(error = null) }
+ }
+}
diff --git a/app/src/main/java/com/fizzy/android/feature/boards/BoardListScreen.kt b/app/src/main/java/com/fizzy/android/feature/boards/BoardListScreen.kt
new file mode 100644
index 0000000..79a456e
--- /dev/null
+++ b/app/src/main/java/com/fizzy/android/feature/boards/BoardListScreen.kt
@@ -0,0 +1,426 @@
+package com.fizzy.android.feature.boards
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.*
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import com.fizzy.android.core.ui.components.EmptyState
+import com.fizzy.android.core.ui.components.ErrorMessage
+import com.fizzy.android.core.ui.components.LoadingIndicator
+import com.fizzy.android.domain.model.Board
+import kotlinx.coroutines.flow.collectLatest
+import java.time.format.DateTimeFormatter
+import java.time.format.FormatStyle
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun BoardListScreen(
+ onBoardClick: (String) -> Unit,
+ onNotificationsClick: () -> Unit,
+ onSettingsClick: () -> Unit,
+ viewModel: BoardListViewModel = hiltViewModel()
+) {
+ val uiState by viewModel.uiState.collectAsState()
+ val unreadCount by viewModel.unreadNotificationsCount.collectAsState()
+ val snackbarHostState = remember { SnackbarHostState() }
+ var showSearch by remember { mutableStateOf(false) }
+
+ LaunchedEffect(Unit) {
+ viewModel.events.collectLatest { event ->
+ when (event) {
+ is BoardListEvent.NavigateToBoard -> onBoardClick(event.boardId)
+ is BoardListEvent.ShowError -> snackbarHostState.showSnackbar(event.message)
+ BoardListEvent.BoardCreated -> snackbarHostState.showSnackbar("Board created")
+ BoardListEvent.BoardUpdated -> snackbarHostState.showSnackbar("Board updated")
+ BoardListEvent.BoardDeleted -> snackbarHostState.showSnackbar("Board deleted")
+ }
+ }
+ }
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = {
+ if (showSearch) {
+ OutlinedTextField(
+ value = uiState.searchQuery,
+ onValueChange = viewModel::onSearchQueryChange,
+ placeholder = { Text("Search boards...") },
+ singleLine = true,
+ modifier = Modifier.fillMaxWidth(),
+ colors = OutlinedTextFieldDefaults.colors(
+ focusedBorderColor = MaterialTheme.colorScheme.primary,
+ unfocusedBorderColor = MaterialTheme.colorScheme.outline
+ )
+ )
+ } else {
+ Text("Boards")
+ }
+ },
+ actions = {
+ if (showSearch) {
+ IconButton(onClick = {
+ showSearch = false
+ viewModel.clearSearch()
+ }) {
+ Icon(Icons.Default.Close, contentDescription = "Close search")
+ }
+ } else {
+ IconButton(onClick = { showSearch = true }) {
+ Icon(Icons.Default.Search, contentDescription = "Search")
+ }
+
+ IconButton(onClick = viewModel::refresh) {
+ Icon(Icons.Default.Refresh, contentDescription = "Refresh")
+ }
+
+ BadgedBox(
+ badge = {
+ if (unreadCount > 0) {
+ Badge { Text(unreadCount.toString()) }
+ }
+ }
+ ) {
+ IconButton(onClick = onNotificationsClick) {
+ Icon(Icons.Default.Notifications, contentDescription = "Notifications")
+ }
+ }
+
+ IconButton(onClick = onSettingsClick) {
+ Icon(Icons.Default.Settings, contentDescription = "Settings")
+ }
+ }
+ }
+ )
+ },
+ floatingActionButton = {
+ FloatingActionButton(
+ onClick = { viewModel.showCreateDialog() },
+ containerColor = MaterialTheme.colorScheme.primary
+ ) {
+ Icon(Icons.Default.Add, contentDescription = "Create board")
+ }
+ },
+ snackbarHost = { SnackbarHost(snackbarHostState) }
+ ) { paddingValues ->
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(paddingValues)
+ ) {
+ when {
+ uiState.isLoading && uiState.boards.isEmpty() -> {
+ LoadingIndicator()
+ }
+ uiState.error != null && uiState.boards.isEmpty() -> {
+ ErrorMessage(
+ message = uiState.error ?: "Unknown error",
+ onRetry = viewModel::loadBoards
+ )
+ }
+ uiState.filteredBoards.isEmpty() && uiState.searchQuery.isNotEmpty() -> {
+ EmptyState(
+ icon = Icons.Default.SearchOff,
+ title = "No boards found",
+ description = "Try a different search term"
+ )
+ }
+ uiState.boards.isEmpty() -> {
+ EmptyState(
+ icon = Icons.Default.ViewKanban,
+ title = "No boards yet",
+ description = "Create your first board to get started",
+ action = {
+ Button(onClick = { viewModel.showCreateDialog() }) {
+ Icon(Icons.Default.Add, contentDescription = null)
+ Spacer(modifier = Modifier.width(8.dp))
+ Text("Create Board")
+ }
+ }
+ )
+ }
+ else -> {
+ BoardList(
+ boards = uiState.filteredBoards,
+ onBoardClick = onBoardClick,
+ onEditClick = viewModel::showEditDialog,
+ onDeleteClick = viewModel::showDeleteConfirmation
+ )
+ }
+ }
+
+ // Show loading indicator when refreshing
+ if (uiState.isRefreshing) {
+ CircularProgressIndicator(
+ modifier = Modifier
+ .align(Alignment.TopCenter)
+ .padding(16.dp)
+ )
+ }
+ }
+ }
+
+ // Create Board Dialog
+ if (uiState.showCreateDialog) {
+ BoardDialog(
+ title = "Create Board",
+ initialName = "",
+ initialDescription = "",
+ onDismiss = viewModel::hideCreateDialog,
+ onConfirm = { name, description ->
+ viewModel.createBoard(name, description)
+ },
+ isLoading = uiState.isLoading
+ )
+ }
+
+ // Edit Board Dialog
+ uiState.showEditDialog?.let { board ->
+ BoardDialog(
+ title = "Edit Board",
+ initialName = board.name,
+ initialDescription = board.description ?: "",
+ onDismiss = viewModel::hideEditDialog,
+ onConfirm = { name, description ->
+ viewModel.updateBoard(board.id, name, description)
+ },
+ isLoading = uiState.isLoading
+ )
+ }
+
+ // Delete Confirmation Dialog
+ uiState.showDeleteConfirmation?.let { board ->
+ AlertDialog(
+ onDismissRequest = viewModel::hideDeleteConfirmation,
+ title = { Text("Delete Board") },
+ text = { Text("Are you sure you want to delete \"${board.name}\"? This action cannot be undone.") },
+ confirmButton = {
+ TextButton(
+ onClick = { viewModel.deleteBoard(board.id) },
+ colors = ButtonDefaults.textButtonColors(
+ contentColor = MaterialTheme.colorScheme.error
+ )
+ ) {
+ Text("Delete")
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = viewModel::hideDeleteConfirmation) {
+ Text("Cancel")
+ }
+ }
+ )
+ }
+}
+
+@Composable
+private fun BoardList(
+ boards: List,
+ onBoardClick: (String) -> Unit,
+ onEditClick: (Board) -> Unit,
+ onDeleteClick: (Board) -> Unit
+) {
+ LazyColumn(
+ contentPadding = PaddingValues(16.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ items(boards, key = { it.id }) { board ->
+ BoardCard(
+ board = board,
+ onClick = { onBoardClick(board.id) },
+ onEditClick = { onEditClick(board) },
+ onDeleteClick = { onDeleteClick(board) }
+ )
+ }
+ }
+}
+
+@Composable
+private fun BoardCard(
+ board: Board,
+ onClick: () -> Unit,
+ onEditClick: () -> Unit,
+ onDeleteClick: () -> Unit
+) {
+ var showMenu by remember { mutableStateOf(false) }
+
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable(onClick = onClick),
+ elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
+ ) {
+ Column(
+ modifier = Modifier.padding(16.dp)
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = board.name,
+ style = MaterialTheme.typography.titleMedium,
+ modifier = Modifier.weight(1f)
+ )
+
+ Box {
+ IconButton(onClick = { showMenu = true }) {
+ Icon(
+ Icons.Default.MoreVert,
+ contentDescription = "More options"
+ )
+ }
+
+ DropdownMenu(
+ expanded = showMenu,
+ onDismissRequest = { showMenu = false }
+ ) {
+ DropdownMenuItem(
+ text = { Text("Edit") },
+ leadingIcon = { Icon(Icons.Default.Edit, contentDescription = null) },
+ onClick = {
+ showMenu = false
+ onEditClick()
+ }
+ )
+ DropdownMenuItem(
+ text = { Text("Delete") },
+ leadingIcon = {
+ Icon(
+ Icons.Default.Delete,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.error
+ )
+ },
+ onClick = {
+ showMenu = false
+ onDeleteClick()
+ }
+ )
+ }
+ }
+ }
+
+ if (!board.description.isNullOrBlank()) {
+ Text(
+ text = board.description,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ }
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Icon(
+ Icons.Default.ViewColumn,
+ contentDescription = null,
+ modifier = Modifier.size(16.dp),
+ tint = MaterialTheme.colorScheme.outline
+ )
+ Spacer(modifier = Modifier.width(4.dp))
+ Text(
+ text = "${board.columnsCount} columns",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.outline
+ )
+ }
+
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Icon(
+ Icons.Default.CreditCard,
+ contentDescription = null,
+ modifier = Modifier.size(16.dp),
+ tint = MaterialTheme.colorScheme.outline
+ )
+ Spacer(modifier = Modifier.width(4.dp))
+ Text(
+ text = "${board.cardsCount} cards",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.outline
+ )
+ }
+
+ Spacer(modifier = Modifier.weight(1f))
+
+ Text(
+ text = (board.updatedAt ?: board.createdAt).atZone(java.time.ZoneId.systemDefault())
+ .format(DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.outline
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun BoardDialog(
+ title: String,
+ initialName: String,
+ initialDescription: String,
+ onDismiss: () -> Unit,
+ onConfirm: (name: String, description: String?) -> Unit,
+ isLoading: Boolean
+) {
+ var name by remember { mutableStateOf(initialName) }
+ var description by remember { mutableStateOf(initialDescription) }
+
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ title = { Text(title) },
+ text = {
+ Column {
+ OutlinedTextField(
+ value = name,
+ onValueChange = { name = it },
+ label = { Text("Name") },
+ singleLine = true,
+ modifier = Modifier.fillMaxWidth(),
+ enabled = !isLoading
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ OutlinedTextField(
+ value = description,
+ onValueChange = { description = it },
+ label = { Text("Description (optional)") },
+ modifier = Modifier.fillMaxWidth(),
+ minLines = 2,
+ maxLines = 4,
+ enabled = !isLoading
+ )
+ }
+ },
+ confirmButton = {
+ TextButton(
+ onClick = { onConfirm(name, description) },
+ enabled = name.isNotBlank() && !isLoading
+ ) {
+ Text(if (initialName.isEmpty()) "Create" else "Save")
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = onDismiss, enabled = !isLoading) {
+ Text("Cancel")
+ }
+ }
+ )
+}
diff --git a/app/src/main/java/com/fizzy/android/feature/boards/BoardListViewModel.kt b/app/src/main/java/com/fizzy/android/feature/boards/BoardListViewModel.kt
new file mode 100644
index 0000000..1d356ba
--- /dev/null
+++ b/app/src/main/java/com/fizzy/android/feature/boards/BoardListViewModel.kt
@@ -0,0 +1,219 @@
+package com.fizzy.android.feature.boards
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.fizzy.android.core.network.ApiResult
+import com.fizzy.android.domain.model.Board
+import com.fizzy.android.domain.repository.BoardRepository
+import com.fizzy.android.domain.repository.NotificationRepository
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.*
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+data class BoardListUiState(
+ val boards: List = emptyList(),
+ val filteredBoards: List = emptyList(),
+ val searchQuery: String = "",
+ val isLoading: Boolean = false,
+ val isRefreshing: Boolean = false,
+ val error: String? = null,
+ val showCreateDialog: Boolean = false,
+ val showEditDialog: Board? = null,
+ val showDeleteConfirmation: Board? = null
+)
+
+sealed class BoardListEvent {
+ data class NavigateToBoard(val boardId: String) : BoardListEvent()
+ data class ShowError(val message: String) : BoardListEvent()
+ data object BoardCreated : BoardListEvent()
+ data object BoardUpdated : BoardListEvent()
+ data object BoardDeleted : BoardListEvent()
+}
+
+@HiltViewModel
+class BoardListViewModel @Inject constructor(
+ private val boardRepository: BoardRepository,
+ private val notificationRepository: NotificationRepository
+) : ViewModel() {
+
+ private val _uiState = MutableStateFlow(BoardListUiState())
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ private val _events = MutableSharedFlow()
+ val events: SharedFlow = _events.asSharedFlow()
+
+ val unreadNotificationsCount: StateFlow = notificationRepository.unreadCount
+ .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0)
+
+ init {
+ loadBoards()
+ observeBoards()
+ }
+
+ private fun observeBoards() {
+ viewModelScope.launch {
+ boardRepository.observeBoards().collect { boards ->
+ _uiState.update { state ->
+ state.copy(
+ boards = boards,
+ filteredBoards = filterBoards(boards, state.searchQuery)
+ )
+ }
+ }
+ }
+ }
+
+ fun loadBoards() {
+ viewModelScope.launch {
+ _uiState.update { it.copy(isLoading = true, error = null) }
+
+ when (val result = boardRepository.getBoards()) {
+ is ApiResult.Success -> {
+ _uiState.update { it.copy(isLoading = false) }
+ }
+ is ApiResult.Error -> {
+ _uiState.update {
+ it.copy(
+ isLoading = false,
+ error = "Failed to load boards: ${result.message}"
+ )
+ }
+ }
+ is ApiResult.Exception -> {
+ _uiState.update {
+ it.copy(
+ isLoading = false,
+ error = "Network error. Please check your connection."
+ )
+ }
+ }
+ }
+ }
+ }
+
+ fun refresh() {
+ viewModelScope.launch {
+ _uiState.update { it.copy(isRefreshing = true) }
+
+ boardRepository.refreshBoards()
+ notificationRepository.getNotifications()
+
+ _uiState.update { it.copy(isRefreshing = false) }
+ }
+ }
+
+ fun onSearchQueryChange(query: String) {
+ _uiState.update { state ->
+ state.copy(
+ searchQuery = query,
+ filteredBoards = filterBoards(state.boards, query)
+ )
+ }
+ }
+
+ fun clearSearch() {
+ _uiState.update { state ->
+ state.copy(
+ searchQuery = "",
+ filteredBoards = state.boards
+ )
+ }
+ }
+
+ private fun filterBoards(boards: List, query: String): List {
+ if (query.isBlank()) return boards
+ return boards.filter { board ->
+ board.name.contains(query, ignoreCase = true) ||
+ board.description?.contains(query, ignoreCase = true) == true
+ }
+ }
+
+ fun showCreateDialog() {
+ _uiState.update { it.copy(showCreateDialog = true) }
+ }
+
+ fun hideCreateDialog() {
+ _uiState.update { it.copy(showCreateDialog = false) }
+ }
+
+ fun createBoard(name: String, description: String?) {
+ viewModelScope.launch {
+ _uiState.update { it.copy(isLoading = true) }
+
+ when (val result = boardRepository.createBoard(name, description?.takeIf { it.isNotBlank() })) {
+ is ApiResult.Success -> {
+ _uiState.update { it.copy(isLoading = false, showCreateDialog = false) }
+ _events.emit(BoardListEvent.BoardCreated)
+ _events.emit(BoardListEvent.NavigateToBoard(result.data.id))
+ }
+ is ApiResult.Error -> {
+ _uiState.update { it.copy(isLoading = false) }
+ _events.emit(BoardListEvent.ShowError("Failed to create board: ${result.message}"))
+ }
+ is ApiResult.Exception -> {
+ _uiState.update { it.copy(isLoading = false) }
+ _events.emit(BoardListEvent.ShowError("Network error"))
+ }
+ }
+ }
+ }
+
+ fun showEditDialog(board: Board) {
+ _uiState.update { it.copy(showEditDialog = board) }
+ }
+
+ fun hideEditDialog() {
+ _uiState.update { it.copy(showEditDialog = null) }
+ }
+
+ fun updateBoard(boardId: String, name: String, description: String?) {
+ viewModelScope.launch {
+ _uiState.update { it.copy(isLoading = true) }
+
+ when (boardRepository.updateBoard(boardId, name, description?.takeIf { it.isNotBlank() })) {
+ is ApiResult.Success -> {
+ _uiState.update { it.copy(isLoading = false, showEditDialog = null) }
+ _events.emit(BoardListEvent.BoardUpdated)
+ }
+ is ApiResult.Error -> {
+ _uiState.update { it.copy(isLoading = false) }
+ _events.emit(BoardListEvent.ShowError("Failed to update board"))
+ }
+ is ApiResult.Exception -> {
+ _uiState.update { it.copy(isLoading = false) }
+ _events.emit(BoardListEvent.ShowError("Network error"))
+ }
+ }
+ }
+ }
+
+ fun showDeleteConfirmation(board: Board) {
+ _uiState.update { it.copy(showDeleteConfirmation = board) }
+ }
+
+ fun hideDeleteConfirmation() {
+ _uiState.update { it.copy(showDeleteConfirmation = null) }
+ }
+
+ fun deleteBoard(boardId: String) {
+ viewModelScope.launch {
+ _uiState.update { it.copy(isLoading = true) }
+
+ when (boardRepository.deleteBoard(boardId)) {
+ is ApiResult.Success -> {
+ _uiState.update { it.copy(isLoading = false, showDeleteConfirmation = null) }
+ _events.emit(BoardListEvent.BoardDeleted)
+ }
+ is ApiResult.Error -> {
+ _uiState.update { it.copy(isLoading = false) }
+ _events.emit(BoardListEvent.ShowError("Failed to delete board"))
+ }
+ is ApiResult.Exception -> {
+ _uiState.update { it.copy(isLoading = false) }
+ _events.emit(BoardListEvent.ShowError("Network error"))
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/fizzy/android/feature/card/CardDetailScreen.kt b/app/src/main/java/com/fizzy/android/feature/card/CardDetailScreen.kt
new file mode 100644
index 0000000..76a63e5
--- /dev/null
+++ b/app/src/main/java/com/fizzy/android/feature/card/CardDetailScreen.kt
@@ -0,0 +1,1058 @@
+package com.fizzy.android.feature.card
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+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.*
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import com.fizzy.android.core.ui.components.ErrorMessage
+import com.fizzy.android.core.ui.components.LoadingIndicator
+import com.fizzy.android.core.ui.theme.FizzyGold
+import com.fizzy.android.domain.model.*
+import kotlinx.coroutines.flow.collectLatest
+import java.time.ZoneId
+import java.time.format.DateTimeFormatter
+import java.time.format.FormatStyle
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun CardDetailScreen(
+ cardId: Long,
+ onBackClick: () -> Unit,
+ viewModel: CardDetailViewModel = hiltViewModel()
+) {
+ val uiState by viewModel.uiState.collectAsState()
+ val snackbarHostState = remember { SnackbarHostState() }
+
+ LaunchedEffect(Unit) {
+ viewModel.events.collectLatest { event ->
+ when (event) {
+ is CardDetailEvent.ShowError -> snackbarHostState.showSnackbar(event.message)
+ CardDetailEvent.CardUpdated -> snackbarHostState.showSnackbar("Card updated")
+ CardDetailEvent.CardClosed -> snackbarHostState.showSnackbar("Card closed")
+ CardDetailEvent.CardReopened -> snackbarHostState.showSnackbar("Card reopened")
+ CardDetailEvent.NavigateBack -> onBackClick()
+ }
+ }
+ }
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { },
+ navigationIcon = {
+ IconButton(onClick = onBackClick) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
+ }
+ },
+ actions = {
+ if (!uiState.isEditing) {
+ IconButton(onClick = viewModel::startEditing) {
+ Icon(Icons.Default.Edit, contentDescription = "Edit")
+ }
+ }
+ }
+ )
+ },
+ snackbarHost = { SnackbarHost(snackbarHostState) }
+ ) { paddingValues ->
+ when {
+ uiState.isLoading && uiState.card == null -> {
+ LoadingIndicator(modifier = Modifier.padding(paddingValues))
+ }
+ uiState.error != null && uiState.card == null -> {
+ ErrorMessage(
+ message = uiState.error ?: "Unknown error",
+ onRetry = viewModel::loadCard,
+ modifier = Modifier.padding(paddingValues)
+ )
+ }
+ uiState.card != null -> {
+ if (uiState.isEditing) {
+ CardEditContent(
+ title = uiState.editTitle,
+ description = uiState.editDescription,
+ onTitleChange = viewModel::onTitleChange,
+ onDescriptionChange = viewModel::onDescriptionChange,
+ onSave = viewModel::saveChanges,
+ onCancel = viewModel::cancelEditing,
+ isLoading = uiState.isLoading,
+ modifier = Modifier.padding(paddingValues)
+ )
+ } else {
+ CardDetailContent(
+ card = uiState.card!!,
+ steps = uiState.steps,
+ comments = uiState.comments,
+ boardTags = uiState.boardTags,
+ boardUsers = uiState.boardUsers,
+ selectedTab = uiState.selectedTab,
+ onTabSelect = viewModel::selectTab,
+ onTogglePriority = viewModel::togglePriority,
+ onToggleWatch = viewModel::toggleWatch,
+ onClose = viewModel::closeCard,
+ onReopen = viewModel::reopenCard,
+ onTriage = viewModel::showTriageDatePicker,
+ onDefer = viewModel::showDeferDatePicker,
+ onDelete = viewModel::deleteCard,
+ onAddTag = viewModel::showTagPicker,
+ onRemoveTag = viewModel::removeTag,
+ onAddAssignee = viewModel::showAssigneePicker,
+ onRemoveAssignee = viewModel::removeAssignee,
+ onAddStep = viewModel::showAddStepDialog,
+ onToggleStep = viewModel::toggleStepCompleted,
+ onDeleteStep = viewModel::deleteStep,
+ onAddComment = viewModel::showAddCommentDialog,
+ onDeleteComment = viewModel::deleteComment,
+ onAddReaction = viewModel::showEmojiPicker,
+ onRemoveReaction = viewModel::removeReaction,
+ modifier = Modifier.padding(paddingValues)
+ )
+ }
+ }
+ }
+ }
+
+ // Dialogs
+ if (uiState.showAddStepDialog) {
+ TextInputDialog(
+ title = "Add Step",
+ label = "Step description",
+ onDismiss = viewModel::hideAddStepDialog,
+ onConfirm = viewModel::createStep
+ )
+ }
+
+ if (uiState.showAddCommentDialog) {
+ TextInputDialog(
+ title = "Add Comment",
+ label = "Comment",
+ multiline = true,
+ onDismiss = viewModel::hideAddCommentDialog,
+ onConfirm = viewModel::createComment
+ )
+ }
+
+ if (uiState.showTagPicker) {
+ TagPickerDialog(
+ availableTags = uiState.boardTags,
+ selectedTags = uiState.card?.tags ?: emptyList(),
+ onDismiss = viewModel::hideTagPicker,
+ onTagToggle = { tag ->
+ if (uiState.card?.tags?.any { it.id == tag.id } == true) {
+ viewModel.removeTag(tag.id)
+ } else {
+ viewModel.addTag(tag.id)
+ }
+ }
+ )
+ }
+
+ if (uiState.showAssigneePicker) {
+ AssigneePickerDialog(
+ availableUsers = uiState.boardUsers,
+ selectedUsers = uiState.card?.assignees ?: emptyList(),
+ onDismiss = viewModel::hideAssigneePicker,
+ onUserToggle = { user ->
+ if (uiState.card?.assignees?.any { it.id == user.id } == true) {
+ viewModel.removeAssignee(user.id)
+ } else {
+ viewModel.addAssignee(user.id)
+ }
+ }
+ )
+ }
+
+ uiState.showEmojiPicker?.let { commentId ->
+ EmojiPickerDialog(
+ onDismiss = viewModel::hideEmojiPicker,
+ onEmojiSelect = { emoji -> viewModel.addReaction(commentId, emoji) }
+ )
+ }
+
+ uiState.showDatePicker?.let { action ->
+ DatePickerDialog(
+ title = if (action == DatePickerAction.TRIAGE) "Triage until" else "Defer until",
+ onDismiss = viewModel::hideDatePicker,
+ onDateSelect = { date ->
+ if (action == DatePickerAction.TRIAGE) {
+ viewModel.triageCard(date)
+ } else {
+ viewModel.deferCard(date)
+ }
+ }
+ )
+ }
+}
+
+@Composable
+private fun CardDetailContent(
+ card: Card,
+ steps: List,
+ comments: List,
+ boardTags: List,
+ boardUsers: List,
+ selectedTab: CardDetailTab,
+ onTabSelect: (CardDetailTab) -> Unit,
+ onTogglePriority: () -> Unit,
+ onToggleWatch: () -> Unit,
+ onClose: () -> Unit,
+ onReopen: () -> Unit,
+ onTriage: () -> Unit,
+ onDefer: () -> Unit,
+ onDelete: () -> Unit,
+ onAddTag: () -> Unit,
+ onRemoveTag: (Long) -> Unit,
+ onAddAssignee: () -> Unit,
+ onRemoveAssignee: (Long) -> Unit,
+ onAddStep: () -> Unit,
+ onToggleStep: (Step) -> Unit,
+ onDeleteStep: (Long) -> Unit,
+ onAddComment: () -> Unit,
+ onDeleteComment: (Long) -> Unit,
+ onAddReaction: (Long) -> Unit,
+ onRemoveReaction: (Long, String) -> Unit,
+ modifier: Modifier = Modifier
+) {
+ Column(
+ modifier = modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ ) {
+ // Title and status
+ Column(modifier = Modifier.padding(16.dp)) {
+ if (card.status != CardStatus.ACTIVE) {
+ StatusChip(status = card.status)
+ Spacer(modifier = Modifier.height(8.dp))
+ }
+
+ Text(
+ text = card.title,
+ style = MaterialTheme.typography.headlineSmall,
+ fontWeight = FontWeight.Bold
+ )
+
+ if (!card.description.isNullOrBlank()) {
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = card.description,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+
+ HorizontalDivider()
+
+ // Action buttons
+ CardActions(
+ card = card,
+ onTogglePriority = onTogglePriority,
+ onToggleWatch = onToggleWatch,
+ onClose = onClose,
+ onReopen = onReopen,
+ onTriage = onTriage,
+ onDefer = onDefer,
+ onDelete = onDelete
+ )
+
+ HorizontalDivider()
+
+ // Tags section
+ TagsSection(
+ tags = card.tags,
+ onAddTag = onAddTag,
+ onRemoveTag = onRemoveTag
+ )
+
+ HorizontalDivider()
+
+ // Assignees section
+ AssigneesSection(
+ assignees = card.assignees,
+ onAddAssignee = onAddAssignee,
+ onRemoveAssignee = onRemoveAssignee
+ )
+
+ HorizontalDivider()
+
+ // Tabs
+ TabRow(
+ selectedTabIndex = selectedTab.ordinal,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Tab(
+ selected = selectedTab == CardDetailTab.STEPS,
+ onClick = { onTabSelect(CardDetailTab.STEPS) },
+ text = { Text("Steps (${steps.size})") }
+ )
+ Tab(
+ selected = selectedTab == CardDetailTab.COMMENTS,
+ onClick = { onTabSelect(CardDetailTab.COMMENTS) },
+ text = { Text("Comments (${comments.size})") }
+ )
+ }
+
+ // Tab content
+ when (selectedTab) {
+ CardDetailTab.STEPS -> StepsContent(
+ steps = steps,
+ onAddStep = onAddStep,
+ onToggleStep = onToggleStep,
+ onDeleteStep = onDeleteStep
+ )
+ CardDetailTab.COMMENTS -> CommentsContent(
+ comments = comments,
+ onAddComment = onAddComment,
+ onDeleteComment = onDeleteComment,
+ onAddReaction = onAddReaction,
+ onRemoveReaction = onRemoveReaction
+ )
+ CardDetailTab.ACTIVITY -> {
+ // Placeholder for activity log
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(32.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ Text(
+ text = "Activity log coming soon",
+ color = MaterialTheme.colorScheme.outline
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun StatusChip(status: CardStatus) {
+ val (text, color) = when (status) {
+ CardStatus.CLOSED -> "Closed" to MaterialTheme.colorScheme.error
+ CardStatus.TRIAGED -> "Triaged" to Color(0xFFF97316)
+ CardStatus.DEFERRED -> "Deferred" to Color(0xFF8B5CF6)
+ CardStatus.ACTIVE -> return
+ }
+
+ Surface(
+ shape = RoundedCornerShape(4.dp),
+ color = color.copy(alpha = 0.15f)
+ ) {
+ Text(
+ text = text,
+ style = MaterialTheme.typography.labelMedium,
+ color = color,
+ modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
+ )
+ }
+}
+
+@Composable
+private fun CardActions(
+ card: Card,
+ onTogglePriority: () -> Unit,
+ onToggleWatch: () -> Unit,
+ onClose: () -> Unit,
+ onReopen: () -> Unit,
+ onTriage: () -> Unit,
+ onDefer: () -> Unit,
+ onDelete: () -> Unit
+) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(8.dp),
+ horizontalArrangement = Arrangement.SpaceEvenly
+ ) {
+ ActionButton(
+ icon = if (card.priority) Icons.Default.Star else Icons.Default.StarOutline,
+ label = "Priority",
+ tint = if (card.priority) FizzyGold else MaterialTheme.colorScheme.outline,
+ onClick = onTogglePriority
+ )
+
+ ActionButton(
+ icon = if (card.watching) Icons.Default.Visibility else Icons.Default.VisibilityOff,
+ label = "Watch",
+ tint = if (card.watching) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline,
+ onClick = onToggleWatch
+ )
+
+ if (card.status == CardStatus.ACTIVE) {
+ ActionButton(
+ icon = Icons.Default.Close,
+ label = "Close",
+ onClick = onClose
+ )
+ } else if (card.status == CardStatus.CLOSED) {
+ ActionButton(
+ icon = Icons.Default.Refresh,
+ label = "Reopen",
+ onClick = onReopen
+ )
+ }
+
+ ActionButton(
+ icon = Icons.Default.Schedule,
+ label = "Triage",
+ onClick = onTriage
+ )
+
+ ActionButton(
+ icon = Icons.Default.EventBusy,
+ label = "Defer",
+ onClick = onDefer
+ )
+
+ ActionButton(
+ icon = Icons.Default.Delete,
+ label = "Delete",
+ tint = MaterialTheme.colorScheme.error,
+ onClick = onDelete
+ )
+ }
+}
+
+@Composable
+private fun ActionButton(
+ icon: androidx.compose.ui.graphics.vector.ImageVector,
+ label: String,
+ tint: Color = MaterialTheme.colorScheme.outline,
+ onClick: () -> Unit
+) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ modifier = Modifier.clickable(onClick = onClick)
+ ) {
+ Icon(
+ imageVector = icon,
+ contentDescription = label,
+ tint = tint,
+ modifier = Modifier.size(24.dp)
+ )
+ Spacer(modifier = Modifier.height(4.dp))
+ Text(
+ text = label,
+ style = MaterialTheme.typography.labelSmall,
+ color = tint
+ )
+ }
+}
+
+@Composable
+private fun TagsSection(
+ tags: List,
+ onAddTag: () -> Unit,
+ onRemoveTag: (Long) -> Unit
+) {
+ Column(modifier = Modifier.padding(16.dp)) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = "Tags",
+ style = MaterialTheme.typography.titleSmall,
+ fontWeight = FontWeight.Medium
+ )
+ IconButton(onClick = onAddTag) {
+ Icon(Icons.Default.Add, contentDescription = "Add tag", modifier = Modifier.size(20.dp))
+ }
+ }
+
+ if (tags.isEmpty()) {
+ Text(
+ text = "No tags",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.outline
+ )
+ } else {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ modifier = Modifier.padding(top = 8.dp)
+ ) {
+ tags.forEach { tag ->
+ InputChip(
+ selected = false,
+ onClick = { onRemoveTag(tag.id) },
+ label = { Text(tag.name) },
+ colors = InputChipDefaults.inputChipColors(
+ containerColor = tag.backgroundColor
+ ),
+ trailingIcon = {
+ Icon(
+ Icons.Default.Close,
+ contentDescription = "Remove",
+ modifier = Modifier.size(16.dp),
+ tint = tag.textColor
+ )
+ }
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun AssigneesSection(
+ assignees: List,
+ onAddAssignee: () -> Unit,
+ onRemoveAssignee: (Long) -> Unit
+) {
+ Column(modifier = Modifier.padding(16.dp)) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = "Assignees",
+ style = MaterialTheme.typography.titleSmall,
+ fontWeight = FontWeight.Medium
+ )
+ IconButton(onClick = onAddAssignee) {
+ Icon(Icons.Default.PersonAdd, contentDescription = "Add assignee", modifier = Modifier.size(20.dp))
+ }
+ }
+
+ if (assignees.isEmpty()) {
+ Text(
+ text = "No assignees",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.outline
+ )
+ } else {
+ Column(
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ modifier = Modifier.padding(top = 8.dp)
+ ) {
+ assignees.forEach { user ->
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Surface(
+ modifier = Modifier.size(32.dp),
+ shape = CircleShape,
+ color = MaterialTheme.colorScheme.primary
+ ) {
+ Box(contentAlignment = Alignment.Center) {
+ Text(
+ text = user.name.first().uppercase(),
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onPrimary
+ )
+ }
+ }
+ Spacer(modifier = Modifier.width(12.dp))
+ Text(
+ text = user.name,
+ style = MaterialTheme.typography.bodyMedium,
+ modifier = Modifier.weight(1f)
+ )
+ IconButton(onClick = { onRemoveAssignee(user.id) }) {
+ Icon(
+ Icons.Default.Close,
+ contentDescription = "Remove",
+ modifier = Modifier.size(18.dp)
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun StepsContent(
+ steps: List,
+ onAddStep: () -> Unit,
+ onToggleStep: (Step) -> Unit,
+ onDeleteStep: (Long) -> Unit
+) {
+ Column(modifier = Modifier.padding(16.dp)) {
+ steps.forEach { step ->
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable { onToggleStep(step) }
+ .padding(vertical = 8.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Checkbox(
+ checked = step.completed,
+ onCheckedChange = { onToggleStep(step) }
+ )
+ Text(
+ text = step.description,
+ style = MaterialTheme.typography.bodyMedium,
+ modifier = Modifier.weight(1f),
+ color = if (step.completed)
+ MaterialTheme.colorScheme.outline
+ else
+ MaterialTheme.colorScheme.onSurface
+ )
+ IconButton(onClick = { onDeleteStep(step.id) }) {
+ Icon(
+ Icons.Default.Delete,
+ contentDescription = "Delete",
+ modifier = Modifier.size(18.dp),
+ tint = MaterialTheme.colorScheme.outline
+ )
+ }
+ }
+ }
+
+ TextButton(onClick = onAddStep, modifier = Modifier.fillMaxWidth()) {
+ Icon(Icons.Default.Add, contentDescription = null)
+ Spacer(modifier = Modifier.width(8.dp))
+ Text("Add Step")
+ }
+ }
+}
+
+@Composable
+private fun CommentsContent(
+ comments: List,
+ onAddComment: () -> Unit,
+ onDeleteComment: (Long) -> Unit,
+ onAddReaction: (Long) -> Unit,
+ onRemoveReaction: (Long, String) -> Unit
+) {
+ Column(modifier = Modifier.padding(16.dp)) {
+ Button(
+ onClick = onAddComment,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Icon(Icons.Default.Add, contentDescription = null)
+ Spacer(modifier = Modifier.width(8.dp))
+ Text("Add Comment")
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ comments.forEach { comment ->
+ CommentItem(
+ comment = comment,
+ onDelete = { onDeleteComment(comment.id) },
+ onAddReaction = { onAddReaction(comment.id) },
+ onRemoveReaction = { emoji -> onRemoveReaction(comment.id, emoji) }
+ )
+ Spacer(modifier = Modifier.height(12.dp))
+ }
+ }
+}
+
+@Composable
+private fun CommentItem(
+ comment: Comment,
+ onDelete: () -> Unit,
+ onAddReaction: () -> Unit,
+ onRemoveReaction: (String) -> Unit
+) {
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
+ )
+ ) {
+ Column(modifier = Modifier.padding(12.dp)) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Surface(
+ modifier = Modifier.size(28.dp),
+ shape = CircleShape,
+ color = MaterialTheme.colorScheme.primary
+ ) {
+ Box(contentAlignment = Alignment.Center) {
+ Text(
+ text = comment.author.name.first().uppercase(),
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onPrimary
+ )
+ }
+ }
+ Spacer(modifier = Modifier.width(8.dp))
+ Column {
+ Text(
+ text = comment.author.name,
+ style = MaterialTheme.typography.labelMedium,
+ fontWeight = FontWeight.Medium
+ )
+ Text(
+ text = comment.createdAt.atZone(ZoneId.systemDefault())
+ .format(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT)),
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.outline
+ )
+ }
+ }
+
+ IconButton(onClick = onDelete) {
+ Icon(
+ Icons.Default.Delete,
+ contentDescription = "Delete",
+ modifier = Modifier.size(18.dp),
+ tint = MaterialTheme.colorScheme.outline
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ Text(
+ text = comment.content,
+ style = MaterialTheme.typography.bodyMedium
+ )
+
+ if (comment.reactions.isNotEmpty()) {
+ Spacer(modifier = Modifier.height(8.dp))
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ comment.reactions.forEach { reaction ->
+ Surface(
+ shape = RoundedCornerShape(12.dp),
+ color = if (reaction.reactedByMe)
+ MaterialTheme.colorScheme.primaryContainer
+ else
+ MaterialTheme.colorScheme.surface,
+ modifier = Modifier.clickable {
+ if (reaction.reactedByMe) {
+ onRemoveReaction(reaction.emoji)
+ } else {
+ // Re-add same reaction - handled by API
+ }
+ }
+ ) {
+ Row(
+ modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(text = reaction.emoji)
+ Spacer(modifier = Modifier.width(4.dp))
+ Text(
+ text = reaction.count.toString(),
+ style = MaterialTheme.typography.labelSmall
+ )
+ }
+ }
+ }
+ }
+ }
+
+ Row(modifier = Modifier.padding(top = 8.dp)) {
+ TextButton(onClick = onAddReaction) {
+ Icon(Icons.Default.AddReaction, contentDescription = null, modifier = Modifier.size(16.dp))
+ Spacer(modifier = Modifier.width(4.dp))
+ Text("React")
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun CardEditContent(
+ title: String,
+ description: String,
+ onTitleChange: (String) -> Unit,
+ onDescriptionChange: (String) -> Unit,
+ onSave: () -> Unit,
+ onCancel: () -> Unit,
+ isLoading: Boolean,
+ modifier: Modifier = Modifier
+) {
+ Column(
+ modifier = modifier
+ .fillMaxSize()
+ .padding(16.dp)
+ ) {
+ OutlinedTextField(
+ value = title,
+ onValueChange = onTitleChange,
+ label = { Text("Title") },
+ modifier = Modifier.fillMaxWidth(),
+ enabled = !isLoading
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ OutlinedTextField(
+ value = description,
+ onValueChange = onDescriptionChange,
+ label = { Text("Description") },
+ modifier = Modifier
+ .fillMaxWidth()
+ .weight(1f),
+ enabled = !isLoading
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ OutlinedButton(
+ onClick = onCancel,
+ modifier = Modifier.weight(1f),
+ enabled = !isLoading
+ ) {
+ Text("Cancel")
+ }
+
+ Button(
+ onClick = onSave,
+ modifier = Modifier.weight(1f),
+ enabled = title.isNotBlank() && !isLoading
+ ) {
+ Text("Save")
+ }
+ }
+ }
+}
+
+// Dialogs
+@Composable
+private fun TextInputDialog(
+ title: String,
+ label: String,
+ multiline: Boolean = false,
+ onDismiss: () -> Unit,
+ onConfirm: (String) -> Unit
+) {
+ var text by remember { mutableStateOf("") }
+
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ title = { Text(title) },
+ text = {
+ OutlinedTextField(
+ value = text,
+ onValueChange = { text = it },
+ label = { Text(label) },
+ modifier = Modifier.fillMaxWidth(),
+ singleLine = !multiline,
+ minLines = if (multiline) 3 else 1
+ )
+ },
+ confirmButton = {
+ TextButton(
+ onClick = { onConfirm(text) },
+ enabled = text.isNotBlank()
+ ) {
+ Text("Add")
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = onDismiss) {
+ Text("Cancel")
+ }
+ }
+ )
+}
+
+@Composable
+private fun TagPickerDialog(
+ availableTags: List,
+ selectedTags: List,
+ onDismiss: () -> Unit,
+ onTagToggle: (Tag) -> Unit
+) {
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ title = { Text("Select Tags") },
+ text = {
+ LazyColumn {
+ items(availableTags) { tag ->
+ val isSelected = selectedTags.any { it.id == tag.id }
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable { onTagToggle(tag) }
+ .padding(vertical = 8.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Checkbox(
+ checked = isSelected,
+ onCheckedChange = { onTagToggle(tag) }
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Surface(
+ shape = RoundedCornerShape(4.dp),
+ color = tag.backgroundColor
+ ) {
+ Text(
+ text = tag.name,
+ style = MaterialTheme.typography.bodyMedium,
+ color = tag.textColor,
+ modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
+ )
+ }
+ }
+ }
+ }
+ },
+ confirmButton = {
+ TextButton(onClick = onDismiss) {
+ Text("Done")
+ }
+ }
+ )
+}
+
+@Composable
+private fun AssigneePickerDialog(
+ availableUsers: List,
+ selectedUsers: List,
+ onDismiss: () -> Unit,
+ onUserToggle: (User) -> Unit
+) {
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ title = { Text("Select Assignees") },
+ text = {
+ LazyColumn {
+ items(availableUsers) { user ->
+ val isSelected = selectedUsers.any { it.id == user.id }
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable { onUserToggle(user) }
+ .padding(vertical = 8.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Checkbox(
+ checked = isSelected,
+ onCheckedChange = { onUserToggle(user) }
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Surface(
+ modifier = Modifier.size(32.dp),
+ shape = CircleShape,
+ color = MaterialTheme.colorScheme.primary
+ ) {
+ Box(contentAlignment = Alignment.Center) {
+ Text(
+ text = user.name.first().uppercase(),
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onPrimary
+ )
+ }
+ }
+ Spacer(modifier = Modifier.width(12.dp))
+ Text(
+ text = user.name,
+ style = MaterialTheme.typography.bodyMedium
+ )
+ }
+ }
+ }
+ },
+ confirmButton = {
+ TextButton(onClick = onDismiss) {
+ Text("Done")
+ }
+ }
+ )
+}
+
+@Composable
+private fun EmojiPickerDialog(
+ onDismiss: () -> Unit,
+ onEmojiSelect: (String) -> Unit
+) {
+ val commonEmojis = listOf(
+ "\uD83D\uDC4D", "\uD83D\uDC4E", "\u2764\uFE0F", "\uD83D\uDE00",
+ "\uD83E\uDD14", "\uD83D\uDE4C", "\uD83D\uDE80", "\uD83C\uDF89",
+ "\uD83D\uDD25", "\uD83D\uDC40", "\uD83D\uDC4F", "\uD83D\uDE4F"
+ )
+
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ title = { Text("Add Reaction") },
+ text = {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ commonEmojis.chunked(4).forEach { row ->
+ Column {
+ row.forEach { emoji ->
+ TextButton(onClick = { onEmojiSelect(emoji) }) {
+ Text(emoji, style = MaterialTheme.typography.headlineMedium)
+ }
+ }
+ }
+ }
+ }
+ },
+ confirmButton = {
+ TextButton(onClick = onDismiss) {
+ Text("Cancel")
+ }
+ }
+ )
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun DatePickerDialog(
+ title: String,
+ onDismiss: () -> Unit,
+ onDateSelect: (java.time.LocalDate) -> Unit
+) {
+ val datePickerState = rememberDatePickerState(
+ initialSelectedDateMillis = System.currentTimeMillis() + 86400000 // Tomorrow
+ )
+
+ DatePickerDialog(
+ onDismissRequest = onDismiss,
+ confirmButton = {
+ TextButton(
+ onClick = {
+ datePickerState.selectedDateMillis?.let { millis ->
+ val date = java.time.Instant.ofEpochMilli(millis)
+ .atZone(ZoneId.systemDefault())
+ .toLocalDate()
+ onDateSelect(date)
+ }
+ }
+ ) {
+ Text("Confirm")
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = onDismiss) {
+ Text("Cancel")
+ }
+ }
+ ) {
+ DatePicker(
+ state = datePickerState,
+ title = { Text(title, modifier = Modifier.padding(16.dp)) }
+ )
+ }
+}
diff --git a/app/src/main/java/com/fizzy/android/feature/card/CardDetailViewModel.kt b/app/src/main/java/com/fizzy/android/feature/card/CardDetailViewModel.kt
new file mode 100644
index 0000000..522bbe8
--- /dev/null
+++ b/app/src/main/java/com/fizzy/android/feature/card/CardDetailViewModel.kt
@@ -0,0 +1,495 @@
+package com.fizzy.android.feature.card
+
+import androidx.lifecycle.SavedStateHandle
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.fizzy.android.core.network.ApiResult
+import com.fizzy.android.domain.model.*
+import com.fizzy.android.domain.repository.BoardRepository
+import com.fizzy.android.domain.repository.CardRepository
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.*
+import kotlinx.coroutines.launch
+import java.time.LocalDate
+import javax.inject.Inject
+
+data class CardDetailUiState(
+ val card: Card? = null,
+ val steps: List = emptyList(),
+ val comments: List = emptyList(),
+ val boardTags: List = emptyList(),
+ val boardUsers: List = emptyList(),
+ val isLoading: Boolean = false,
+ val error: String? = null,
+ val selectedTab: CardDetailTab = CardDetailTab.STEPS,
+ val isEditing: Boolean = false,
+ val editTitle: String = "",
+ val editDescription: String = "",
+ val showDatePicker: DatePickerAction? = null,
+ val showTagPicker: Boolean = false,
+ val showAssigneePicker: Boolean = false,
+ val showAddStepDialog: Boolean = false,
+ val showAddCommentDialog: Boolean = false,
+ val editingStep: Step? = null,
+ val showEmojiPicker: Long? = null // commentId
+)
+
+enum class CardDetailTab {
+ STEPS, COMMENTS, ACTIVITY
+}
+
+enum class DatePickerAction {
+ TRIAGE, DEFER
+}
+
+sealed class CardDetailEvent {
+ data class ShowError(val message: String) : CardDetailEvent()
+ data object CardUpdated : CardDetailEvent()
+ data object CardClosed : CardDetailEvent()
+ data object CardReopened : CardDetailEvent()
+ data object NavigateBack : CardDetailEvent()
+}
+
+@HiltViewModel
+class CardDetailViewModel @Inject constructor(
+ savedStateHandle: SavedStateHandle,
+ private val cardRepository: CardRepository,
+ private val boardRepository: BoardRepository
+) : ViewModel() {
+
+ private val cardId: Long = checkNotNull(savedStateHandle["cardId"])
+
+ private val _uiState = MutableStateFlow(CardDetailUiState())
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ private val _events = MutableSharedFlow()
+ val events: SharedFlow = _events.asSharedFlow()
+
+ init {
+ loadCard()
+ }
+
+ fun loadCard() {
+ viewModelScope.launch {
+ _uiState.update { it.copy(isLoading = true, error = null) }
+
+ when (val result = cardRepository.getCard(cardId)) {
+ is ApiResult.Success -> {
+ val card = result.data
+ _uiState.update {
+ it.copy(
+ isLoading = false,
+ card = card,
+ editTitle = card.title,
+ editDescription = card.description ?: ""
+ )
+ }
+ loadCardDetails(card.boardId)
+ }
+ is ApiResult.Error -> {
+ _uiState.update {
+ it.copy(isLoading = false, error = "Failed to load card")
+ }
+ }
+ is ApiResult.Exception -> {
+ _uiState.update {
+ it.copy(isLoading = false, error = "Network error")
+ }
+ }
+ }
+ }
+ }
+
+ private fun loadCardDetails(boardId: String) {
+ viewModelScope.launch {
+ // Load steps
+ when (val result = cardRepository.getSteps(cardId)) {
+ is ApiResult.Success -> {
+ _uiState.update { it.copy(steps = result.data) }
+ }
+ else -> { /* Ignore */ }
+ }
+
+ // Load comments
+ when (val result = cardRepository.getComments(cardId)) {
+ is ApiResult.Success -> {
+ _uiState.update { it.copy(comments = result.data) }
+ }
+ else -> { /* Ignore */ }
+ }
+
+ // Load board tags
+ when (val result = boardRepository.getTags(boardId)) {
+ is ApiResult.Success -> {
+ _uiState.update { it.copy(boardTags = result.data) }
+ }
+ else -> { /* Ignore */ }
+ }
+
+ // Load board users
+ when (val result = boardRepository.getBoardUsers(boardId)) {
+ is ApiResult.Success -> {
+ _uiState.update { it.copy(boardUsers = result.data) }
+ }
+ else -> { /* Ignore */ }
+ }
+ }
+ }
+
+ fun selectTab(tab: CardDetailTab) {
+ _uiState.update { it.copy(selectedTab = tab) }
+ }
+
+ // Edit mode
+ fun startEditing() {
+ val card = _uiState.value.card ?: return
+ _uiState.update {
+ it.copy(
+ isEditing = true,
+ editTitle = card.title,
+ editDescription = card.description ?: ""
+ )
+ }
+ }
+
+ fun cancelEditing() {
+ _uiState.update { it.copy(isEditing = false) }
+ }
+
+ fun onTitleChange(title: String) {
+ _uiState.update { it.copy(editTitle = title) }
+ }
+
+ fun onDescriptionChange(description: String) {
+ _uiState.update { it.copy(editDescription = description) }
+ }
+
+ fun saveChanges() {
+ viewModelScope.launch {
+ _uiState.update { it.copy(isLoading = true) }
+
+ val title = _uiState.value.editTitle.trim()
+ val description = _uiState.value.editDescription.trim().takeIf { it.isNotEmpty() }
+
+ when (val result = cardRepository.updateCard(cardId, title, description)) {
+ is ApiResult.Success -> {
+ _uiState.update {
+ it.copy(
+ isLoading = false,
+ isEditing = false,
+ card = result.data
+ )
+ }
+ _events.emit(CardDetailEvent.CardUpdated)
+ }
+ is ApiResult.Error -> {
+ _uiState.update { it.copy(isLoading = false) }
+ _events.emit(CardDetailEvent.ShowError("Failed to update card"))
+ }
+ is ApiResult.Exception -> {
+ _uiState.update { it.copy(isLoading = false) }
+ _events.emit(CardDetailEvent.ShowError("Network error"))
+ }
+ }
+ }
+ }
+
+ // Card actions
+ fun togglePriority() {
+ viewModelScope.launch {
+ val card = _uiState.value.card ?: return@launch
+ when (val result = cardRepository.togglePriority(cardId, !card.priority)) {
+ is ApiResult.Success -> {
+ _uiState.update { it.copy(card = result.data) }
+ }
+ else -> _events.emit(CardDetailEvent.ShowError("Failed to update priority"))
+ }
+ }
+ }
+
+ fun toggleWatch() {
+ viewModelScope.launch {
+ val card = _uiState.value.card ?: return@launch
+ when (val result = cardRepository.toggleWatch(cardId, !card.watching)) {
+ is ApiResult.Success -> {
+ _uiState.update { it.copy(card = result.data) }
+ }
+ else -> _events.emit(CardDetailEvent.ShowError("Failed to update watch status"))
+ }
+ }
+ }
+
+ fun closeCard() {
+ viewModelScope.launch {
+ when (val result = cardRepository.closeCard(cardId)) {
+ is ApiResult.Success -> {
+ _uiState.update { it.copy(card = result.data) }
+ _events.emit(CardDetailEvent.CardClosed)
+ }
+ else -> _events.emit(CardDetailEvent.ShowError("Failed to close card"))
+ }
+ }
+ }
+
+ fun reopenCard() {
+ viewModelScope.launch {
+ when (val result = cardRepository.reopenCard(cardId)) {
+ is ApiResult.Success -> {
+ _uiState.update { it.copy(card = result.data) }
+ _events.emit(CardDetailEvent.CardReopened)
+ }
+ else -> _events.emit(CardDetailEvent.ShowError("Failed to reopen card"))
+ }
+ }
+ }
+
+ fun showTriageDatePicker() {
+ _uiState.update { it.copy(showDatePicker = DatePickerAction.TRIAGE) }
+ }
+
+ fun showDeferDatePicker() {
+ _uiState.update { it.copy(showDatePicker = DatePickerAction.DEFER) }
+ }
+
+ fun hideDatePicker() {
+ _uiState.update { it.copy(showDatePicker = null) }
+ }
+
+ fun triageCard(date: LocalDate) {
+ viewModelScope.launch {
+ _uiState.update { it.copy(showDatePicker = null) }
+ when (val result = cardRepository.triageCard(cardId, date)) {
+ is ApiResult.Success -> {
+ _uiState.update { it.copy(card = result.data) }
+ }
+ else -> _events.emit(CardDetailEvent.ShowError("Failed to triage card"))
+ }
+ }
+ }
+
+ fun deferCard(date: LocalDate) {
+ viewModelScope.launch {
+ _uiState.update { it.copy(showDatePicker = null) }
+ when (val result = cardRepository.deferCard(cardId, date)) {
+ is ApiResult.Success -> {
+ _uiState.update { it.copy(card = result.data) }
+ }
+ else -> _events.emit(CardDetailEvent.ShowError("Failed to defer card"))
+ }
+ }
+ }
+
+ fun deleteCard() {
+ viewModelScope.launch {
+ when (cardRepository.deleteCard(cardId)) {
+ is ApiResult.Success -> {
+ _events.emit(CardDetailEvent.NavigateBack)
+ }
+ else -> _events.emit(CardDetailEvent.ShowError("Failed to delete card"))
+ }
+ }
+ }
+
+ // Tags
+ fun showTagPicker() {
+ _uiState.update { it.copy(showTagPicker = true) }
+ }
+
+ fun hideTagPicker() {
+ _uiState.update { it.copy(showTagPicker = false) }
+ }
+
+ fun addTag(tagId: Long) {
+ viewModelScope.launch {
+ when (val result = cardRepository.addTag(cardId, tagId)) {
+ is ApiResult.Success -> {
+ _uiState.update { it.copy(card = result.data) }
+ }
+ else -> _events.emit(CardDetailEvent.ShowError("Failed to add tag"))
+ }
+ }
+ }
+
+ fun removeTag(tagId: Long) {
+ viewModelScope.launch {
+ when (val result = cardRepository.removeTag(cardId, tagId)) {
+ is ApiResult.Success -> {
+ _uiState.update { it.copy(card = result.data) }
+ }
+ else -> _events.emit(CardDetailEvent.ShowError("Failed to remove tag"))
+ }
+ }
+ }
+
+ // Assignees
+ fun showAssigneePicker() {
+ _uiState.update { it.copy(showAssigneePicker = true) }
+ }
+
+ fun hideAssigneePicker() {
+ _uiState.update { it.copy(showAssigneePicker = false) }
+ }
+
+ fun addAssignee(userId: Long) {
+ viewModelScope.launch {
+ when (val result = cardRepository.addAssignee(cardId, userId)) {
+ is ApiResult.Success -> {
+ _uiState.update { it.copy(card = result.data) }
+ }
+ else -> _events.emit(CardDetailEvent.ShowError("Failed to add assignee"))
+ }
+ }
+ }
+
+ fun removeAssignee(userId: Long) {
+ viewModelScope.launch {
+ when (val result = cardRepository.removeAssignee(cardId, userId)) {
+ is ApiResult.Success -> {
+ _uiState.update { it.copy(card = result.data) }
+ }
+ else -> _events.emit(CardDetailEvent.ShowError("Failed to remove assignee"))
+ }
+ }
+ }
+
+ // Steps
+ fun showAddStepDialog() {
+ _uiState.update { it.copy(showAddStepDialog = true) }
+ }
+
+ fun hideAddStepDialog() {
+ _uiState.update { it.copy(showAddStepDialog = false) }
+ }
+
+ fun createStep(description: String) {
+ viewModelScope.launch {
+ _uiState.update { it.copy(showAddStepDialog = false) }
+ when (val result = cardRepository.createStep(cardId, description)) {
+ is ApiResult.Success -> {
+ _uiState.update { state ->
+ state.copy(steps = state.steps + result.data)
+ }
+ refreshCard()
+ }
+ else -> _events.emit(CardDetailEvent.ShowError("Failed to create step"))
+ }
+ }
+ }
+
+ fun toggleStepCompleted(step: Step) {
+ viewModelScope.launch {
+ when (val result = cardRepository.updateStep(cardId, step.id, null, !step.completed, null)) {
+ is ApiResult.Success -> {
+ _uiState.update { state ->
+ state.copy(steps = state.steps.map {
+ if (it.id == step.id) result.data else it
+ })
+ }
+ refreshCard()
+ }
+ else -> _events.emit(CardDetailEvent.ShowError("Failed to update step"))
+ }
+ }
+ }
+
+ fun deleteStep(stepId: Long) {
+ viewModelScope.launch {
+ when (cardRepository.deleteStep(cardId, stepId)) {
+ is ApiResult.Success -> {
+ _uiState.update { state ->
+ state.copy(steps = state.steps.filter { it.id != stepId })
+ }
+ refreshCard()
+ }
+ else -> _events.emit(CardDetailEvent.ShowError("Failed to delete step"))
+ }
+ }
+ }
+
+ // Comments
+ fun showAddCommentDialog() {
+ _uiState.update { it.copy(showAddCommentDialog = true) }
+ }
+
+ fun hideAddCommentDialog() {
+ _uiState.update { it.copy(showAddCommentDialog = false) }
+ }
+
+ fun createComment(content: String) {
+ viewModelScope.launch {
+ _uiState.update { it.copy(showAddCommentDialog = false) }
+ when (val result = cardRepository.createComment(cardId, content)) {
+ is ApiResult.Success -> {
+ _uiState.update { state ->
+ state.copy(comments = listOf(result.data) + state.comments)
+ }
+ }
+ else -> _events.emit(CardDetailEvent.ShowError("Failed to create comment"))
+ }
+ }
+ }
+
+ fun deleteComment(commentId: Long) {
+ viewModelScope.launch {
+ when (cardRepository.deleteComment(cardId, commentId)) {
+ is ApiResult.Success -> {
+ _uiState.update { state ->
+ state.copy(comments = state.comments.filter { it.id != commentId })
+ }
+ }
+ else -> _events.emit(CardDetailEvent.ShowError("Failed to delete comment"))
+ }
+ }
+ }
+
+ // Reactions
+ fun showEmojiPicker(commentId: Long) {
+ _uiState.update { it.copy(showEmojiPicker = commentId) }
+ }
+
+ fun hideEmojiPicker() {
+ _uiState.update { it.copy(showEmojiPicker = null) }
+ }
+
+ fun addReaction(commentId: Long, emoji: String) {
+ viewModelScope.launch {
+ _uiState.update { it.copy(showEmojiPicker = null) }
+ when (val result = cardRepository.addReaction(cardId, commentId, emoji)) {
+ is ApiResult.Success -> {
+ _uiState.update { state ->
+ state.copy(comments = state.comments.map {
+ if (it.id == commentId) result.data else it
+ })
+ }
+ }
+ else -> _events.emit(CardDetailEvent.ShowError("Failed to add reaction"))
+ }
+ }
+ }
+
+ fun removeReaction(commentId: Long, emoji: String) {
+ viewModelScope.launch {
+ when (val result = cardRepository.removeReaction(cardId, commentId, emoji)) {
+ is ApiResult.Success -> {
+ _uiState.update { state ->
+ state.copy(comments = state.comments.map {
+ if (it.id == commentId) result.data else it
+ })
+ }
+ }
+ else -> _events.emit(CardDetailEvent.ShowError("Failed to remove reaction"))
+ }
+ }
+ }
+
+ private fun refreshCard() {
+ viewModelScope.launch {
+ when (val result = cardRepository.getCard(cardId)) {
+ is ApiResult.Success -> {
+ _uiState.update { it.copy(card = result.data) }
+ }
+ else -> { /* Ignore */ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/fizzy/android/feature/kanban/KanbanScreen.kt b/app/src/main/java/com/fizzy/android/feature/kanban/KanbanScreen.kt
new file mode 100644
index 0000000..636be83
--- /dev/null
+++ b/app/src/main/java/com/fizzy/android/feature/kanban/KanbanScreen.kt
@@ -0,0 +1,629 @@
+package com.fizzy.android.feature.kanban
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
+import androidx.compose.foundation.horizontalScroll
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.filled.*
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.draw.shadow
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.IntOffset
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import com.fizzy.android.core.ui.components.ErrorMessage
+import com.fizzy.android.core.ui.components.LoadingIndicator
+import com.fizzy.android.core.ui.theme.FizzyGold
+import com.fizzy.android.domain.model.Card
+import com.fizzy.android.domain.model.CardStatus
+import com.fizzy.android.domain.model.Column
+import kotlinx.coroutines.flow.collectLatest
+import kotlin.math.roundToInt
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun KanbanScreen(
+ boardId: String,
+ onBackClick: () -> Unit,
+ onCardClick: (Long) -> Unit,
+ viewModel: KanbanViewModel = hiltViewModel()
+) {
+ val uiState by viewModel.uiState.collectAsState()
+ val snackbarHostState = remember { SnackbarHostState() }
+
+ LaunchedEffect(Unit) {
+ viewModel.events.collectLatest { event ->
+ when (event) {
+ is KanbanEvent.ShowError -> snackbarHostState.showSnackbar(event.message)
+ is KanbanEvent.NavigateToCard -> onCardClick(event.cardId)
+ KanbanEvent.ColumnCreated -> snackbarHostState.showSnackbar("Column created")
+ KanbanEvent.ColumnUpdated -> snackbarHostState.showSnackbar("Column updated")
+ KanbanEvent.ColumnDeleted -> snackbarHostState.showSnackbar("Column deleted")
+ KanbanEvent.CardCreated -> snackbarHostState.showSnackbar("Card created")
+ KanbanEvent.CardMoved -> { /* Silent */ }
+ }
+ }
+ }
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text(uiState.board?.name ?: "Board") },
+ navigationIcon = {
+ IconButton(onClick = onBackClick) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
+ }
+ },
+ actions = {
+ IconButton(onClick = viewModel::refresh) {
+ Icon(Icons.Default.Refresh, contentDescription = "Refresh")
+ }
+ IconButton(onClick = viewModel::showAddColumnDialog) {
+ Icon(Icons.Default.AddCircleOutline, contentDescription = "Add column")
+ }
+ }
+ )
+ },
+ snackbarHost = { SnackbarHost(snackbarHostState) }
+ ) { paddingValues ->
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(paddingValues)
+ ) {
+ when {
+ uiState.isLoading && uiState.columns.isEmpty() -> {
+ LoadingIndicator()
+ }
+ uiState.error != null && uiState.columns.isEmpty() -> {
+ ErrorMessage(
+ message = uiState.error ?: "Unknown error",
+ onRetry = viewModel::loadBoard
+ )
+ }
+ else -> {
+ KanbanBoard(
+ columns = uiState.columns,
+ dragState = uiState.dragState,
+ onCardClick = viewModel::onCardClick,
+ onCardLongPress = viewModel::startDragging,
+ onDragEnd = viewModel::endDragging,
+ onDragCancel = viewModel::cancelDragging,
+ onDragTargetUpdate = viewModel::updateDragTarget,
+ onAddCard = viewModel::showAddCardDialog,
+ onEditColumn = viewModel::showEditColumnDialog,
+ onDeleteColumn = viewModel::deleteColumn
+ )
+ }
+ }
+
+ // Show loading indicator when refreshing
+ if (uiState.isRefreshing) {
+ CircularProgressIndicator(
+ modifier = Modifier
+ .align(Alignment.TopCenter)
+ .padding(16.dp)
+ )
+ }
+ }
+ }
+
+ // Add Column Dialog
+ if (uiState.showAddColumnDialog) {
+ ColumnDialog(
+ title = "Add Column",
+ initialName = "",
+ onDismiss = viewModel::hideAddColumnDialog,
+ onConfirm = viewModel::createColumn
+ )
+ }
+
+ // Edit Column Dialog
+ uiState.editingColumn?.let { column ->
+ ColumnDialog(
+ title = "Edit Column",
+ initialName = column.name,
+ onDismiss = viewModel::hideEditColumnDialog,
+ onConfirm = { name -> viewModel.updateColumn(column.id, name) }
+ )
+ }
+
+ // Add Card Dialog
+ uiState.showAddCardDialog?.let { columnId ->
+ CardQuickAddDialog(
+ onDismiss = viewModel::hideAddCardDialog,
+ onConfirm = { title -> viewModel.createCard(columnId, title) }
+ )
+ }
+}
+
+@Composable
+private fun KanbanBoard(
+ columns: List,
+ dragState: DragState,
+ onCardClick: (Long) -> Unit,
+ onCardLongPress: (Card) -> Unit,
+ onDragEnd: () -> Unit,
+ onDragCancel: () -> Unit,
+ onDragTargetUpdate: (String, Int) -> Unit,
+ onAddCard: (String) -> Unit,
+ onEditColumn: (Column) -> Unit,
+ onDeleteColumn: (String) -> Unit
+) {
+ val scrollState = rememberScrollState()
+
+ Row(
+ modifier = Modifier
+ .fillMaxSize()
+ .horizontalScroll(scrollState)
+ .padding(8.dp),
+ horizontalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ columns.forEach { column ->
+ KanbanColumn(
+ column = column,
+ isDragTarget = dragState.targetColumnId == column.id,
+ onCardClick = onCardClick,
+ onCardLongPress = onCardLongPress,
+ onDragEnd = onDragEnd,
+ onDragCancel = onDragCancel,
+ onDragTargetUpdate = { position -> onDragTargetUpdate(column.id, position) },
+ onAddCard = { onAddCard(column.id) },
+ onEditColumn = { onEditColumn(column) },
+ onDeleteColumn = { onDeleteColumn(column.id) },
+ draggingCard = if (dragState.sourceColumnId == column.id) dragState.draggingCard else null
+ )
+ }
+
+ // Add column button at the end
+ AddColumnButton(onClick = { /* Handled by FAB */ })
+ }
+}
+
+@Composable
+private fun KanbanColumn(
+ column: Column,
+ isDragTarget: Boolean,
+ onCardClick: (Long) -> Unit,
+ onCardLongPress: (Card) -> Unit,
+ onDragEnd: () -> Unit,
+ onDragCancel: () -> Unit,
+ onDragTargetUpdate: (Int) -> Unit,
+ onAddCard: () -> Unit,
+ onEditColumn: () -> Unit,
+ onDeleteColumn: () -> Unit,
+ draggingCard: Card?
+) {
+ var showMenu by remember { mutableStateOf(false) }
+
+ Card(
+ modifier = Modifier
+ .width(300.dp)
+ .fillMaxHeight(),
+ colors = CardDefaults.cardColors(
+ containerColor = if (isDragTarget)
+ MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)
+ else
+ MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
+ )
+ ) {
+ Column(modifier = Modifier.fillMaxSize()) {
+ // Column Header
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(12.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Text(
+ text = column.name,
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.SemiBold
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Surface(
+ shape = CircleShape,
+ color = MaterialTheme.colorScheme.outline.copy(alpha = 0.2f)
+ ) {
+ Text(
+ text = column.cards.size.toString(),
+ style = MaterialTheme.typography.labelSmall,
+ modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp)
+ )
+ }
+ }
+
+ Box {
+ IconButton(onClick = { showMenu = true }) {
+ Icon(
+ Icons.Default.MoreVert,
+ contentDescription = "Column options",
+ modifier = Modifier.size(20.dp)
+ )
+ }
+
+ DropdownMenu(
+ expanded = showMenu,
+ onDismissRequest = { showMenu = false }
+ ) {
+ DropdownMenuItem(
+ text = { Text("Edit") },
+ leadingIcon = { Icon(Icons.Default.Edit, contentDescription = null) },
+ onClick = {
+ showMenu = false
+ onEditColumn()
+ }
+ )
+ DropdownMenuItem(
+ text = { Text("Delete") },
+ leadingIcon = {
+ Icon(
+ Icons.Default.Delete,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.error
+ )
+ },
+ onClick = {
+ showMenu = false
+ onDeleteColumn()
+ }
+ )
+ }
+ }
+ }
+
+ // Cards List
+ LazyColumn(
+ modifier = Modifier
+ .weight(1f)
+ .padding(horizontal = 8.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ itemsIndexed(column.cards, key = { _, card -> card.id }) { index, card ->
+ KanbanCard(
+ card = card,
+ isDragging = draggingCard?.id == card.id,
+ onClick = { onCardClick(card.id) },
+ onLongPress = { onCardLongPress(card) }
+ )
+ }
+ }
+
+ // Add Card Button
+ TextButton(
+ onClick = onAddCard,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(8.dp)
+ ) {
+ Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(18.dp))
+ Spacer(modifier = Modifier.width(4.dp))
+ Text("Add Card")
+ }
+ }
+ }
+}
+
+@Composable
+private fun KanbanCard(
+ card: Card,
+ isDragging: Boolean,
+ onClick: () -> Unit,
+ onLongPress: () -> Unit
+) {
+ var offsetX by remember { mutableFloatStateOf(0f) }
+ var offsetY by remember { mutableFloatStateOf(0f) }
+
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .graphicsLayer {
+ if (isDragging) {
+ alpha = 0.5f
+ }
+ }
+ .pointerInput(Unit) {
+ detectDragGesturesAfterLongPress(
+ onDragStart = { onLongPress() },
+ onDragEnd = { },
+ onDragCancel = { },
+ onDrag = { change, dragAmount ->
+ change.consume()
+ offsetX += dragAmount.x
+ offsetY += dragAmount.y
+ }
+ )
+ }
+ .clickable(onClick = onClick),
+ elevation = CardDefaults.cardElevation(
+ defaultElevation = if (isDragging) 8.dp else 2.dp
+ )
+ ) {
+ Column(
+ modifier = Modifier.padding(12.dp)
+ ) {
+ // Priority indicator
+ if (card.priority) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.padding(bottom = 4.dp)
+ ) {
+ Icon(
+ Icons.Default.Star,
+ contentDescription = "Priority",
+ modifier = Modifier.size(14.dp),
+ tint = FizzyGold
+ )
+ Spacer(modifier = Modifier.width(4.dp))
+ Text(
+ text = "Priority",
+ style = MaterialTheme.typography.labelSmall,
+ color = FizzyGold
+ )
+ }
+ }
+
+ // Title
+ Text(
+ text = card.title,
+ style = MaterialTheme.typography.bodyMedium,
+ fontWeight = FontWeight.Medium,
+ maxLines = 3,
+ overflow = TextOverflow.Ellipsis
+ )
+
+ // Tags
+ if (card.tags.isNotEmpty()) {
+ Spacer(modifier = Modifier.height(8.dp))
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(4.dp),
+ modifier = Modifier.horizontalScroll(rememberScrollState())
+ ) {
+ card.tags.take(3).forEach { tag ->
+ Surface(
+ shape = RoundedCornerShape(4.dp),
+ color = tag.backgroundColor
+ ) {
+ Text(
+ text = tag.name,
+ style = MaterialTheme.typography.labelSmall,
+ color = tag.textColor,
+ modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
+ )
+ }
+ }
+ if (card.tags.size > 3) {
+ Text(
+ text = "+${card.tags.size - 3}",
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.outline
+ )
+ }
+ }
+ }
+
+ // Bottom indicators
+ if (card.hasSteps || card.commentsCount > 0 || card.assignees.isNotEmpty()) {
+ Spacer(modifier = Modifier.height(8.dp))
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ // Steps progress
+ if (card.hasSteps) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Icon(
+ Icons.Default.CheckBox,
+ contentDescription = null,
+ modifier = Modifier.size(14.dp),
+ tint = if (card.stepsCompleted == card.stepsTotal)
+ MaterialTheme.colorScheme.primary
+ else
+ MaterialTheme.colorScheme.outline
+ )
+ Spacer(modifier = Modifier.width(4.dp))
+ Text(
+ text = card.stepsDisplay,
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.outline
+ )
+ }
+ }
+
+ // Comments count
+ if (card.commentsCount > 0) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Icon(
+ Icons.Default.ChatBubbleOutline,
+ contentDescription = null,
+ modifier = Modifier.size(14.dp),
+ tint = MaterialTheme.colorScheme.outline
+ )
+ Spacer(modifier = Modifier.width(4.dp))
+ Text(
+ text = card.commentsCount.toString(),
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.outline
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.weight(1f))
+
+ // Assignees avatars
+ if (card.assignees.isNotEmpty()) {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy((-8).dp)
+ ) {
+ card.assignees.take(3).forEach { user ->
+ Surface(
+ modifier = Modifier.size(24.dp),
+ shape = CircleShape,
+ color = MaterialTheme.colorScheme.primary
+ ) {
+ Box(contentAlignment = Alignment.Center) {
+ Text(
+ text = user.name.first().uppercase(),
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onPrimary
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // Status badge
+ if (card.status != CardStatus.ACTIVE) {
+ Spacer(modifier = Modifier.height(8.dp))
+ StatusBadge(status = card.status)
+ }
+ }
+ }
+}
+
+@Composable
+private fun StatusBadge(status: CardStatus) {
+ val (text, color) = when (status) {
+ CardStatus.CLOSED -> "Closed" to MaterialTheme.colorScheme.error
+ CardStatus.TRIAGED -> "Triaged" to Color(0xFFF97316)
+ CardStatus.DEFERRED -> "Deferred" to Color(0xFF8B5CF6)
+ CardStatus.ACTIVE -> return
+ }
+
+ Surface(
+ shape = RoundedCornerShape(4.dp),
+ color = color.copy(alpha = 0.15f)
+ ) {
+ Text(
+ text = text,
+ style = MaterialTheme.typography.labelSmall,
+ color = color,
+ modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
+ )
+ }
+}
+
+@Composable
+private fun AddColumnButton(onClick: () -> Unit) {
+ Card(
+ modifier = Modifier
+ .width(280.dp)
+ .height(100.dp)
+ .clickable(onClick = onClick),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)
+ )
+ ) {
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center
+ ) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Icon(
+ Icons.Default.Add,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.outline
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(
+ text = "Add Column",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.outline
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun ColumnDialog(
+ title: String,
+ initialName: String,
+ onDismiss: () -> Unit,
+ onConfirm: (String) -> Unit
+) {
+ var name by remember { mutableStateOf(initialName) }
+
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ title = { Text(title) },
+ text = {
+ OutlinedTextField(
+ value = name,
+ onValueChange = { name = it },
+ label = { Text("Column name") },
+ singleLine = true,
+ modifier = Modifier.fillMaxWidth()
+ )
+ },
+ confirmButton = {
+ TextButton(
+ onClick = { onConfirm(name) },
+ enabled = name.isNotBlank()
+ ) {
+ Text(if (initialName.isEmpty()) "Create" else "Save")
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = onDismiss) {
+ Text("Cancel")
+ }
+ }
+ )
+}
+
+@Composable
+private fun CardQuickAddDialog(
+ onDismiss: () -> Unit,
+ onConfirm: (String) -> Unit
+) {
+ var title by remember { mutableStateOf("") }
+
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ title = { Text("Add Card") },
+ text = {
+ OutlinedTextField(
+ value = title,
+ onValueChange = { title = it },
+ label = { Text("Card title") },
+ singleLine = true,
+ modifier = Modifier.fillMaxWidth()
+ )
+ },
+ confirmButton = {
+ TextButton(
+ onClick = { onConfirm(title) },
+ enabled = title.isNotBlank()
+ ) {
+ Text("Add")
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = onDismiss) {
+ Text("Cancel")
+ }
+ }
+ )
+}
diff --git a/app/src/main/java/com/fizzy/android/feature/kanban/KanbanViewModel.kt b/app/src/main/java/com/fizzy/android/feature/kanban/KanbanViewModel.kt
new file mode 100644
index 0000000..f5e6f83
--- /dev/null
+++ b/app/src/main/java/com/fizzy/android/feature/kanban/KanbanViewModel.kt
@@ -0,0 +1,413 @@
+package com.fizzy.android.feature.kanban
+
+import androidx.lifecycle.SavedStateHandle
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.fizzy.android.core.network.ApiResult
+import com.fizzy.android.domain.model.Board
+import com.fizzy.android.domain.model.Card
+import com.fizzy.android.domain.model.Column
+import com.fizzy.android.domain.repository.BoardRepository
+import com.fizzy.android.domain.repository.CardRepository
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import android.util.Log
+import kotlinx.coroutines.flow.*
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+private const val TAG = "KanbanViewModel"
+
+data class KanbanUiState(
+ val board: Board? = null,
+ val columns: List = emptyList(),
+ val isLoading: Boolean = false,
+ val isRefreshing: Boolean = false,
+ val error: String? = null,
+ val showAddColumnDialog: Boolean = false,
+ val editingColumn: Column? = null,
+ val showAddCardDialog: String? = null, // columnId
+ val dragState: DragState = DragState()
+)
+
+data class DragState(
+ val isDragging: Boolean = false,
+ val draggingCard: Card? = null,
+ val sourceColumnId: String? = null,
+ val targetColumnId: String? = null,
+ val targetPosition: Int? = null
+)
+
+sealed class KanbanEvent {
+ data class ShowError(val message: String) : KanbanEvent()
+ data class NavigateToCard(val cardId: Long) : KanbanEvent()
+ data object ColumnCreated : KanbanEvent()
+ data object ColumnUpdated : KanbanEvent()
+ data object ColumnDeleted : KanbanEvent()
+ data object CardCreated : KanbanEvent()
+ data object CardMoved : KanbanEvent()
+}
+
+@HiltViewModel
+class KanbanViewModel @Inject constructor(
+ savedStateHandle: SavedStateHandle,
+ private val boardRepository: BoardRepository,
+ private val cardRepository: CardRepository
+) : ViewModel() {
+
+ private val boardId: String = checkNotNull(savedStateHandle["boardId"])
+
+ private val _uiState = MutableStateFlow(KanbanUiState())
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ private val _events = MutableSharedFlow()
+ val events: SharedFlow = _events.asSharedFlow()
+
+ private var pollingJob: Job? = null
+
+ init {
+ loadBoard()
+ startPolling()
+ }
+
+ override fun onCleared() {
+ super.onCleared()
+ pollingJob?.cancel()
+ }
+
+ private fun startPolling() {
+ pollingJob = viewModelScope.launch {
+ while (true) {
+ delay(10_000) // Poll every 10 seconds
+ if (!_uiState.value.isDragging()) {
+ silentRefresh()
+ }
+ }
+ }
+ }
+
+ private suspend fun silentRefresh() {
+ val columnsResult = boardRepository.getColumns(boardId)
+ val cardsResult = cardRepository.getBoardCards(boardId)
+
+ if (columnsResult is ApiResult.Success) {
+ val cards = if (cardsResult is ApiResult.Success) cardsResult.data else emptyList()
+ val columnsWithCards = distributeCardsToColumns(columnsResult.data, cards)
+ _uiState.update { state ->
+ state.copy(columns = columnsWithCards)
+ }
+ }
+ }
+
+ private fun KanbanUiState.isDragging() = dragState.isDragging
+
+ fun loadBoard() {
+ viewModelScope.launch {
+ _uiState.update { it.copy(isLoading = true, error = null) }
+
+ val boardResult = boardRepository.getBoard(boardId)
+ val columnsResult = boardRepository.getColumns(boardId)
+ val cardsResult = cardRepository.getBoardCards(boardId)
+ Log.d(TAG, "loadBoard cardsResult: $cardsResult")
+
+ when {
+ boardResult is ApiResult.Success && columnsResult is ApiResult.Success -> {
+ // Get cards (may fail, that's ok - show empty)
+ val cards = if (cardsResult is ApiResult.Success) cardsResult.data else emptyList()
+ val columnsWithCards = distributeCardsToColumns(columnsResult.data, cards)
+
+ _uiState.update {
+ it.copy(
+ isLoading = false,
+ board = boardResult.data,
+ columns = columnsWithCards
+ )
+ }
+ }
+ boardResult is ApiResult.Error -> {
+ _uiState.update {
+ it.copy(isLoading = false, error = "Failed to load board: ${boardResult.message}")
+ }
+ }
+ columnsResult is ApiResult.Error -> {
+ _uiState.update {
+ it.copy(isLoading = false, error = "Failed to load columns: ${(columnsResult as ApiResult.Error).message}")
+ }
+ }
+ else -> {
+ _uiState.update {
+ it.copy(isLoading = false, error = "Network error")
+ }
+ }
+ }
+ }
+ }
+
+ private fun distributeCardsToColumns(columns: List, cards: List): List {
+ Log.d(TAG, "distributeCardsToColumns: ${cards.size} cards, ${columns.size} columns")
+ Log.d(TAG, "Column IDs: ${columns.map { "${it.name}=${it.id}" }}")
+ Log.d(TAG, "Card columnIds: ${cards.map { "${it.title}→${it.columnId}" }}")
+
+ val cardsByColumn = cards.groupBy { it.columnId }
+ Log.d(TAG, "Cards grouped by column: ${cardsByColumn.mapValues { it.value.map { c -> c.title } }}")
+
+ return columns.map { column ->
+ val columnCards = cardsByColumn[column.id]?.sortedBy { it.position } ?: emptyList()
+ Log.d(TAG, "Column '${column.name}' (${column.id}): ${columnCards.size} cards")
+ column.copy(cards = columnCards)
+ }
+ }
+
+ fun refresh() {
+ viewModelScope.launch {
+ _uiState.update { it.copy(isRefreshing = true) }
+ loadBoardData()
+ _uiState.update { it.copy(isRefreshing = false) }
+ }
+ }
+
+ private suspend fun loadBoardData() {
+ val columnsResult = boardRepository.getColumns(boardId)
+ val cardsResult = cardRepository.getBoardCards(boardId)
+
+ if (columnsResult is ApiResult.Success) {
+ val cards = if (cardsResult is ApiResult.Success) cardsResult.data else emptyList()
+ val columnsWithCards = distributeCardsToColumns(columnsResult.data, cards)
+ _uiState.update { it.copy(columns = columnsWithCards) }
+ }
+ }
+
+ // Column operations
+ fun showAddColumnDialog() {
+ _uiState.update { it.copy(showAddColumnDialog = true) }
+ }
+
+ fun hideAddColumnDialog() {
+ _uiState.update { it.copy(showAddColumnDialog = false) }
+ }
+
+ fun createColumn(name: String) {
+ viewModelScope.launch {
+ _uiState.update { it.copy(isLoading = true) }
+
+ val position = _uiState.value.columns.maxOfOrNull { it.position }?.plus(1) ?: 0
+
+ when (val result = boardRepository.createColumn(boardId, name, position)) {
+ is ApiResult.Success -> {
+ _uiState.update { state ->
+ state.copy(
+ isLoading = false,
+ showAddColumnDialog = false,
+ columns = state.columns + result.data
+ )
+ }
+ _events.emit(KanbanEvent.ColumnCreated)
+ }
+ is ApiResult.Error -> {
+ _uiState.update { it.copy(isLoading = false) }
+ _events.emit(KanbanEvent.ShowError("Failed to create column"))
+ }
+ is ApiResult.Exception -> {
+ _uiState.update { it.copy(isLoading = false) }
+ _events.emit(KanbanEvent.ShowError("Network error"))
+ }
+ }
+ }
+ }
+
+ fun showEditColumnDialog(column: Column) {
+ _uiState.update { it.copy(editingColumn = column) }
+ }
+
+ fun hideEditColumnDialog() {
+ _uiState.update { it.copy(editingColumn = null) }
+ }
+
+ fun updateColumn(columnId: String, name: String) {
+ viewModelScope.launch {
+ _uiState.update { it.copy(isLoading = true) }
+
+ when (val result = boardRepository.updateColumn(boardId, columnId, name, null)) {
+ is ApiResult.Success -> {
+ _uiState.update { state ->
+ state.copy(
+ isLoading = false,
+ editingColumn = null,
+ columns = state.columns.map {
+ if (it.id == columnId) result.data else it
+ }
+ )
+ }
+ _events.emit(KanbanEvent.ColumnUpdated)
+ }
+ is ApiResult.Error -> {
+ _uiState.update { it.copy(isLoading = false) }
+ _events.emit(KanbanEvent.ShowError("Failed to update column"))
+ }
+ is ApiResult.Exception -> {
+ _uiState.update { it.copy(isLoading = false) }
+ _events.emit(KanbanEvent.ShowError("Network error"))
+ }
+ }
+ }
+ }
+
+ fun deleteColumn(columnId: String) {
+ viewModelScope.launch {
+ _uiState.update { it.copy(isLoading = true) }
+
+ when (boardRepository.deleteColumn(boardId, columnId)) {
+ is ApiResult.Success -> {
+ _uiState.update { state ->
+ state.copy(
+ isLoading = false,
+ columns = state.columns.filter { it.id != columnId }
+ )
+ }
+ _events.emit(KanbanEvent.ColumnDeleted)
+ }
+ is ApiResult.Error -> {
+ _uiState.update { it.copy(isLoading = false) }
+ _events.emit(KanbanEvent.ShowError("Failed to delete column"))
+ }
+ is ApiResult.Exception -> {
+ _uiState.update { it.copy(isLoading = false) }
+ _events.emit(KanbanEvent.ShowError("Network error"))
+ }
+ }
+ }
+ }
+
+ // Card operations
+ fun showAddCardDialog(columnId: String) {
+ _uiState.update { it.copy(showAddCardDialog = columnId) }
+ }
+
+ fun hideAddCardDialog() {
+ _uiState.update { it.copy(showAddCardDialog = null) }
+ }
+
+ fun createCard(columnId: String, title: String) {
+ viewModelScope.launch {
+ _uiState.update { it.copy(isLoading = true) }
+
+ when (val result = cardRepository.createCard(boardId, columnId, title, null)) {
+ is ApiResult.Success -> {
+ _uiState.update { state ->
+ state.copy(
+ isLoading = false,
+ showAddCardDialog = null,
+ columns = state.columns.map { column ->
+ if (column.id == columnId) {
+ column.copy(cards = column.cards + result.data)
+ } else column
+ }
+ )
+ }
+ _events.emit(KanbanEvent.CardCreated)
+ }
+ is ApiResult.Error -> {
+ _uiState.update { it.copy(isLoading = false) }
+ _events.emit(KanbanEvent.ShowError("Failed to create card"))
+ }
+ is ApiResult.Exception -> {
+ _uiState.update { it.copy(isLoading = false) }
+ _events.emit(KanbanEvent.ShowError("Network error"))
+ }
+ }
+ }
+ }
+
+ // Drag and drop
+ fun startDragging(card: Card) {
+ _uiState.update {
+ it.copy(
+ dragState = DragState(
+ isDragging = true,
+ draggingCard = card,
+ sourceColumnId = card.columnId
+ )
+ )
+ }
+ }
+
+ fun updateDragTarget(columnId: String, position: Int) {
+ _uiState.update {
+ it.copy(
+ dragState = it.dragState.copy(
+ targetColumnId = columnId,
+ targetPosition = position
+ )
+ )
+ }
+ }
+
+ fun endDragging() {
+ val dragState = _uiState.value.dragState
+ val card = dragState.draggingCard
+ val targetColumnId = dragState.targetColumnId
+ val targetPosition = dragState.targetPosition
+
+ if (card != null && targetColumnId != null && targetPosition != null) {
+ // Check if actually moved
+ if (card.columnId != targetColumnId || card.position != targetPosition) {
+ moveCard(card.id, targetColumnId, targetPosition)
+ }
+ }
+
+ _uiState.update { it.copy(dragState = DragState()) }
+ }
+
+ fun cancelDragging() {
+ _uiState.update { it.copy(dragState = DragState()) }
+ }
+
+ private fun moveCard(cardId: Long, columnId: String, position: Int) {
+ viewModelScope.launch {
+ // Optimistic update
+ _uiState.update { state ->
+ val card = state.columns.flatMap { it.cards }.find { it.id == cardId } ?: return@update state
+
+ val updatedColumns = state.columns.map { column ->
+ when {
+ column.id == card.columnId && column.id != columnId -> {
+ // Remove from source column
+ column.copy(cards = column.cards.filter { it.id != cardId })
+ }
+ column.id == columnId -> {
+ // Add to target column
+ val cardsWithoutCard = column.cards.filter { it.id != cardId }
+ val updatedCard = card.copy(columnId = columnId, position = position)
+ val newCards = cardsWithoutCard.toMutableList().apply {
+ add(position.coerceIn(0, size), updatedCard)
+ }
+ column.copy(cards = newCards)
+ }
+ else -> column
+ }
+ }
+
+ state.copy(columns = updatedColumns)
+ }
+
+ // API call
+ when (cardRepository.moveCard(cardId, columnId, position)) {
+ is ApiResult.Success -> {
+ _events.emit(KanbanEvent.CardMoved)
+ }
+ is ApiResult.Error, is ApiResult.Exception -> {
+ // Rollback - reload data
+ loadBoardData()
+ _events.emit(KanbanEvent.ShowError("Failed to move card"))
+ }
+ }
+ }
+ }
+
+ fun onCardClick(cardId: Long) {
+ viewModelScope.launch {
+ _events.emit(KanbanEvent.NavigateToCard(cardId))
+ }
+ }
+}
diff --git a/app/src/main/java/com/fizzy/android/feature/notifications/NotificationsScreen.kt b/app/src/main/java/com/fizzy/android/feature/notifications/NotificationsScreen.kt
new file mode 100644
index 0000000..e4cfe9c
--- /dev/null
+++ b/app/src/main/java/com/fizzy/android/feature/notifications/NotificationsScreen.kt
@@ -0,0 +1,324 @@
+package com.fizzy.android.feature.notifications
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.filled.*
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import com.fizzy.android.core.ui.components.EmptyState
+import com.fizzy.android.core.ui.components.ErrorMessage
+import com.fizzy.android.core.ui.components.LoadingIndicator
+import com.fizzy.android.domain.model.Notification
+import com.fizzy.android.domain.model.NotificationType
+import kotlinx.coroutines.flow.collectLatest
+import java.time.LocalDate
+import java.time.ZoneId
+import java.time.format.DateTimeFormatter
+import java.time.format.FormatStyle
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun NotificationsScreen(
+ onBackClick: () -> Unit,
+ onNotificationClick: (Notification) -> Unit,
+ viewModel: NotificationsViewModel = hiltViewModel()
+) {
+ val uiState by viewModel.uiState.collectAsState()
+ val snackbarHostState = remember { SnackbarHostState() }
+
+ LaunchedEffect(Unit) {
+ viewModel.events.collectLatest { event ->
+ when (event) {
+ is NotificationsEvent.ShowError -> snackbarHostState.showSnackbar(event.message)
+ is NotificationsEvent.NavigateToCard -> {
+ val notification = uiState.notifications.find { it.cardId == event.cardId }
+ notification?.let { onNotificationClick(it) }
+ }
+ }
+ }
+ }
+
+ val unreadCount = uiState.notifications.count { !it.read }
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text("Notifications") },
+ navigationIcon = {
+ IconButton(onClick = onBackClick) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
+ }
+ },
+ actions = {
+ IconButton(onClick = viewModel::refresh) {
+ Icon(Icons.Default.Refresh, contentDescription = "Refresh")
+ }
+ if (unreadCount > 0) {
+ TextButton(onClick = viewModel::markAllAsRead) {
+ Text("Mark all read")
+ }
+ }
+ }
+ )
+ },
+ snackbarHost = { SnackbarHost(snackbarHostState) }
+ ) { paddingValues ->
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(paddingValues)
+ ) {
+ when {
+ uiState.isLoading && uiState.notifications.isEmpty() -> {
+ LoadingIndicator()
+ }
+ uiState.error != null && uiState.notifications.isEmpty() -> {
+ ErrorMessage(
+ message = uiState.error ?: "Unknown error",
+ onRetry = viewModel::loadNotifications
+ )
+ }
+ uiState.notifications.isEmpty() -> {
+ EmptyState(
+ icon = Icons.Default.Notifications,
+ title = "No notifications",
+ description = "You're all caught up!"
+ )
+ }
+ else -> {
+ NotificationsList(
+ groupedNotifications = uiState.groupedNotifications,
+ onNotificationClick = { notification ->
+ viewModel.onNotificationClick(notification)
+ },
+ onMarkAsRead = viewModel::markAsRead
+ )
+ }
+ }
+
+ // Show loading indicator when refreshing
+ if (uiState.isRefreshing) {
+ CircularProgressIndicator(
+ modifier = Modifier
+ .align(Alignment.TopCenter)
+ .padding(16.dp)
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun NotificationsList(
+ groupedNotifications: Map