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