Fix Fizzy API integration to match official documentation

- Update auth endpoints: POST /session and /session/magic_link
- Use card number (Int) instead of card ID in API paths
- Add separate card action endpoints: closure, triage, goldness, watch
- Implement wrapped request objects for all create/update operations
- Move tags to account level (GET /tags)
- Update notification endpoints to use /reading suffix
- Change HTTP methods from PATCH to PUT for updates
- Update all DTOs with correct field names and structures
- Update repository implementations for new API structure
This commit is contained in:
Paweł Orzech 2026-01-19 08:48:46 +00:00
parent 7ebc7c4458
commit 101bf72250
75 changed files with 9023 additions and 2 deletions

18
.gitignore vendored
View file

@ -24,4 +24,20 @@ hs_err_pid*
replay_pid* replay_pid*
# Kotlin Gradle plugin data, see https://kotlinlang.org/docs/whatsnew20.html#new-directory-for-kotlin-data-in-gradle-projects # Kotlin Gradle plugin data, see https://kotlinlang.org/docs/whatsnew20.html#new-directory-for-kotlin-data-in-gradle-projects
.kotlin/ .kotlin/
# Gradle
.gradle/
build/
!gradle/wrapper/gradle-wrapper.jar
# IDE
.idea/
*.iml
# Local configuration
local.properties
# OS files
.DS_Store
Thumbs.db

View file

@ -1,2 +1,43 @@
# Fuzzel # 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

125
app/build.gradle.kts Normal file
View file

@ -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")
}

38
app/proguard-rules.pro vendored Normal file
View file

@ -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.**

View file

@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application
android:name=".app.FizzyApplication"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Fizzy"
tools:targetApi="34">
<activity
android:name=".app.MainActivity"
android:exported="true"
android:theme="@style/Theme.Fizzy.Splash">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View file

@ -0,0 +1,7 @@
package com.fizzy.android.app
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp
class FizzyApplication : Application()

View file

@ -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 }
}
}
)
}
}
}

View file

@ -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()
}
}
}
}
}

View file

@ -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<Preferences> by preferencesDataStore(name = "fizzy_settings")
@Module
@InstallIn(SingletonComponent::class)
object DataModule {
@Provides
@Singleton
fun provideDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
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
}

View file

@ -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)
}

View file

@ -0,0 +1,85 @@
package com.fizzy.android.core.network
import retrofit2.Response
sealed class ApiResult<out T> {
data class Success<T>(val data: T) : ApiResult<T>()
data class Error(val code: Int, val message: String) : ApiResult<Nothing>()
data class Exception(val throwable: Throwable) : ApiResult<Nothing>()
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 <R> map(transform: (T) -> R): ApiResult<R> = when (this) {
is Success -> Success(transform(data))
is Error -> this
is Exception -> this
}
suspend fun <R> mapSuspend(transform: suspend (T) -> R): ApiResult<R> = when (this) {
is Success -> Success(transform(data))
is Error -> this
is Exception -> this
}
companion object {
suspend fun <T> from(block: suspend () -> Response<T>): ApiResult<T> {
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 <T, R> ApiResult<T>.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 <T> ApiResult<T>.onSuccess(action: (T) -> Unit): ApiResult<T> {
if (this is ApiResult.Success) {
action(data)
}
return this
}
inline fun <T> ApiResult<T>.onError(action: (Int, String) -> Unit): ApiResult<T> {
if (this is ApiResult.Error) {
action(code, message)
}
return this
}
inline fun <T> ApiResult<T>.onException(action: (Throwable) -> Unit): ApiResult<T> {
if (this is ApiResult.Exception) {
action(throwable)
}
return this
}

View file

@ -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
}
}

View file

@ -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)
}
}

View file

@ -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<String?>(null)
val currentInstance: StateFlow<String?> = _currentInstance.asStateFlow()
private val _currentToken = MutableStateFlow<String?>(null)
val currentToken: StateFlow<String?> = _currentToken.asStateFlow()
private val _accountSlug = MutableStateFlow<String?>(null)
val accountSlug: StateFlow<String?> = _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/"
}
}

View file

@ -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()
}
}
}

View file

@ -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
)
}
}

View file

@ -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
)
}

View file

@ -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

View file

@ -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
)
}

View file

@ -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
)
)

View file

