From 101bf722506490c72426b62e650afdc2e2533c65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Orzech?= Date: Mon, 19 Jan 2026 08:48:46 +0000 Subject: [PATCH] Fix Fizzy API integration to match official documentation - Update auth endpoints: POST /session and /session/magic_link - Use card number (Int) instead of card ID in API paths - Add separate card action endpoints: closure, triage, goldness, watch - Implement wrapped request objects for all create/update operations - Move tags to account level (GET /tags) - Update notification endpoints to use /reading suffix - Change HTTP methods from PATCH to PUT for updates - Update all DTOs with correct field names and structures - Update repository implementations for new API structure --- .gitignore | 18 +- README.md | 43 +- app/build.gradle.kts | 125 ++ app/proguard-rules.pro | 38 + app/src/main/AndroidManifest.xml | 30 + .../com/fizzy/android/app/FizzyApplication.kt | 7 + .../com/fizzy/android/app/FizzyNavHost.kt | 119 ++ .../com/fizzy/android/app/MainActivity.kt | 50 + .../com/fizzy/android/core/di/DataModule.kt | 60 + .../fizzy/android/core/di/NetworkModule.kt | 66 + .../fizzy/android/core/network/ApiResult.kt | 85 ++ .../android/core/network/AuthInterceptor.kt | 50 + .../core/network/InstanceInterceptor.kt | 52 + .../android/core/network/InstanceManager.kt | 54 + .../android/core/ui/components/EmptyState.kt | 60 + .../core/ui/components/ErrorMessage.kt | 86 ++ .../core/ui/components/LoadingIndicator.kt | 37 + .../com/fizzy/android/core/ui/theme/Color.kt | 96 ++ .../com/fizzy/android/core/ui/theme/Theme.kt | 111 ++ .../com/fizzy/android/core/ui/theme/Type.kt | 115 ++ .../fizzy/android/data/api/FizzyApiService.kt | 243 ++++ .../com/fizzy/android/data/api/dto/AuthDto.kt | 34 + .../fizzy/android/data/api/dto/BoardDto.kt | 102 ++ .../com/fizzy/android/data/api/dto/CardDto.kt | 153 +++ .../fizzy/android/data/api/dto/ColumnDto.kt | 84 ++ .../fizzy/android/data/api/dto/CommentDto.kt | 100 ++ .../fizzy/android/data/api/dto/IdentityDto.kt | 69 ++ .../android/data/api/dto/NotificationDto.kt | 58 + .../com/fizzy/android/data/api/dto/StepDto.kt | 78 ++ .../com/fizzy/android/data/api/dto/TagDto.kt | 35 + .../com/fizzy/android/data/api/dto/UserDto.kt | 26 + .../android/data/local/AccountStorage.kt | 178 +++ .../android/data/local/SettingsStorage.kt | 46 + .../data/repository/AuthRepositoryImpl.kt | 179 +++ .../data/repository/BoardRepositoryImpl.kt | 200 ++++ .../data/repository/CardRepositoryImpl.kt | 384 ++++++ .../repository/NotificationRepositoryImpl.kt | 46 + .../com/fizzy/android/domain/model/Account.kt | 23 + .../com/fizzy/android/domain/model/Board.kt | 16 + .../com/fizzy/android/domain/model/Card.kt | 42 + .../com/fizzy/android/domain/model/Column.kt | 10 + .../com/fizzy/android/domain/model/Comment.kt | 20 + .../android/domain/model/Notification.kt | 27 + .../com/fizzy/android/domain/model/Step.kt | 13 + .../com/fizzy/android/domain/model/Tag.kt | 24 + .../com/fizzy/android/domain/model/User.kt | 9 + .../domain/repository/AuthRepository.kt | 23 + .../domain/repository/BoardRepository.kt | 31 + .../domain/repository/CardRepository.kt | 48 + .../repository/NotificationRepository.kt | 13 + .../fizzy/android/feature/auth/AuthScreen.kt | 536 +++++++++ .../android/feature/auth/AuthViewModel.kt | 243 ++++ .../android/feature/boards/BoardListScreen.kt | 426 +++++++ .../feature/boards/BoardListViewModel.kt | 219 ++++ .../android/feature/card/CardDetailScreen.kt | 1058 +++++++++++++++++ .../feature/card/CardDetailViewModel.kt | 495 ++++++++ .../android/feature/kanban/KanbanScreen.kt | 629 ++++++++++ .../android/feature/kanban/KanbanViewModel.kt | 413 +++++++ .../notifications/NotificationsScreen.kt | 324 +++++ .../notifications/NotificationsViewModel.kt | 187 +++ .../feature/settings/SettingsScreen.kt | 415 +++++++ .../feature/settings/SettingsViewModel.kt | 145 +++ .../res/drawable/ic_launcher_background.xml | 10 + .../res/drawable/ic_launcher_foreground.xml | 56 + .../res/mipmap-anydpi-v26/ic_launcher.xml | 5 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 5 + app/src/main/res/values/colors.xml | 7 + app/src/main/res/values/strings.xml | 4 + app/src/main/res/values/themes.xml | 13 + build.gradle.kts | 6 + gradle.properties | 5 + gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 63721 bytes gradle/wrapper/gradle-wrapper.properties | 7 + gradlew | 183 +++ settings.gradle.kts | 18 + 75 files changed, 9023 insertions(+), 2 deletions(-) create mode 100644 app/build.gradle.kts create mode 100644 app/proguard-rules.pro create mode 100644 app/src/main/AndroidManifest.xml create mode 100644 app/src/main/java/com/fizzy/android/app/FizzyApplication.kt create mode 100644 app/src/main/java/com/fizzy/android/app/FizzyNavHost.kt create mode 100644 app/src/main/java/com/fizzy/android/app/MainActivity.kt create mode 100644 app/src/main/java/com/fizzy/android/core/di/DataModule.kt create mode 100644 app/src/main/java/com/fizzy/android/core/di/NetworkModule.kt create mode 100644 app/src/main/java/com/fizzy/android/core/network/ApiResult.kt create mode 100644 app/src/main/java/com/fizzy/android/core/network/AuthInterceptor.kt create mode 100644 app/src/main/java/com/fizzy/android/core/network/InstanceInterceptor.kt create mode 100644 app/src/main/java/com/fizzy/android/core/network/InstanceManager.kt create mode 100644 app/src/main/java/com/fizzy/android/core/ui/components/EmptyState.kt create mode 100644 app/src/main/java/com/fizzy/android/core/ui/components/ErrorMessage.kt create mode 100644 app/src/main/java/com/fizzy/android/core/ui/components/LoadingIndicator.kt create mode 100644 app/src/main/java/com/fizzy/android/core/ui/theme/Color.kt create mode 100644 app/src/main/java/com/fizzy/android/core/ui/theme/Theme.kt create mode 100644 app/src/main/java/com/fizzy/android/core/ui/theme/Type.kt create mode 100644 app/src/main/java/com/fizzy/android/data/api/FizzyApiService.kt create mode 100644 app/src/main/java/com/fizzy/android/data/api/dto/AuthDto.kt create mode 100644 app/src/main/java/com/fizzy/android/data/api/dto/BoardDto.kt create mode 100644 app/src/main/java/com/fizzy/android/data/api/dto/CardDto.kt create mode 100644 app/src/main/java/com/fizzy/android/data/api/dto/ColumnDto.kt create mode 100644 app/src/main/java/com/fizzy/android/data/api/dto/CommentDto.kt create mode 100644 app/src/main/java/com/fizzy/android/data/api/dto/IdentityDto.kt create mode 100644 app/src/main/java/com/fizzy/android/data/api/dto/NotificationDto.kt create mode 100644 app/src/main/java/com/fizzy/android/data/api/dto/StepDto.kt create mode 100644 app/src/main/java/com/fizzy/android/data/api/dto/TagDto.kt create mode 100644 app/src/main/java/com/fizzy/android/data/api/dto/UserDto.kt create mode 100644 app/src/main/java/com/fizzy/android/data/local/AccountStorage.kt create mode 100644 app/src/main/java/com/fizzy/android/data/local/SettingsStorage.kt create mode 100644 app/src/main/java/com/fizzy/android/data/repository/AuthRepositoryImpl.kt create mode 100644 app/src/main/java/com/fizzy/android/data/repository/BoardRepositoryImpl.kt create mode 100644 app/src/main/java/com/fizzy/android/data/repository/CardRepositoryImpl.kt create mode 100644 app/src/main/java/com/fizzy/android/data/repository/NotificationRepositoryImpl.kt create mode 100644 app/src/main/java/com/fizzy/android/domain/model/Account.kt create mode 100644 app/src/main/java/com/fizzy/android/domain/model/Board.kt create mode 100644 app/src/main/java/com/fizzy/android/domain/model/Card.kt create mode 100644 app/src/main/java/com/fizzy/android/domain/model/Column.kt create mode 100644 app/src/main/java/com/fizzy/android/domain/model/Comment.kt create mode 100644 app/src/main/java/com/fizzy/android/domain/model/Notification.kt create mode 100644 app/src/main/java/com/fizzy/android/domain/model/Step.kt create mode 100644 app/src/main/java/com/fizzy/android/domain/model/Tag.kt create mode 100644 app/src/main/java/com/fizzy/android/domain/model/User.kt create mode 100644 app/src/main/java/com/fizzy/android/domain/repository/AuthRepository.kt create mode 100644 app/src/main/java/com/fizzy/android/domain/repository/BoardRepository.kt create mode 100644 app/src/main/java/com/fizzy/android/domain/repository/CardRepository.kt create mode 100644 app/src/main/java/com/fizzy/android/domain/repository/NotificationRepository.kt create mode 100644 app/src/main/java/com/fizzy/android/feature/auth/AuthScreen.kt create mode 100644 app/src/main/java/com/fizzy/android/feature/auth/AuthViewModel.kt create mode 100644 app/src/main/java/com/fizzy/android/feature/boards/BoardListScreen.kt create mode 100644 app/src/main/java/com/fizzy/android/feature/boards/BoardListViewModel.kt create mode 100644 app/src/main/java/com/fizzy/android/feature/card/CardDetailScreen.kt create mode 100644 app/src/main/java/com/fizzy/android/feature/card/CardDetailViewModel.kt create mode 100644 app/src/main/java/com/fizzy/android/feature/kanban/KanbanScreen.kt create mode 100644 app/src/main/java/com/fizzy/android/feature/kanban/KanbanViewModel.kt create mode 100644 app/src/main/java/com/fizzy/android/feature/notifications/NotificationsScreen.kt create mode 100644 app/src/main/java/com/fizzy/android/feature/notifications/NotificationsViewModel.kt create mode 100644 app/src/main/java/com/fizzy/android/feature/settings/SettingsScreen.kt create mode 100644 app/src/main/java/com/fizzy/android/feature/settings/SettingsViewModel.kt create mode 100644 app/src/main/res/drawable/ic_launcher_background.xml create mode 100644 app/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 app/src/main/res/values/colors.xml create mode 100644 app/src/main/res/values/strings.xml create mode 100644 app/src/main/res/values/themes.xml create mode 100644 build.gradle.kts create mode 100644 gradle.properties create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 settings.gradle.kts 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 0000000000000000000000000000000000000000..7f93135c49b765f8051ef9d0a6055ff8e46073d8 GIT binary patch literal 63721 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鯊%1Et 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⁢ zv5o9#E7!G0XmdzfsS{efPNi`1b44~SZ4Z8fuX!I}#8g+(wxzQwUT#Xb2(tbY1+EUhGKoT@KEU9Ktl>_0 z%bjDJg;#*gtJZv!-Zs`?^}v5eKmnbjqlvnSzE@_SP|LG_PJ6CYU+6zY6>92%E+ z=j@TZf-iW4(%U{lnYxQA;7Q!b;^brF8n0D>)`q5>|WDDXLrqYU_tKN2>=#@~OE7grMnNh?UOz-O~6 z6%rHy{#h9K0AT+lDC7q4{hw^|q6*Ry;;L%Q@)Ga}$60_q%D)rv(CtS$CQbpq9|y1e zRSrN4;$Jyl{m5bZw`$8TGvb}(LpY{-cQ)fcyJv7l3S52TLXVDsphtv&aPuDk1OzCA z4A^QtC(!11`IsNx_HnSy?>EKpHJWT^wmS~hc^p^zIIh@9f6U@I2 zC=Mve{j2^)mS#U$e{@Q?SO6%LDsXz@SY+=cK_QMmXBIU)j!$ajc-zLx3V60EXJ!qC zi<%2x8Q24YN+&8U@CIlN zrZkcT9yh%LrlGS9`G)KdP(@9Eo-AQz@8GEFWcb7U=a0H^ZVbLmz{+&M7W(nXJ4sN8 zJLR7eeK(K8`2-}j(T7JsO`L!+CvbueT%izanm-^A1Dn{`1Nw`9P?cq;7no+XfC`K(GO9?O^5zNIt4M+M8LM0=7Gz8UA@Z0N+lg+cX)NfazRu z5D)~HA^(u%w^cz+@2@_#S|u>GpB+j4KzQ^&Wcl9f z&hG#bCA(Yk0D&t&aJE^xME^&E-&xGHhXn%}psEIj641H+Nl-}boj;)Zt*t(4wZ5DN z@GXF$bL=&pBq-#vkTkh>7hl%K5|3 z{`Vn9b$iR-SoGENp}bn4;fR3>9sA%X2@1L3aE9yTra;Wb#_`xWwLSLdfu+PAu+o3| zGVnpzPr=ch{uuoHjtw7+_!L_2;knQ!DuDl0R`|%jr+}jFzXtrHIKc323?JO{l&;VF z*L1+}JU7%QJOg|5|Tc|D8fN zJORAg=_vsy{ak|o);@)Yh8Lkcg@$FG3k@ep36BRa^>~UmnRPziS>Z=`Jb2x*Q#`%A zU*i3&Vg?TluO@X0O;r2Jl6LKLUOVhSqg1*qOt^|8*c7 zo(298@+r$k_wQNGHv{|$tW(T8L+4_`FQ{kEW5Jgg{yf7ey4ss_(SNKfz(N9lx&a;< je(UuV8hP?p&}TPdm1I$XmG#(RzlD&B2izSj9sl%y5~4qc literal 0 HcmV?d00001 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")