@ -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<RequestMagicLinkResponse>
@POST("session/magic_link")
suspend fun verifyMagicLink(@Body request: VerifyMagicLinkRequest): Response<VerifyMagicLinkResponse>
@GET("my/identity.json")
suspend fun getCurrentIdentity(): Response<IdentityDto>
// Legacy alias
@GET("my/identity.json")
suspend fun getCurrentUser(): Response<UserDto>
// ==================== Users ====================
@GET("users")
suspend fun getUsers(): Response<List<UserDto>>
@GET("users/{userId}")
suspend fun getUser(@Path("userId") userId: String): Response<UserDto>
// ==================== Boards ====================
@GET("boards.json")
suspend fun getBoards(): Response<BoardsResponse>
@GET("boards/{boardId}.json")
suspend fun getBoard(@Path("boardId") boardId: String): Response<BoardResponse>
@POST("boards.json")
suspend fun createBoard(@Body request: CreateBoardRequest): Response<Unit>
@PUT("boards/{boardId}")
suspend fun updateBoard(
@Path("boardId") boardId: String,
@Body request: UpdateBoardRequest
): Response<Unit>
@DELETE("boards/{boardId}.json")
suspend fun deleteBoard(@Path("boardId") boardId: String): Response<Unit>
// ==================== Columns ====================
@GET("boards/{boardId}/columns.json")
suspend fun getColumns(@Path("boardId") boardId: String): Response<ColumnsResponse>
@POST("boards/{boardId}/columns.json")
suspend fun createColumn(
@Path("boardId") boardId: String,
@Body request: CreateColumnRequest
): Response<Unit>
@PUT("boards/{boardId}/columns/{columnId}")
suspend fun updateColumn(
@Path("boardId") boardId: String,
@Path("columnId") columnId: String,
@Body request: UpdateColumnRequest
): Response<Unit>
@DELETE("boards/{boardId}/columns/{columnId}.json")
suspend fun deleteColumn(
@Path("boardId") boardId: String,
@Path("columnId") columnId: String
): Response<Unit>
// ==================== Cards ====================
@GET("cards.json")
suspend fun getCards(@Query("board_ids[]") boardId: String? = null): Response<CardsResponse>
@GET("cards/{cardNumber}.json")
suspend fun getCard(@Path("cardNumber") cardNumber: Int): Response<CardResponse>
@POST("boards/{boardId}/cards.json")
suspend fun createCard(
@Path("boardId") boardId: String,
@Body request: CreateCardRequest
): Response<Unit>
@PUT("cards/{cardNumber}")
suspend fun updateCard(
@Path("cardNumber") cardNumber: Int,
@Body request: UpdateCardRequest
): Response<Unit>
@DELETE("cards/{cardNumber}.json")
suspend fun deleteCard(@Path("cardNumber") cardNumber: Int): Response<Unit>
// ==================== Card Actions ====================
// Close/Reopen
@POST("cards/{cardNumber}/closure")
suspend fun closeCard(@Path("cardNumber") cardNumber: Int): Response<Unit>
@DELETE("cards/{cardNumber}/closure")
suspend fun reopenCard(@Path("cardNumber") cardNumber: Int): Response<Unit>
// Not Now (defer/put aside)
@POST("cards/{cardNumber}/not_now")
suspend fun markCardNotNow(@Path("cardNumber") cardNumber: Int): Response<Unit>
// Triage (move to column)
@POST("cards/{cardNumber}/triage")
suspend fun triageCard(
@Path("cardNumber") cardNumber: Int,
@Body request: TriageCardRequest
): Response<Unit>
@DELETE("cards/{cardNumber}/triage")
suspend fun untriageCard(@Path("cardNumber") cardNumber: Int): Response<Unit>
// Priority (golden)
@POST("cards/{cardNumber}/goldness")
suspend fun markCardGolden(@Path("cardNumber") cardNumber: Int): Response<Unit>
@DELETE("cards/{cardNumber}/goldness")
suspend fun unmarkCardGolden(@Path("cardNumber") cardNumber: Int): Response<Unit>
// Watch
@POST("cards/{cardNumber}/watch")
suspend fun watchCard(@Path("cardNumber") cardNumber: Int): Response<Unit>
@DELETE("cards/{cardNumber}/watch")
suspend fun unwatchCard(@Path("cardNumber") cardNumber: Int): Response<Unit>
// ==================== Assignments ====================
@POST("cards/{cardNumber}/assignments")
suspend fun addAssignment(
@Path("cardNumber") cardNumber: Int,
@Body request: AssignmentRequest
): Response<Unit>
// 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<List<TagDto>>
// ==================== Taggings (Card Tags) ====================
@POST("cards/{cardNumber}/taggings")
suspend fun addTagging(
@Path("cardNumber") cardNumber: Int,
@Body request: TaggingRequest
): Response<Unit>
@DELETE("cards/{cardNumber}/taggings/{taggingId}")
suspend fun removeTagging(
@Path("cardNumber") cardNumber: Int,
@Path("taggingId") taggingId: String
): Response<Unit>
// ==================== Steps ====================
@GET("cards/{cardNumber}/steps.json")
suspend fun getSteps(@Path("cardNumber") cardNumber: Int): Response<StepsResponse>
@POST("cards/{cardNumber}/steps.json")
suspend fun createStep(
@Path("cardNumber") cardNumber: Int,
@Body request: CreateStepRequest
): Response<Unit>
@PUT("cards/{cardNumber}/steps/{stepId}")
suspend fun updateStep(
@Path("cardNumber") cardNumber: Int,
@Path("stepId") stepId: String,
@Body request: UpdateStepRequest
): Response<Unit>
@DELETE("cards/{cardNumber}/steps/{stepId}.json")
suspend fun deleteStep(
@Path("cardNumber") cardNumber: Int,
@Path("stepId") stepId: String
): Response<Unit>
// ==================== Comments ====================
@GET("cards/{cardNumber}/comments.json")
suspend fun getComments(@Path("cardNumber") cardNumber: Int): Response<CommentsResponse>
@POST("cards/{cardNumber}/comments.json")
suspend fun createComment(
@Path("cardNumber") cardNumber: Int,
@Body request: CreateCommentRequest
): Response<Unit>
@PUT("cards/{cardNumber}/comments/{commentId}")
suspend fun updateComment(
@Path("cardNumber") cardNumber: Int,
@Path("commentId") commentId: String,
@Body request: UpdateCommentRequest
): Response<Unit>
@DELETE("cards/{cardNumber}/comments/{commentId}.json")
suspend fun deleteComment(
@Path("cardNumber") cardNumber: Int,
@Path("commentId") commentId: String
): Response<Unit>
// ==================== Reactions ====================
@POST("cards/{cardNumber}/comments/{commentId}/reactions")
suspend fun addReaction(
@Path("cardNumber") cardNumber: Int,
@Path("commentId") commentId: String,
@Body request: CreateReactionRequest
): Response<Unit>
@DELETE("cards/{cardNumber}/comments/{commentId}/reactions/{reactionId}")
suspend fun removeReaction(
@Path("cardNumber") cardNumber: Int,
@Path("commentId") commentId: String,
@Path("reactionId") reactionId: String
): Response<Unit>
// ==================== Notifications ====================
@GET("notifications.json")
suspend fun getNotifications(): Response<NotificationsResponse>
@POST("notifications/{notificationId}/reading")
suspend fun markNotificationRead(@Path("notificationId") notificationId: String): Response<Unit>
@DELETE("notifications/{notificationId}/reading")
suspend fun markNotificationUnread(@Path("notificationId") notificationId: String): Response<Unit>
@POST("notifications/bulk_reading")
suspend fun markAllNotificationsRead(): Response<Unit>
}

View file

@ -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
)

View file

@ -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<BoardDto>
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
)
)
}

View file

@ -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<UserDto>? = null,
@Json(name = "tags") val tags: List<TagDto>? = null,
@Json(name = "steps") val steps: List<StepDto>? = 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<CardDto>
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<String>? = 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
)
)
}

View file

@ -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<CardDto>? = 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<ColumnDto>
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
)
)
}

View file

@ -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<ReactionDto>? = 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<UserDto>? = 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<CommentDto>
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)
)
}

View file

@ -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<IdentityAccountDto>? = 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

View file

@ -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<NotificationDto>,
@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()
)

View file

@ -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<StepDto>
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
)
)
}

View file

@ -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<TagDto>
// 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"
)

View file

@ -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"
)

View file

@ -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<List<Account>>
val activeAccount: Flow<Account?>
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<Account>
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<List<AccountData>>(accountListType)
private val _accountsFlow = MutableStateFlow<List<Account>>(loadAccounts())
override val accounts: Flow<List<Account>> = _accountsFlow.asStateFlow()
override val activeAccount: Flow<Account?> = _accountsFlow.map { accounts ->
accounts.find { it.isActive }
}
private fun loadAccounts(): List<Account> {
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<Account>) {
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<Account> {
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
)
}
}

View file

@ -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<ThemeMode>
suspend fun setThemeMode(mode: ThemeMode)
}
@Singleton
class SettingsStorageImpl @Inject constructor(
private val dataStore: DataStore<Preferences>
) : SettingsStorage {
override val themeMode: Flow<ThemeMode> = 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")
}
}

View file

@ -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<Boolean> = accountStorage.activeAccount.map { it != null }
override val currentAccount: Flow<Account?> = accountStorage.activeAccount
override val allAccounts: Flow<List<Account>> = 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<Unit> {
// 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<Account> {
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<Account> {
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<User> {
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)
}
}
}

View file

@ -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<List<Board>>(emptyList())
override fun observeBoards(): Flow<List<Board>> = _boardsFlow.asStateFlow()
override suspend fun getBoards(): ApiResult<List<Board>> {
return ApiResult.from {
apiService.getBoards()
}.map { response ->
val boards = response.map { it.toDomain() }
_boardsFlow.value = boards
boards
}
}
override suspend fun getBoard(boardId: String): ApiResult<Board> {
return ApiResult.from {
apiService.getBoard(boardId)
}.map { response ->
response.toDomain()
}
}
override suspend fun createBoard(name: String, description: String?): ApiResult<Board> {
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<Board> {
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<Unit> {
return ApiResult.from {
apiService.deleteBoard(boardId)
}.map {
_boardsFlow.value = _boardsFlow.value.filter { it.id != boardId }
}
}
override suspend fun getColumns(boardId: String): ApiResult<List<Column>> {
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<Column> {
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<Column> {
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<Unit> {
return ApiResult.from {
apiService.deleteColumn(boardId, columnId)
}
}
// Tags are now at account level, not board level
override suspend fun getTags(boardId: String): ApiResult<List<Tag>> {
return ApiResult.from {
apiService.getTags()
}.map { response ->
response.map { it.toDomain() }
}
}
override suspend fun createTag(boardId: String, name: String, color: String): ApiResult<Tag> {
// 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<Unit> {
// 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<List<User>> {
return ApiResult.from {
apiService.getUsers()
}.map { users ->
users.map { it.toDomain() }
}
}
override suspend fun refreshBoards() {
getBoards()
}
}

View file

@ -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<List<Card>> {
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<Card> {
return ApiResult.from {
apiService.getCard(cardId.toInt())
}.map { response ->
response.toDomain()
}
}
override suspend fun createCard(
boardId: String,
columnId: String,
title: String,
description: String?
): ApiResult<Card> {
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<Card> {
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<Unit> {
return ApiResult.from {
apiService.deleteCard(cardId.toInt())
}
}
override suspend fun moveCard(cardId: Long, columnId: String, position: Int): ApiResult<Card> {
// 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<Card> {
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<Card> {
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<Card> {
// 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<Card> {
// 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<Card> {
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<Card> {
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<Card> {
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<Card> {
// 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<Card> {
// 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<Card> {
// 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<List<Step>> {
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<Step> {
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<Step> {
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<Unit> {
return ApiResult.from {
apiService.deleteStep(cardId.toInt(), stepId.toString())
}
}
override suspend fun getComments(cardId: Long): ApiResult<List<Comment>> {
return ApiResult.from {
apiService.getComments(cardId.toInt())
}.map { response ->
response.map { it.toDomain() }
}
}
override suspend fun createComment(cardId: Long, content: String): ApiResult<Comment> {
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<Comment> {
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<Unit> {
return ApiResult.from {
apiService.deleteComment(cardId.toInt(), commentId.toString())
}
}
override suspend fun addReaction(cardId: Long, commentId: Long, emoji: String): ApiResult<Comment> {
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<Comment> {
// 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())
}
}
}

View file

@ -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<Int> = _unreadCount.asStateFlow()
override suspend fun getNotifications(): ApiResult<List<Notification>> {
return ApiResult.from {
apiService.getNotifications()
}.map { response ->
_unreadCount.value = response.unreadCount
response.notifications.map { it.toDomain() }
}
}
override suspend fun markAsRead(notificationId: Long): ApiResult<Unit> {
return ApiResult.from {
apiService.markNotificationRead(notificationId.toString())
}.map {
_unreadCount.value = (_unreadCount.value - 1).coerceAtLeast(0)
}
}
override suspend fun markAllAsRead(): ApiResult<Unit> {
return ApiResult.from {
apiService.markAllNotificationsRead()
}.map {
_unreadCount.value = 0
}
}
}

View file

@ -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("/")
}

View file

@ -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
)

View file

@ -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<User> = emptyList(),
val tags: List<Tag> = 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
}

View file

@ -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<Card> = emptyList(),
val cardsCount: Int = 0
)

View file

@ -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<Reaction> = emptyList()
)
data class Reaction(
val emoji: String,
val count: Int,
val users: List<User> = emptyList(),
val reactedByMe: Boolean = false
)

View file

@ -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
}

View file

@ -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
)

View file

@ -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
}
}

View file

@ -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
)

View file

@ -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<Boolean>
val currentAccount: Flow<Account?>
val allAccounts: Flow<List<Account>>
suspend fun requestMagicLink(instanceUrl: String, email: String): ApiResult<Unit>
suspend fun verifyMagicLink(instanceUrl: String, email: String, code: String): ApiResult<Account>
suspend fun loginWithToken(instanceUrl: String, token: String): ApiResult<Account>
suspend fun getCurrentUser(): ApiResult<User>
suspend fun switchAccount(accountId: String)
suspend fun logout(accountId: String)
suspend fun logoutAll()
suspend fun initializeActiveAccount()
}

View file

@ -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<List<Board>>
suspend fun getBoards(): ApiResult<List<Board>>
suspend fun getBoard(boardId: String): ApiResult<Board>
suspend fun createBoard(name: String, description: String?): ApiResult<Board>
suspend fun updateBoard(boardId: String, name: String?, description: String?): ApiResult<Board>
suspend fun deleteBoard(boardId: String): ApiResult<Unit>
suspend fun getColumns(boardId: String): ApiResult<List<Column>>
suspend fun createColumn(boardId: String, name: String, position: Int?): ApiResult<Column>
suspend fun updateColumn(boardId: String, columnId: String, name: String?, position: Int?): ApiResult<Column>
suspend fun deleteColumn(boardId: String, columnId: String): ApiResult<Unit>
suspend fun getTags(boardId: String): ApiResult<List<Tag>>
suspend fun createTag(boardId: String, name: String, color: String): ApiResult<Tag>
suspend fun deleteTag(boardId: String, tagId: Long): ApiResult<Unit>
suspend fun getBoardUsers(boardId: String): ApiResult<List<User>>
suspend fun refreshBoards()
}

View file

@ -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<List<Card>>
suspend fun getCard(cardId: Long): ApiResult<Card>
suspend fun createCard(boardId: String, columnId: String, title: String, description: String?): ApiResult<Card>
suspend fun updateCard(cardId: Long, title: String?, description: String?): ApiResult<Card>
suspend fun deleteCard(cardId: Long): ApiResult<Unit>
suspend fun moveCard(cardId: Long, columnId: String, position: Int): ApiResult<Card>
// Card actions
suspend fun closeCard(cardId: Long): ApiResult<Card>
suspend fun reopenCard(cardId: Long): ApiResult<Card>
suspend fun triageCard(cardId: Long, date: LocalDate): ApiResult<Card>
suspend fun deferCard(cardId: Long, date: LocalDate): ApiResult<Card>
suspend fun togglePriority(cardId: Long, priority: Boolean): ApiResult<Card>
suspend fun toggleWatch(cardId: Long, watching: Boolean): ApiResult<Card>
// Assignees
suspend fun addAssignee(cardId: Long, userId: Long): ApiResult<Card>
suspend fun removeAssignee(cardId: Long, userId: Long): ApiResult<Card>
// Tags
suspend fun addTag(cardId: Long, tagId: Long): ApiResult<Card>
suspend fun removeTag(cardId: Long, tagId: Long): ApiResult<Card>
// Steps
suspend fun getSteps(cardId: Long): ApiResult<List<Step>>
suspend fun createStep(cardId: Long, description: String): ApiResult<Step>
suspend fun updateStep(cardId: Long, stepId: Long, description: String?, completed: Boolean?, position: Int?): ApiResult<Step>
suspend fun deleteStep(cardId: Long, stepId: Long): ApiResult<Unit>
// Comments
suspend fun getComments(cardId: Long): ApiResult<List<Comment>>
suspend fun createComment(cardId: Long, content: String): ApiResult<Comment>
suspend fun updateComment(cardId: Long, commentId: Long, content: String): ApiResult<Comment>
suspend fun deleteComment(cardId: Long, commentId: Long): ApiResult<Unit>
// Reactions
suspend fun addReaction(cardId: Long, commentId: Long, emoji: String): ApiResult<Comment>
suspend fun removeReaction(cardId: Long, commentId: Long, emoji: String): ApiResult<Comment>
}

View file

@ -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<Int>
suspend fun getNotifications(): ApiResult<List<Notification>>
suspend fun markAsRead(notificationId: Long): ApiResult<Unit>
suspend fun markAllAsRead(): ApiResult<Unit>
}

View file

@ -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")
}
}
}

View file

@ -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<AuthUiState> = _uiState.asStateFlow()
private val _events = MutableSharedFlow<AuthEvent>()
val events: SharedFlow<AuthEvent> = _events.asSharedFlow()
val isLoggedIn: StateFlow<Boolean> = 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) }
}
}

View file

@ -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<Board>,
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")
}
}
)
}

View file

@ -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<Board> = emptyList(),
val filteredBoards: List<Board> = 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<BoardListUiState> = _uiState.asStateFlow()
private val _events = MutableSharedFlow<BoardListEvent>()
val events: SharedFlow<BoardListEvent> = _events.asSharedFlow()
val unreadNotificationsCount: StateFlow<Int> = 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<Board>, query: String): List<Board> {
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"))
}
}
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -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<Step> = emptyList(),
val comments: List<Comment> = emptyList(),
val boardTags: List<Tag> = emptyList(),
val boardUsers: List<User> = 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<CardDetailUiState> = _uiState.asStateFlow()
private val _events = MutableSharedFlow<CardDetailEvent>()
val events: SharedFlow<CardDetailEvent> = _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 */ }
}
}
}
}

View file

@ -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<Column>,
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")
}
}
)
}

View file

@ -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<Column> = 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<KanbanUiState> = _uiState.asStateFlow()
private val _events = MutableSharedFlow<KanbanEvent>()
val events: SharedFlow<KanbanEvent> = _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<Column>, cards: List<Card>): List<Column> {
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))
}
}
}

View file

@ -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<LocalDate, List<Notification>>,
onNotificationClick: (Notification) -> Unit,
onMarkAsRead: (Long) -> Unit
) {
LazyColumn(
modifier = Modifier.fillMaxSize()
) {
groupedNotifications.forEach { (date, notifications) ->
item(key = "header_$date") {
DateHeader(date = date)
}
items(
items = notifications,
key = { it.id }
) { notification ->
NotificationItem(
notification = notification,
onClick = { onNotificationClick(notification) },
onMarkAsRead = { onMarkAsRead(notification.id) }
)
}
}
}
}
@Composable
private fun DateHeader(date: LocalDate) {
val today = LocalDate.now()
val yesterday = today.minusDays(1)
val dateText = when (date) {
today -> "Today"
yesterday -> "Yesterday"
else -> date.format(DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM))
}
Text(
text = dateText,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f))
.padding(horizontal = 16.dp, vertical = 8.dp)
)
}
@Composable
private fun NotificationItem(
notification: Notification,
onClick: () -> Unit,
onMarkAsRead: () -> Unit
) {
val icon = when (notification.type) {
NotificationType.CARD_ASSIGNED -> Icons.Default.PersonAdd
NotificationType.CARD_MENTIONED -> Icons.Default.AlternateEmail
NotificationType.CARD_COMMENTED -> Icons.Default.Comment
NotificationType.CARD_MOVED -> Icons.Default.MoveDown
NotificationType.CARD_UPDATED -> Icons.Default.Edit
NotificationType.STEP_COMPLETED -> Icons.Default.CheckCircle
NotificationType.REACTION_ADDED -> Icons.Default.ThumbUp
NotificationType.BOARD_SHARED -> Icons.Default.Share
NotificationType.OTHER -> Icons.Default.Notifications
}
val iconColor = when (notification.type) {
NotificationType.CARD_ASSIGNED -> MaterialTheme.colorScheme.primary
NotificationType.CARD_MENTIONED -> MaterialTheme.colorScheme.secondary
NotificationType.CARD_COMMENTED -> MaterialTheme.colorScheme.tertiary
NotificationType.STEP_COMPLETED -> MaterialTheme.colorScheme.primary
else -> MaterialTheme.colorScheme.outline
}
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.background(
if (!notification.read)
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.1f)
else
MaterialTheme.colorScheme.surface
)
.padding(16.dp),
verticalAlignment = Alignment.Top
) {
// Unread indicator
if (!notification.read) {
Box(
modifier = Modifier
.padding(top = 6.dp)
.size(8.dp)
.background(
color = MaterialTheme.colorScheme.primary,
shape = CircleShape
)
)
Spacer(modifier = Modifier.width(8.dp))
}
// Icon
Surface(
modifier = Modifier.size(40.dp),
shape = CircleShape,
color = iconColor.copy(alpha = 0.15f)
) {
Box(contentAlignment = Alignment.Center) {
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = iconColor
)
}
}
Spacer(modifier = Modifier.width(12.dp))
// Content
Column(modifier = Modifier.weight(1f)) {
Text(
text = notification.title,
style = MaterialTheme.typography.bodyMedium,
fontWeight = if (!notification.read) FontWeight.SemiBold else FontWeight.Normal,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(2.dp))
Text(
text = notification.body,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(4.dp))
Row(
verticalAlignment = Alignment.CenterVertically
) {
if (notification.actor != null) {
Surface(
modifier = Modifier.size(16.dp),
shape = CircleShape,
color = MaterialTheme.colorScheme.primary
) {
Box(contentAlignment = Alignment.Center) {
Text(
text = notification.actor.name.first().uppercase(),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onPrimary
)
}
}
Spacer(modifier = Modifier.width(4.dp))
Text(
text = notification.actor.name,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.outline
)
Text(
text = "",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.outline
)
}
Text(
text = notification.createdAt
.atZone(ZoneId.systemDefault())
.format(DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.outline
)
}
}
// Mark as read button (if unread)
if (!notification.read) {
IconButton(
onClick = onMarkAsRead,
modifier = Modifier.size(32.dp)
) {
Icon(
Icons.Default.MarkEmailRead,
contentDescription = "Mark as read",
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.outline
)
}
}
}
HorizontalDivider()
}

View file

@ -0,0 +1,187 @@
package com.fizzy.android.feature.notifications
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.fizzy.android.core.network.ApiResult
import com.fizzy.android.domain.model.Notification
import com.fizzy.android.domain.repository.NotificationRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import java.time.LocalDate
import java.time.ZoneId
import javax.inject.Inject
data class NotificationsUiState(
val notifications: List<Notification> = emptyList(),
val groupedNotifications: Map<LocalDate, List<Notification>> = emptyMap(),
val isLoading: Boolean = false,
val isRefreshing: Boolean = false,
val error: String? = null
)
sealed class NotificationsEvent {
data class ShowError(val message: String) : NotificationsEvent()
data class NavigateToCard(val cardId: Long) : NotificationsEvent()
}
@HiltViewModel
class NotificationsViewModel @Inject constructor(
private val notificationRepository: NotificationRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(NotificationsUiState())
val uiState: StateFlow<NotificationsUiState> = _uiState.asStateFlow()
private val _events = MutableSharedFlow<NotificationsEvent>()
val events: SharedFlow<NotificationsEvent> = _events.asSharedFlow()
private var pollingJob: Job? = null
init {
loadNotifications()
startPolling()
}
override fun onCleared() {
super.onCleared()
pollingJob?.cancel()
}
private fun startPolling() {
pollingJob = viewModelScope.launch {
while (true) {
delay(30_000) // Poll every 30 seconds
silentRefresh()
}
}
}
private suspend fun silentRefresh() {
when (val result = notificationRepository.getNotifications()) {
is ApiResult.Success -> {
updateNotifications(result.data)
}
else -> { /* Ignore silent refresh failures */ }
}
}
fun loadNotifications() {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, error = null) }
when (val result = notificationRepository.getNotifications()) {
is ApiResult.Success -> {
updateNotifications(result.data)
_uiState.update { it.copy(isLoading = false) }
}
is ApiResult.Error -> {
_uiState.update {
it.copy(isLoading = false, error = "Failed to load notifications")
}
}
is ApiResult.Exception -> {
_uiState.update {
it.copy(isLoading = false, error = "Network error")
}
}
}
}
}
fun refresh() {
viewModelScope.launch {
_uiState.update { it.copy(isRefreshing = true) }
when (val result = notificationRepository.getNotifications()) {
is ApiResult.Success -> {
updateNotifications(result.data)
}
else -> { /* Ignore */ }
}
_uiState.update { it.copy(isRefreshing = false) }
}
}
private fun updateNotifications(notifications: List<Notification>) {
val grouped = notifications.groupBy { notification ->
notification.createdAt
.atZone(ZoneId.systemDefault())
.toLocalDate()
}.toSortedMap(compareByDescending { it })
_uiState.update {
it.copy(
notifications = notifications,
groupedNotifications = grouped
)
}
}
fun markAsRead(notificationId: Long) {
viewModelScope.launch {
when (notificationRepository.markAsRead(notificationId)) {
is ApiResult.Success -> {
_uiState.update { state ->
val updated = state.notifications.map { notification ->
if (notification.id == notificationId) {
notification.copy(read = true)
} else notification
}
val grouped = updated.groupBy { notification ->
notification.createdAt
.atZone(ZoneId.systemDefault())
.toLocalDate()
}.toSortedMap(compareByDescending { it })
state.copy(
notifications = updated,
groupedNotifications = grouped
)
}
}
else -> _events.emit(NotificationsEvent.ShowError("Failed to mark as read"))
}
}
}
fun markAllAsRead() {
viewModelScope.launch {
when (notificationRepository.markAllAsRead()) {
is ApiResult.Success -> {
_uiState.update { state ->
val updated = state.notifications.map { it.copy(read = true) }
val grouped = updated.groupBy { notification ->
notification.createdAt
.atZone(ZoneId.systemDefault())
.toLocalDate()
}.toSortedMap(compareByDescending { it })
state.copy(
notifications = updated,
groupedNotifications = grouped
)
}
}
else -> _events.emit(NotificationsEvent.ShowError("Failed to mark all as read"))
}
}
}
fun onNotificationClick(notification: Notification) {
viewModelScope.launch {
// Mark as read
if (!notification.read) {
markAsRead(notification.id)
}
// Navigate to card if applicable
notification.cardId?.let { cardId ->
_events.emit(NotificationsEvent.NavigateToCard(cardId))
}
}
}
}

View file

@ -0,0 +1,415 @@
package com.fizzy.android.feature.settings
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.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.Logout
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.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.fizzy.android.data.local.ThemeMode
import com.fizzy.android.domain.model.Account
import kotlinx.coroutines.flow.collectLatest
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(
onBackClick: () -> Unit,
onLogout: () -> Unit,
viewModel: SettingsViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsState()
LaunchedEffect(Unit) {
viewModel.events.collectLatest { event ->
when (event) {
SettingsEvent.Logout -> onLogout()
SettingsEvent.AccountSwitched -> { /* Stay on screen */ }
SettingsEvent.NavigateToAddAccount -> {
// Would navigate to auth screen for adding new account
// For simplicity, just log out and let user re-auth
}
}
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Settings") },
navigationIcon = {
IconButton(onClick = onBackClick) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
}
)
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.verticalScroll(rememberScrollState())
) {
// Current Account Section
SettingsSectionHeader("Account")
uiState.currentAccount?.let { account ->
AccountItem(
account = account,
isActive = true,
showSwitcher = uiState.allAccounts.size > 1,
onSwitcherClick = viewModel::showAccountSwitcher,
onLogoutClick = { viewModel.showLogoutConfirmation(account) }
)
}
// Multiple Accounts Management
if (uiState.allAccounts.size > 1) {
ListItem(
headlineContent = { Text("Switch Account") },
leadingContent = {
Icon(Icons.Default.SwitchAccount, contentDescription = null)
},
trailingContent = {
Text(
"${uiState.allAccounts.size} accounts",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline
)
},
modifier = Modifier.clickable { viewModel.showAccountSwitcher() }
)
}
ListItem(
headlineContent = { Text("Add Account") },
leadingContent = {
Icon(Icons.Default.PersonAdd, contentDescription = null)
},
modifier = Modifier.clickable { viewModel.navigateToAddAccount() }
)
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
// Appearance Section
SettingsSectionHeader("Appearance")
ThemeSelector(
currentTheme = uiState.themeMode,
onThemeSelect = viewModel::setThemeMode
)
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
// About Section
SettingsSectionHeader("About")
ListItem(
headlineContent = { Text("Version") },
supportingContent = { Text("1.0.0") },
leadingContent = {
Icon(Icons.Default.Info, contentDescription = null)
}
)
ListItem(
headlineContent = { Text("Open Source Licenses") },
leadingContent = {
Icon(Icons.Default.Description, contentDescription = null)
},
modifier = Modifier.clickable { /* Open licenses */ }
)
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
// Danger Zone
SettingsSectionHeader("Danger Zone", isDestructive = true)
ListItem(
headlineContent = {
Text(
"Log out from all accounts",
color = MaterialTheme.colorScheme.error
)
},
leadingContent = {
Icon(
Icons.AutoMirrored.Filled.Logout,
contentDescription = null,
tint = MaterialTheme.colorScheme.error
)
},
modifier = Modifier.clickable { viewModel.showLogoutAllConfirmation() }
)
Spacer(modifier = Modifier.height(32.dp))
}
}
// Account Switcher Dialog
if (uiState.showAccountSwitcher) {
AccountSwitcherDialog(
accounts = uiState.allAccounts,
currentAccountId = uiState.currentAccount?.id,
onAccountSelect = viewModel::switchAccount,
onAddAccount = viewModel::showAddAccount,
onDismiss = viewModel::hideAccountSwitcher
)
}
// Logout Confirmation Dialog
uiState.showLogoutConfirmation?.let { account ->
AlertDialog(
onDismissRequest = viewModel::hideLogoutConfirmation,
title = { Text("Log out?") },
text = {
Text("Are you sure you want to log out from ${account.email} on ${account.instanceHost}?")
},
confirmButton = {
TextButton(
onClick = { viewModel.logout(account.id) },
colors = ButtonDefaults.textButtonColors(
contentColor = MaterialTheme.colorScheme.error
)
) {
Text("Log out")
}
},
dismissButton = {
TextButton(onClick = viewModel::hideLogoutConfirmation) {
Text("Cancel")
}
}
)
}
// Logout All Confirmation Dialog
if (uiState.showLogoutAllConfirmation) {
AlertDialog(
onDismissRequest = viewModel::hideLogoutAllConfirmation,
title = { Text("Log out from all accounts?") },
text = {
Text("You will be logged out from all ${uiState.allAccounts.size} accounts. You will need to sign in again.")
},
confirmButton = {
TextButton(
onClick = viewModel::logoutAll,
colors = ButtonDefaults.textButtonColors(
contentColor = MaterialTheme.colorScheme.error
)
) {
Text("Log out all")
}
},
dismissButton = {
TextButton(onClick = viewModel::hideLogoutAllConfirmation) {
Text("Cancel")
}
}
)
}
}
@Composable
private fun SettingsSectionHeader(
title: String,
isDestructive: Boolean = false
) {
Text(
text = title,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = if (isDestructive)
MaterialTheme.colorScheme.error
else
MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
)
}
@Composable
private fun AccountItem(
account: Account,
isActive: Boolean,
showSwitcher: Boolean,
onSwitcherClick: () -> Unit,
onLogoutClick: () -> Unit
) {
ListItem(
headlineContent = {
Text(
text = account.userName,
fontWeight = if (isActive) FontWeight.SemiBold else FontWeight.Normal
)
},
supportingContent = {
Column {
Text(account.email)
Text(
account.instanceHost,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline
)
}
},
leadingContent = {
Surface(
modifier = Modifier.size(48.dp),
shape = CircleShape,
color = MaterialTheme.colorScheme.primary
) {
Box(contentAlignment = Alignment.Center) {
Text(
text = account.userName.first().uppercase(),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onPrimary
)
}
}
},
trailingContent = {
Row {
if (showSwitcher) {
IconButton(onClick = onSwitcherClick) {
Icon(
Icons.Default.SwitchAccount,
contentDescription = "Switch account"
)
}
}
IconButton(onClick = onLogoutClick) {
Icon(
Icons.AutoMirrored.Filled.Logout,
contentDescription = "Log out",
tint = MaterialTheme.colorScheme.error
)
}
}
}
)
}
@Composable
private fun ThemeSelector(
currentTheme: ThemeMode,
onThemeSelect: (ThemeMode) -> Unit
) {
Column {
ThemeMode.entries.forEach { theme ->
ListItem(
headlineContent = {
Text(
when (theme) {
ThemeMode.SYSTEM -> "System default"
ThemeMode.LIGHT -> "Light"
ThemeMode.DARK -> "Dark"
}
)
},
leadingContent = {
RadioButton(
selected = currentTheme == theme,
onClick = { onThemeSelect(theme) }
)
},
modifier = Modifier.clickable { onThemeSelect(theme) }
)
}
}
}
@Composable
private fun AccountSwitcherDialog(
accounts: List<Account>,
currentAccountId: String?,
onAccountSelect: (String) -> Unit,
onAddAccount: () -> Unit,
onDismiss: () -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Switch Account") },
text = {
LazyColumn {
items(accounts) { account ->
ListItem(
headlineContent = { Text(account.userName) },
supportingContent = {
Column {
Text(account.email)
Text(
account.instanceHost,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline
)
}
},
leadingContent = {
Surface(
modifier = Modifier.size(40.dp),
shape = CircleShape,
color = if (account.id == currentAccountId)
MaterialTheme.colorScheme.primary
else
MaterialTheme.colorScheme.surfaceVariant
) {
Box(contentAlignment = Alignment.Center) {
Text(
text = account.userName.first().uppercase(),
style = MaterialTheme.typography.titleSmall,
color = if (account.id == currentAccountId)
MaterialTheme.colorScheme.onPrimary
else
MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
},
trailingContent = {
if (account.id == currentAccountId) {
Icon(
Icons.Default.Check,
contentDescription = "Active",
tint = MaterialTheme.colorScheme.primary
)
}
},
modifier = Modifier.clickable {
if (account.id != currentAccountId) {
onAccountSelect(account.id)
}
}
)
HorizontalDivider()
}
item {
ListItem(
headlineContent = { Text("Add account") },
leadingContent = {
Icon(Icons.Default.PersonAdd, contentDescription = null)
},
modifier = Modifier.clickable { onAddAccount() }
)
}
}
},
confirmButton = {
TextButton(onClick = onDismiss) {
Text("Close")
}
}
)
}

View file

@ -0,0 +1,145 @@
package com.fizzy.android.feature.settings
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.fizzy.android.data.local.SettingsStorage
import com.fizzy.android.data.local.ThemeMode
import com.fizzy.android.domain.model.Account
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 SettingsUiState(
val currentAccount: Account? = null,
val allAccounts: List<Account> = emptyList(),
val themeMode: ThemeMode = ThemeMode.SYSTEM,
val showAccountSwitcher: Boolean = false,
val showAddAccount: Boolean = false,
val showLogoutConfirmation: Account? = null,
val showLogoutAllConfirmation: Boolean = false
)
sealed class SettingsEvent {
data object Logout : SettingsEvent()
data object AccountSwitched : SettingsEvent()
data object NavigateToAddAccount : SettingsEvent()
}
@HiltViewModel
class SettingsViewModel @Inject constructor(
private val authRepository: AuthRepository,
private val settingsStorage: SettingsStorage
) : ViewModel() {
private val _uiState = MutableStateFlow(SettingsUiState())
val uiState: StateFlow<SettingsUiState> = _uiState.asStateFlow()
private val _events = MutableSharedFlow<SettingsEvent>()
val events: SharedFlow<SettingsEvent> = _events.asSharedFlow()
init {
observeAccounts()
observeTheme()
}
private fun observeAccounts() {
viewModelScope.launch {
combine(
authRepository.currentAccount,
authRepository.allAccounts
) { current, all ->
Pair(current, all)
}.collect { (current, all) ->
_uiState.update {
it.copy(
currentAccount = current,
allAccounts = all
)
}
}
}
}
private fun observeTheme() {
viewModelScope.launch {
settingsStorage.themeMode.collect { theme ->
_uiState.update { it.copy(themeMode = theme) }
}
}
}
fun showAccountSwitcher() {
_uiState.update { it.copy(showAccountSwitcher = true) }
}
fun hideAccountSwitcher() {
_uiState.update { it.copy(showAccountSwitcher = false) }
}
fun switchAccount(accountId: String) {
viewModelScope.launch {
authRepository.switchAccount(accountId)
_uiState.update { it.copy(showAccountSwitcher = false) }
_events.emit(SettingsEvent.AccountSwitched)
}
}
fun showAddAccount() {
_uiState.update { it.copy(showAccountSwitcher = false, showAddAccount = true) }
}
fun hideAddAccount() {
_uiState.update { it.copy(showAddAccount = false) }
}
fun navigateToAddAccount() {
viewModelScope.launch {
_events.emit(SettingsEvent.NavigateToAddAccount)
}
}
fun showLogoutConfirmation(account: Account) {
_uiState.update { it.copy(showLogoutConfirmation = account) }
}
fun hideLogoutConfirmation() {
_uiState.update { it.copy(showLogoutConfirmation = null) }
}
fun logout(accountId: String) {
viewModelScope.launch {
authRepository.logout(accountId)
_uiState.update { it.copy(showLogoutConfirmation = null) }
// Check if there are any accounts left
val remaining = _uiState.value.allAccounts.filter { it.id != accountId }
if (remaining.isEmpty()) {
_events.emit(SettingsEvent.Logout)
}
}
}
fun showLogoutAllConfirmation() {
_uiState.update { it.copy(showLogoutAllConfirmation = true) }
}
fun hideLogoutAllConfirmation() {
_uiState.update { it.copy(showLogoutAllConfirmation = false) }
}
fun logoutAll() {
viewModelScope.launch {
authRepository.logoutAll()
_uiState.update { it.copy(showLogoutAllConfirmation = false) }
_events.emit(SettingsEvent.Logout)
}
}
fun setThemeMode(mode: ThemeMode) {
viewModelScope.launch {
settingsStorage.setThemeMode(mode)
}
}
}

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#2563EB"
android:pathData="M0,0h108v108h-108z"/>
</vector>

View file

@ -0,0 +1,56 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- Background circle -->
<path
android:fillColor="#2563EB"
android:pathData="M54,54m-40,0a40,40 0,1 1,80 0a40,40 0,1 1,-80 0"/>
<!-- Kanban board icon -->
<path
android:fillColor="#FFFFFF"
android:pathData="M30,35h48v38h-48z"
android:strokeWidth="2"
android:strokeColor="#FFFFFF"/>
<!-- Column dividers -->
<path
android:fillColor="#FFFFFF"
android:pathData="M46,35v38"/>
<path
android:fillColor="#FFFFFF"
android:strokeWidth="1"
android:strokeColor="#FFFFFF"
android:pathData="M46,35L46,73"/>
<path
android:fillColor="#FFFFFF"
android:strokeWidth="1"
android:strokeColor="#FFFFFF"
android:pathData="M62,35L62,73"/>
<!-- Cards in columns -->
<path
android:fillColor="#FFFFFF"
android:fillAlpha="0.7"
android:pathData="M33,40h10v6h-10z"/>
<path
android:fillColor="#FFFFFF"
android:fillAlpha="0.7"
android:pathData="M33,49h10v8h-10z"/>
<path
android:fillColor="#FFFFFF"
android:fillAlpha="0.7"
android:pathData="M49,40h10v10h-10z"/>
<path
android:fillColor="#FFFFFF"
android:fillAlpha="0.7"
android:pathData="M49,53h10v6h-10z"/>
<path
android:fillColor="#FFFFFF"
android:fillAlpha="0.7"
android:pathData="M65,40h10v5h-10z"/>
</vector>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="splash_background">#FFFFFF</color>
<color name="fizzy_primary">#2563EB</color>
<color name="fizzy_primary_dark">#1D4ED8</color>
<color name="fizzy_secondary">#10B981</color>
</resources>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Fizzy</string>
</resources>

View file

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.Fizzy" parent="android:Theme.Material.Light.NoActionBar">
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
</style>
<style name="Theme.Fizzy.Splash" parent="Theme.SplashScreen">
<item name="windowSplashScreenBackground">@color/splash_background</item>
<item name="windowSplashScreenAnimatedIcon">@drawable/ic_launcher_foreground</item>
<item name="postSplashScreenTheme">@style/Theme.Fizzy</item>
</style>
</resources>

6
build.gradle.kts Normal file
View file

@ -0,0 +1,6 @@
plugins {
id("com.android.application") version "8.2.2" apply false
id("org.jetbrains.kotlin.android") version "1.9.22" apply false
id("com.google.dagger.hilt.android") version "2.50" apply false
id("com.google.devtools.ksp") version "1.9.22-1.0.17" apply false
}

5
gradle.properties Normal file
View file

@ -0,0 +1,5 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true
org.gradle.java.home=/Applications/Android Studio.app/Contents/jbr/Contents/Home

BIN
gradle/wrapper/gradle-wrapper.jar vendored Normal file

Binary file not shown.

View file

@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

183
gradlew vendored Executable file
View file

@ -0,0 +1,183 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#)}; t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# temporary directory check times (less one)
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Collect all arguments for the java command;
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
# temporary directory checks; they're typically GRADLE_OPTS="${GRADLE_OPTS} -Djava.io.tmpdir=/some/path"
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n://www.gradle.org/docs/current/userguide/gradle_command_line.html
#
# The default xargs://www.gnu.org/software/findutils/manual/html_node/find_html/Invoking-xargs.html
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[`528444444]\\\[]~\\\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

18
settings.gradle.kts Normal file
View file

@ -0,0 +1,18 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "Fizzy"
include(":app")