mirror of
https://github.com/pawelorzech/Fuzzel.git
synced 2026-01-29 19:54:30 +00:00
Fix Fizzy API integration to match official documentation
- Update auth endpoints: POST /session and /session/magic_link - Use card number (Int) instead of card ID in API paths - Add separate card action endpoints: closure, triage, goldness, watch - Implement wrapped request objects for all create/update operations - Move tags to account level (GET /tags) - Update notification endpoints to use /reading suffix - Change HTTP methods from PATCH to PUT for updates - Update all DTOs with correct field names and structures - Update repository implementations for new API structure
This commit is contained in:
parent
7ebc7c4458
commit
101bf72250
75 changed files with 9023 additions and 2 deletions
18
.gitignore
vendored
18
.gitignore
vendored
|
|
@ -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/
|
||||
.kotlin/
|
||||
|
||||
# Gradle
|
||||
.gradle/
|
||||
build/
|
||||
!gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
*.iml
|
||||
|
||||
# Local configuration
|
||||
local.properties
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
|
|
|||
43
README.md
43
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
|
||||
|
|
|
|||
125
app/build.gradle.kts
Normal file
125
app/build.gradle.kts
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
id("com.google.dagger.hilt.android")
|
||||
id("com.google.devtools.ksp")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.fizzy.android"
|
||||
compileSdk = 34
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.fizzy.android"
|
||||
minSdk = 26
|
||||
targetSdk = 34
|
||||
versionCode = 1
|
||||
versionName = "1.0.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables {
|
||||
useSupportLibrary = true
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
}
|
||||
debug {
|
||||
isDebuggable = true
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
buildConfig = true
|
||||
}
|
||||
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion = "1.5.8"
|
||||
}
|
||||
|
||||
packaging {
|
||||
resources {
|
||||
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Core Android
|
||||
implementation("androidx.core:core-ktx:1.12.0")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
|
||||
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0")
|
||||
implementation("androidx.activity:activity-compose:1.8.2")
|
||||
|
||||
// Compose BOM
|
||||
implementation(platform("androidx.compose:compose-bom:2024.02.00"))
|
||||
implementation("androidx.compose.ui:ui")
|
||||
implementation("androidx.compose.ui:ui-graphics")
|
||||
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||
implementation("androidx.compose.material3:material3")
|
||||
implementation("androidx.compose.material:material-icons-extended")
|
||||
|
||||
// Navigation
|
||||
implementation("androidx.navigation:navigation-compose:2.7.7")
|
||||
|
||||
// Hilt
|
||||
implementation("com.google.dagger:hilt-android:2.50")
|
||||
ksp("com.google.dagger:hilt-android-compiler:2.50")
|
||||
implementation("androidx.hilt:hilt-navigation-compose:1.1.0")
|
||||
|
||||
// Retrofit + Moshi
|
||||
implementation("com.squareup.retrofit2:retrofit:2.9.0")
|
||||
implementation("com.squareup.retrofit2:converter-moshi:2.9.0")
|
||||
implementation("com.squareup.moshi:moshi-kotlin:1.15.0")
|
||||
ksp("com.squareup.moshi:moshi-kotlin-codegen:1.15.0")
|
||||
|
||||
// OkHttp
|
||||
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||
implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
|
||||
|
||||
// Coroutines
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
|
||||
|
||||
// DataStore
|
||||
implementation("androidx.datastore:datastore-preferences:1.0.0")
|
||||
|
||||
// Security (EncryptedSharedPreferences)
|
||||
implementation("androidx.security:security-crypto:1.1.0-alpha06")
|
||||
|
||||
// Coil for images
|
||||
implementation("io.coil-kt:coil-compose:2.5.0")
|
||||
|
||||
// Splash screen
|
||||
implementation("androidx.core:core-splashscreen:1.0.1")
|
||||
|
||||
// Testing
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3")
|
||||
testImplementation("io.mockk:mockk:1.13.9")
|
||||
testImplementation("app.cash.turbine:turbine:1.0.0")
|
||||
|
||||
androidTestImplementation("androidx.test.ext:junit:1.1.5")
|
||||
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
|
||||
androidTestImplementation(platform("androidx.compose:compose-bom:2024.02.00"))
|
||||
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
|
||||
|
||||
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||
debugImplementation("androidx.compose.ui:ui-test-manifest")
|
||||
}
|
||||
38
app/proguard-rules.pro
vendored
Normal file
38
app/proguard-rules.pro
vendored
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
# Add project specific ProGuard rules here.
|
||||
# You can control the set of applied configuration files using the
|
||||
# proguardFiles setting in build.gradle.kts.
|
||||
|
||||
# Keep Moshi JSON adapters
|
||||
-keep class com.fizzy.android.data.api.dto.** { *; }
|
||||
-keepclassmembers class com.fizzy.android.data.api.dto.** { *; }
|
||||
|
||||
# Keep Retrofit interfaces
|
||||
-keep,allowobfuscation,allowshrinking interface retrofit2.Call
|
||||
-keep,allowobfuscation,allowshrinking class retrofit2.Response
|
||||
-keep,allowobfuscation,allowshrinking class kotlin.coroutines.Continuation
|
||||
|
||||
# Keep Moshi adapters
|
||||
-keep class com.squareup.moshi.** { *; }
|
||||
-keep interface com.squareup.moshi.** { *; }
|
||||
-keepclassmembers class * {
|
||||
@com.squareup.moshi.FromJson *;
|
||||
@com.squareup.moshi.ToJson *;
|
||||
}
|
||||
|
||||
# Keep Hilt generated components
|
||||
-keep class dagger.hilt.** { *; }
|
||||
-keep class javax.inject.** { *; }
|
||||
-keep class * extends dagger.hilt.android.internal.managers.ComponentSupplier { *; }
|
||||
|
||||
# Keep domain models
|
||||
-keep class com.fizzy.android.domain.model.** { *; }
|
||||
|
||||
# Coroutines
|
||||
-keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {}
|
||||
-keepnames class kotlinx.coroutines.CoroutineExceptionHandler {}
|
||||
|
||||
# OkHttp
|
||||
-dontwarn okhttp3.internal.platform.**
|
||||
-dontwarn org.conscrypt.**
|
||||
-dontwarn org.bouncycastle.**
|
||||
-dontwarn org.openjsse.**
|
||||
30
app/src/main/AndroidManifest.xml
Normal file
30
app/src/main/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
|
||||
<application
|
||||
android:name=".app.FizzyApplication"
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.Fizzy"
|
||||
tools:targetApi="34">
|
||||
|
||||
<activity
|
||||
android:name=".app.MainActivity"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.Fizzy.Splash">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package com.fizzy.android.app
|
||||
|
||||
import android.app.Application
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
|
||||
@HiltAndroidApp
|
||||
class FizzyApplication : Application()
|
||||
119
app/src/main/java/com/fizzy/android/app/FizzyNavHost.kt
Normal file
119
app/src/main/java/com/fizzy/android/app/FizzyNavHost.kt
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
package com.fizzy.android.app
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation.navArgument
|
||||
import com.fizzy.android.feature.auth.AuthScreen
|
||||
import com.fizzy.android.feature.auth.AuthViewModel
|
||||
import com.fizzy.android.feature.boards.BoardListScreen
|
||||
import com.fizzy.android.feature.card.CardDetailScreen
|
||||
import com.fizzy.android.feature.kanban.KanbanScreen
|
||||
import com.fizzy.android.feature.notifications.NotificationsScreen
|
||||
import com.fizzy.android.feature.settings.SettingsScreen
|
||||
|
||||
sealed class Screen(val route: String) {
|
||||
data object Auth : Screen("auth")
|
||||
data object Boards : Screen("boards")
|
||||
data object Kanban : Screen("kanban/{boardId}") {
|
||||
fun createRoute(boardId: String) = "kanban/$boardId"
|
||||
}
|
||||
data object CardDetail : Screen("card/{cardId}") {
|
||||
fun createRoute(cardId: Long) = "card/$cardId"
|
||||
}
|
||||
data object Notifications : Screen("notifications")
|
||||
data object Settings : Screen("settings")
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun FizzyNavHost() {
|
||||
val navController = rememberNavController()
|
||||
val authViewModel: AuthViewModel = hiltViewModel()
|
||||
val isLoggedIn by authViewModel.isLoggedIn.collectAsState()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
authViewModel.initializeAuth()
|
||||
}
|
||||
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = if (isLoggedIn) Screen.Boards.route else Screen.Auth.route
|
||||
) {
|
||||
composable(Screen.Auth.route) {
|
||||
AuthScreen(
|
||||
onAuthSuccess = {
|
||||
navController.navigate(Screen.Boards.route) {
|
||||
popUpTo(Screen.Auth.route) { inclusive = true }
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
composable(Screen.Boards.route) {
|
||||
BoardListScreen(
|
||||
onBoardClick = { boardId ->
|
||||
navController.navigate(Screen.Kanban.createRoute(boardId))
|
||||
},
|
||||
onNotificationsClick = {
|
||||
navController.navigate(Screen.Notifications.route)
|
||||
},
|
||||
onSettingsClick = {
|
||||
navController.navigate(Screen.Settings.route)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
composable(
|
||||
route = Screen.Kanban.route,
|
||||
arguments = listOf(navArgument("boardId") { type = NavType.StringType })
|
||||
) { backStackEntry ->
|
||||
val boardId = backStackEntry.arguments?.getString("boardId") ?: return@composable
|
||||
KanbanScreen(
|
||||
boardId = boardId,
|
||||
onBackClick = { navController.popBackStack() },
|
||||
onCardClick = { cardId ->
|
||||
navController.navigate(Screen.CardDetail.createRoute(cardId))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
composable(
|
||||
route = Screen.CardDetail.route,
|
||||
arguments = listOf(navArgument("cardId") { type = NavType.LongType })
|
||||
) { backStackEntry ->
|
||||
val cardId = backStackEntry.arguments?.getLong("cardId") ?: return@composable
|
||||
CardDetailScreen(
|
||||
cardId = cardId,
|
||||
onBackClick = { navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
|
||||
composable(Screen.Notifications.route) {
|
||||
NotificationsScreen(
|
||||
onBackClick = { navController.popBackStack() },
|
||||
onNotificationClick = { notification ->
|
||||
notification.cardId?.let { cardId ->
|
||||
navController.navigate(Screen.CardDetail.createRoute(cardId))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
composable(Screen.Settings.route) {
|
||||
SettingsScreen(
|
||||
onBackClick = { navController.popBackStack() },
|
||||
onLogout = {
|
||||
navController.navigate(Screen.Auth.route) {
|
||||
popUpTo(Screen.Boards.route) { inclusive = true }
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
50
app/src/main/java/com/fizzy/android/app/MainActivity.kt
Normal file
50
app/src/main/java/com/fizzy/android/app/MainActivity.kt
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
package com.fizzy.android.app
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import com.fizzy.android.core.ui.theme.FizzyTheme
|
||||
import com.fizzy.android.data.local.SettingsStorage
|
||||
import com.fizzy.android.data.local.ThemeMode
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
||||
@Inject
|
||||
lateinit var settingsStorage: SettingsStorage
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
installSplashScreen()
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
|
||||
setContent {
|
||||
val themeMode by settingsStorage.themeMode.collectAsState(initial = ThemeMode.SYSTEM)
|
||||
val darkTheme = when (themeMode) {
|
||||
ThemeMode.SYSTEM -> isSystemInDarkTheme()
|
||||
ThemeMode.LIGHT -> false
|
||||
ThemeMode.DARK -> true
|
||||
}
|
||||
|
||||
FizzyTheme(darkTheme = darkTheme) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
FizzyNavHost()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
60
app/src/main/java/com/fizzy/android/core/di/DataModule.kt
Normal file
60
app/src/main/java/com/fizzy/android/core/di/DataModule.kt
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
package com.fizzy.android.core.di
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import com.fizzy.android.data.local.AccountStorage
|
||||
import com.fizzy.android.data.local.AccountStorageImpl
|
||||
import com.fizzy.android.data.local.SettingsStorage
|
||||
import com.fizzy.android.data.local.SettingsStorageImpl
|
||||
import com.fizzy.android.data.repository.*
|
||||
import com.fizzy.android.domain.repository.*
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
|
||||
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "fizzy_settings")
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object DataModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
|
||||
context.dataStore
|
||||
}
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
abstract class RepositoryModule {
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindAccountStorage(impl: AccountStorageImpl): AccountStorage
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindSettingsStorage(impl: SettingsStorageImpl): SettingsStorage
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindAuthRepository(impl: AuthRepositoryImpl): AuthRepository
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindBoardRepository(impl: BoardRepositoryImpl): BoardRepository
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindCardRepository(impl: CardRepositoryImpl): CardRepository
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindNotificationRepository(impl: NotificationRepositoryImpl): NotificationRepository
|
||||
}
|
||||
66
app/src/main/java/com/fizzy/android/core/di/NetworkModule.kt
Normal file
66
app/src/main/java/com/fizzy/android/core/di/NetworkModule.kt
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
package com.fizzy.android.core.di
|
||||
|
||||
import com.fizzy.android.core.network.AuthInterceptor
|
||||
import com.fizzy.android.core.network.InstanceInterceptor
|
||||
import com.fizzy.android.data.api.FizzyApiService
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.moshi.MoshiConverterFactory
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object NetworkModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideMoshi(): Moshi = Moshi.Builder()
|
||||
.addLast(KotlinJsonAdapterFactory())
|
||||
.build()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideLoggingInterceptor(): HttpLoggingInterceptor =
|
||||
HttpLoggingInterceptor().apply {
|
||||
level = HttpLoggingInterceptor.Level.BODY
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideOkHttpClient(
|
||||
loggingInterceptor: HttpLoggingInterceptor,
|
||||
instanceInterceptor: InstanceInterceptor,
|
||||
authInterceptor: AuthInterceptor
|
||||
): OkHttpClient = OkHttpClient.Builder()
|
||||
.addInterceptor(instanceInterceptor)
|
||||
.addInterceptor(authInterceptor)
|
||||
.addInterceptor(loggingInterceptor)
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.writeTimeout(30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideRetrofit(
|
||||
okHttpClient: OkHttpClient,
|
||||
moshi: Moshi
|
||||
): Retrofit = Retrofit.Builder()
|
||||
.baseUrl("https://placeholder.fizzy.com/") // Will be overridden by InstanceInterceptor
|
||||
.client(okHttpClient)
|
||||
.addConverterFactory(MoshiConverterFactory.create(moshi))
|
||||
.build()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideFizzyApiService(retrofit: Retrofit): FizzyApiService =
|
||||
retrofit.create(FizzyApiService::class.java)
|
||||
}
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
package com.fizzy.android.core.network
|
||||
|
||||
import retrofit2.Response
|
||||
|
||||
sealed class ApiResult<out T> {
|
||||
data class Success<T>(val data: T) : ApiResult<T>()
|
||||
data class Error(val code: Int, val message: String) : ApiResult<Nothing>()
|
||||
data class Exception(val throwable: Throwable) : ApiResult<Nothing>()
|
||||
|
||||
val isSuccess: Boolean get() = this is Success
|
||||
val isError: Boolean get() = this is Error
|
||||
val isException: Boolean get() = this is Exception
|
||||
|
||||
fun getOrNull(): T? = (this as? Success)?.data
|
||||
|
||||
fun getOrThrow(): T = when (this) {
|
||||
is Success -> data
|
||||
is Error -> throw RuntimeException("API Error: $code - $message")
|
||||
is Exception -> throw throwable
|
||||
}
|
||||
|
||||
fun <R> map(transform: (T) -> R): ApiResult<R> = when (this) {
|
||||
is Success -> Success(transform(data))
|
||||
is Error -> this
|
||||
is Exception -> this
|
||||
}
|
||||
|
||||
suspend fun <R> mapSuspend(transform: suspend (T) -> R): ApiResult<R> = when (this) {
|
||||
is Success -> Success(transform(data))
|
||||
is Error -> this
|
||||
is Exception -> this
|
||||
}
|
||||
|
||||
companion object {
|
||||
suspend fun <T> from(block: suspend () -> Response<T>): ApiResult<T> {
|
||||
return try {
|
||||
val response = block()
|
||||
if (response.isSuccessful) {
|
||||
val body = response.body()
|
||||
if (body != null) {
|
||||
Success(body)
|
||||
} else {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
Success(Unit as T)
|
||||
}
|
||||
} else {
|
||||
Error(response.code(), response.message())
|
||||
}
|
||||
} catch (e: kotlin.Exception) {
|
||||
Exception(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inline fun <T, R> ApiResult<T>.fold(
|
||||
onSuccess: (T) -> R,
|
||||
onError: (Int, String) -> R,
|
||||
onException: (Throwable) -> R
|
||||
): R = when (this) {
|
||||
is ApiResult.Success -> onSuccess(data)
|
||||
is ApiResult.Error -> onError(code, message)
|
||||
is ApiResult.Exception -> onException(throwable)
|
||||
}
|
||||
|
||||
inline fun <T> ApiResult<T>.onSuccess(action: (T) -> Unit): ApiResult<T> {
|
||||
if (this is ApiResult.Success) {
|
||||
action(data)
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
inline fun <T> ApiResult<T>.onError(action: (Int, String) -> Unit): ApiResult<T> {
|
||||
if (this is ApiResult.Error) {
|
||||
action(code, message)
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
inline fun <T> ApiResult<T>.onException(action: (Throwable) -> Unit): ApiResult<T> {
|
||||
if (this is ApiResult.Exception) {
|
||||
action(throwable)
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
package com.fizzy.android.core.network
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class InstanceManager @Inject constructor() {
|
||||
|
||||
private val _currentInstance = MutableStateFlow<String?>(null)
|
||||
val currentInstance: StateFlow<String?> = _currentInstance.asStateFlow()
|
||||
|
||||
private val _currentToken = MutableStateFlow<String?>(null)
|
||||
val currentToken: StateFlow<String?> = _currentToken.asStateFlow()
|
||||
|
||||
private val _accountSlug = MutableStateFlow<String?>(null)
|
||||
val accountSlug: StateFlow<String?> = _accountSlug.asStateFlow()
|
||||
|
||||
fun setInstance(baseUrl: String, token: String, slug: String? = null) {
|
||||
val normalizedUrl = normalizeUrl(baseUrl)
|
||||
_currentInstance.value = normalizedUrl
|
||||
_currentToken.value = token
|
||||
_accountSlug.value = slug?.removePrefix("/")
|
||||
}
|
||||
|
||||
fun clearInstance() {
|
||||
_currentInstance.value = null
|
||||
_currentToken.value = null
|
||||
_accountSlug.value = null
|
||||
}
|
||||
|
||||
fun getBaseUrl(): String? = _currentInstance.value
|
||||
|
||||
fun getToken(): String? = _currentToken.value
|
||||
|
||||
fun getAccountSlug(): String? = _accountSlug.value
|
||||
|
||||
private fun normalizeUrl(url: String): String {
|
||||
var normalized = url.trim()
|
||||
if (!normalized.startsWith("http://") && !normalized.startsWith("https://")) {
|
||||
normalized = "https://$normalized"
|
||||
}
|
||||
if (!normalized.endsWith("/")) {
|
||||
normalized = "$normalized/"
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val OFFICIAL_INSTANCE = "https://fizzy.com/"
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
96
app/src/main/java/com/fizzy/android/core/ui/theme/Color.kt
Normal file
96
app/src/main/java/com/fizzy/android/core/ui/theme/Color.kt
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
package com.fizzy.android.core.ui.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
// Primary colors - Fizzy Blue
|
||||
val FizzyBlue = Color(0xFF2563EB)
|
||||
val FizzyBlueDark = Color(0xFF1D4ED8)
|
||||
val FizzyBlueLight = Color(0xFF60A5FA)
|
||||
|
||||
// Secondary colors - Teal/Green
|
||||
val FizzyTeal = Color(0xFF10B981)
|
||||
val FizzyTealDark = Color(0xFF059669)
|
||||
val FizzyTealLight = Color(0xFF34D399)
|
||||
|
||||
// Status colors
|
||||
val FizzyGold = Color(0xFFF59E0B) // Priority
|
||||
val FizzyOrange = Color(0xFFF97316) // Triage
|
||||
val FizzyRed = Color(0xFFEF4444) // Closed/Error
|
||||
val FizzyPurple = Color(0xFF8B5CF6) // Deferred
|
||||
val FizzyGreen = Color(0xFF22C55E) // Success
|
||||
|
||||
// Neutral colors - Light theme
|
||||
val NeutralWhite = Color(0xFFFFFFFF)
|
||||
val NeutralGray50 = Color(0xFFF9FAFB)
|
||||
val NeutralGray100 = Color(0xFFF3F4F6)
|
||||
val NeutralGray200 = Color(0xFFE5E7EB)
|
||||
val NeutralGray300 = Color(0xFFD1D5DB)
|
||||
val NeutralGray400 = Color(0xFF9CA3AF)
|
||||
val NeutralGray500 = Color(0xFF6B7280)
|
||||
val NeutralGray600 = Color(0xFF4B5563)
|
||||
val NeutralGray700 = Color(0xFF374151)
|
||||
val NeutralGray800 = Color(0xFF1F2937)
|
||||
val NeutralGray900 = Color(0xFF111827)
|
||||
val NeutralBlack = Color(0xFF000000)
|
||||
|
||||
// Material 3 Light Scheme
|
||||
val md_theme_light_primary = FizzyBlue
|
||||
val md_theme_light_onPrimary = NeutralWhite
|
||||
val md_theme_light_primaryContainer = Color(0xFFD6E4FF)
|
||||
val md_theme_light_onPrimaryContainer = Color(0xFF001A41)
|
||||
val md_theme_light_secondary = FizzyTeal
|
||||
val md_theme_light_onSecondary = NeutralWhite
|
||||
val md_theme_light_secondaryContainer = Color(0xFFB4F1DF)
|
||||
val md_theme_light_onSecondaryContainer = Color(0xFF00201A)
|
||||
val md_theme_light_tertiary = FizzyPurple
|
||||
val md_theme_light_onTertiary = NeutralWhite
|
||||
val md_theme_light_tertiaryContainer = Color(0xFFEADDFF)
|
||||
val md_theme_light_onTertiaryContainer = Color(0xFF21005D)
|
||||
val md_theme_light_error = FizzyRed
|
||||
val md_theme_light_errorContainer = Color(0xFFFFDAD6)
|
||||
val md_theme_light_onError = NeutralWhite
|
||||
val md_theme_light_onErrorContainer = Color(0xFF410002)
|
||||
val md_theme_light_background = NeutralGray50
|
||||
val md_theme_light_onBackground = NeutralGray900
|
||||
val md_theme_light_surface = NeutralWhite
|
||||
val md_theme_light_onSurface = NeutralGray900
|
||||
val md_theme_light_surfaceVariant = NeutralGray100
|
||||
val md_theme_light_onSurfaceVariant = NeutralGray700
|
||||
val md_theme_light_outline = NeutralGray400
|
||||
val md_theme_light_inverseOnSurface = NeutralGray50
|
||||
val md_theme_light_inverseSurface = NeutralGray800
|
||||
val md_theme_light_inversePrimary = FizzyBlueLight
|
||||
val md_theme_light_surfaceTint = FizzyBlue
|
||||
val md_theme_light_outlineVariant = NeutralGray200
|
||||
val md_theme_light_scrim = NeutralBlack
|
||||
|
||||
// Material 3 Dark Scheme
|
||||
val md_theme_dark_primary = FizzyBlueLight
|
||||
val md_theme_dark_onPrimary = Color(0xFF002E6A)
|
||||
val md_theme_dark_primaryContainer = FizzyBlueDark
|
||||
val md_theme_dark_onPrimaryContainer = Color(0xFFD6E4FF)
|
||||
val md_theme_dark_secondary = FizzyTealLight
|
||||
val md_theme_dark_onSecondary = Color(0xFF00382E)
|
||||
val md_theme_dark_secondaryContainer = FizzyTealDark
|
||||
val md_theme_dark_onSecondaryContainer = Color(0xFFB4F1DF)
|
||||
val md_theme_dark_tertiary = Color(0xFFCFBCFF)
|
||||
val md_theme_dark_onTertiary = Color(0xFF381E72)
|
||||
val md_theme_dark_tertiaryContainer = Color(0xFF4F378B)
|
||||
val md_theme_dark_onTertiaryContainer = Color(0xFFEADDFF)
|
||||
val md_theme_dark_error = Color(0xFFFFB4AB)
|
||||
val md_theme_dark_errorContainer = Color(0xFF93000A)
|
||||
val md_theme_dark_onError = Color(0xFF690005)
|
||||
val md_theme_dark_onErrorContainer = Color(0xFFFFDAD6)
|
||||
val md_theme_dark_background = NeutralGray900
|
||||
val md_theme_dark_onBackground = NeutralGray100
|
||||
val md_theme_dark_surface = NeutralGray800
|
||||
val md_theme_dark_onSurface = NeutralGray100
|
||||
val md_theme_dark_surfaceVariant = NeutralGray700
|
||||
val md_theme_dark_onSurfaceVariant = NeutralGray300
|
||||
val md_theme_dark_outline = NeutralGray500
|
||||
val md_theme_dark_inverseOnSurface = NeutralGray900
|
||||
val md_theme_dark_inverseSurface = NeutralGray100
|
||||
val md_theme_dark_inversePrimary = FizzyBlue
|
||||
val md_theme_dark_surfaceTint = FizzyBlueLight
|
||||
val md_theme_dark_outlineVariant = NeutralGray600
|
||||
val md_theme_dark_scrim = NeutralBlack
|
||||
111
app/src/main/java/com/fizzy/android/core/ui/theme/Theme.kt
Normal file
111
app/src/main/java/com/fizzy/android/core/ui/theme/Theme.kt
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
package com.fizzy.android.core.ui.theme
|
||||
|
||||
import android.app.Activity
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.core.view.WindowCompat
|
||||
|
||||
private val LightColorScheme = lightColorScheme(
|
||||
primary = md_theme_light_primary,
|
||||
onPrimary = md_theme_light_onPrimary,
|
||||
primaryContainer = md_theme_light_primaryContainer,
|
||||
onPrimaryContainer = md_theme_light_onPrimaryContainer,
|
||||
secondary = md_theme_light_secondary,
|
||||
onSecondary = md_theme_light_onSecondary,
|
||||
secondaryContainer = md_theme_light_secondaryContainer,
|
||||
onSecondaryContainer = md_theme_light_onSecondaryContainer,
|
||||
tertiary = md_theme_light_tertiary,
|
||||
onTertiary = md_theme_light_onTertiary,
|
||||
tertiaryContainer = md_theme_light_tertiaryContainer,
|
||||
onTertiaryContainer = md_theme_light_onTertiaryContainer,
|
||||
error = md_theme_light_error,
|
||||
errorContainer = md_theme_light_errorContainer,
|
||||
onError = md_theme_light_onError,
|
||||
onErrorContainer = md_theme_light_onErrorContainer,
|
||||
background = md_theme_light_background,
|
||||
onBackground = md_theme_light_onBackground,
|
||||
surface = md_theme_light_surface,
|
||||
onSurface = md_theme_light_onSurface,
|
||||
surfaceVariant = md_theme_light_surfaceVariant,
|
||||
onSurfaceVariant = md_theme_light_onSurfaceVariant,
|
||||
outline = md_theme_light_outline,
|
||||
inverseOnSurface = md_theme_light_inverseOnSurface,
|
||||
inverseSurface = md_theme_light_inverseSurface,
|
||||
inversePrimary = md_theme_light_inversePrimary,
|
||||
surfaceTint = md_theme_light_surfaceTint,
|
||||
outlineVariant = md_theme_light_outlineVariant,
|
||||
scrim = md_theme_light_scrim
|
||||
)
|
||||
|
||||
private val DarkColorScheme = darkColorScheme(
|
||||
primary = md_theme_dark_primary,
|
||||
onPrimary = md_theme_dark_onPrimary,
|
||||
primaryContainer = md_theme_dark_primaryContainer,
|
||||
onPrimaryContainer = md_theme_dark_onPrimaryContainer,
|
||||
secondary = md_theme_dark_secondary,
|
||||
onSecondary = md_theme_dark_onSecondary,
|
||||
secondaryContainer = md_theme_dark_secondaryContainer,
|
||||
onSecondaryContainer = md_theme_dark_onSecondaryContainer,
|
||||
tertiary = md_theme_dark_tertiary,
|
||||
onTertiary = md_theme_dark_onTertiary,
|
||||
tertiaryContainer = md_theme_dark_tertiaryContainer,
|
||||
onTertiaryContainer = md_theme_dark_onTertiaryContainer,
|
||||
error = md_theme_dark_error,
|
||||
errorContainer = md_theme_dark_errorContainer,
|
||||
onError = md_theme_dark_onError,
|
||||
onErrorContainer = md_theme_dark_onErrorContainer,
|
||||
background = md_theme_dark_background,
|
||||
onBackground = md_theme_dark_onBackground,
|
||||
surface = md_theme_dark_surface,
|
||||
onSurface = md_theme_dark_onSurface,
|
||||
surfaceVariant = md_theme_dark_surfaceVariant,
|
||||
onSurfaceVariant = md_theme_dark_onSurfaceVariant,
|
||||
outline = md_theme_dark_outline,
|
||||
inverseOnSurface = md_theme_dark_inverseOnSurface,
|
||||
inverseSurface = md_theme_dark_inverseSurface,
|
||||
inversePrimary = md_theme_dark_inversePrimary,
|
||||
surfaceTint = md_theme_dark_surfaceTint,
|
||||
outlineVariant = md_theme_dark_outlineVariant,
|
||||
scrim = md_theme_dark_scrim
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun FizzyTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
dynamicColor: Boolean = false,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val colorScheme = when {
|
||||
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||
val context = LocalContext.current
|
||||
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||
}
|
||||
darkTheme -> DarkColorScheme
|
||||
else -> LightColorScheme
|
||||
}
|
||||
|
||||
val view = LocalView.current
|
||||
if (!view.isInEditMode) {
|
||||
SideEffect {
|
||||
val window = (view.context as Activity).window
|
||||
window.statusBarColor = colorScheme.background.toArgb()
|
||||
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme
|
||||
}
|
||||
}
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = Typography,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
115
app/src/main/java/com/fizzy/android/core/ui/theme/Type.kt
Normal file
115
app/src/main/java/com/fizzy/android/core/ui/theme/Type.kt
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
package com.fizzy.android.core.ui.theme
|
||||
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
val Typography = Typography(
|
||||
displayLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 57.sp,
|
||||
lineHeight = 64.sp,
|
||||
letterSpacing = (-0.25).sp
|
||||
),
|
||||
displayMedium = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 45.sp,
|
||||
lineHeight = 52.sp,
|
||||
letterSpacing = 0.sp
|
||||
),
|
||||
displaySmall = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 36.sp,
|
||||
lineHeight = 44.sp,
|
||||
letterSpacing = 0.sp
|
||||
),
|
||||
headlineLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 32.sp,
|
||||
lineHeight = 40.sp,
|
||||
letterSpacing = 0.sp
|
||||
),
|
||||
headlineMedium = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 28.sp,
|
||||
lineHeight = 36.sp,
|
||||
letterSpacing = 0.sp
|
||||
),
|
||||
headlineSmall = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 24.sp,
|
||||
lineHeight = 32.sp,
|
||||
letterSpacing = 0.sp
|
||||
),
|
||||
titleLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 22.sp,
|
||||
lineHeight = 28.sp,
|
||||
letterSpacing = 0.sp
|
||||
),
|
||||
titleMedium = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 24.sp,
|
||||
letterSpacing = 0.15.sp
|
||||
),
|
||||
titleSmall = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 20.sp,
|
||||
letterSpacing = 0.1.sp
|
||||
),
|
||||
bodyLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 24.sp,
|
||||
letterSpacing = 0.5.sp
|
||||
),
|
||||
bodyMedium = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 20.sp,
|
||||
letterSpacing = 0.25.sp
|
||||
),
|
||||
bodySmall = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 12.sp,
|
||||
lineHeight = 16.sp,
|
||||
letterSpacing = 0.4.sp
|
||||
),
|
||||
labelLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 20.sp,
|
||||
letterSpacing = 0.1.sp
|
||||
),
|
||||
labelMedium = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 12.sp,
|
||||
lineHeight = 16.sp,
|
||||
letterSpacing = 0.5.sp
|
||||
),
|
||||
labelSmall = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 11.sp,
|
||||
lineHeight = 16.sp,
|
||||
letterSpacing = 0.5.sp
|
||||
)
|
||||
)
|
||||
243
app/src/main/java/com/fizzy/android/data/api/FizzyApiService.kt
Normal file
243
app/src/main/java/com/fizzy/android/data/api/FizzyApiService.kt
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
package com.fizzy.android.data.api
|
||||
|
||||
import com.fizzy.android.data.api.dto.*
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.*
|
||||
|
||||
interface FizzyApiService {
|
||||
|
||||
// ==================== Auth ====================
|
||||
|
||||
@POST("session")
|
||||
suspend fun requestMagicLink(@Body request: RequestMagicLinkRequest): Response<RequestMagicLinkResponse>
|
||||
|
||||
@POST("session/magic_link")
|
||||
suspend fun verifyMagicLink(@Body request: VerifyMagicLinkRequest): Response<VerifyMagicLinkResponse>
|
||||
|
||||
@GET("my/identity.json")
|
||||
suspend fun getCurrentIdentity(): Response<IdentityDto>
|
||||
|
||||
// Legacy alias
|
||||
@GET("my/identity.json")
|
||||
suspend fun getCurrentUser(): Response<UserDto>
|
||||
|
||||
// ==================== Users ====================
|
||||
|
||||
@GET("users")
|
||||
suspend fun getUsers(): Response<List<UserDto>>
|
||||
|
||||
@GET("users/{userId}")
|
||||
suspend fun getUser(@Path("userId") userId: String): Response<UserDto>
|
||||
|
||||
// ==================== Boards ====================
|
||||
|
||||
@GET("boards.json")
|
||||
suspend fun getBoards(): Response<BoardsResponse>
|
||||
|
||||
@GET("boards/{boardId}.json")
|
||||
suspend fun getBoard(@Path("boardId") boardId: String): Response<BoardResponse>
|
||||
|
||||
@POST("boards.json")
|
||||
suspend fun createBoard(@Body request: CreateBoardRequest): Response<Unit>
|
||||
|
||||
@PUT("boards/{boardId}")
|
||||
suspend fun updateBoard(
|
||||
@Path("boardId") boardId: String,
|
||||
@Body request: UpdateBoardRequest
|
||||
): Response<Unit>
|
||||
|
||||
@DELETE("boards/{boardId}.json")
|
||||
suspend fun deleteBoard(@Path("boardId") boardId: String): Response<Unit>
|
||||
|
||||
// ==================== Columns ====================
|
||||
|
||||
@GET("boards/{boardId}/columns.json")
|
||||
suspend fun getColumns(@Path("boardId") boardId: String): Response<ColumnsResponse>
|
||||
|
||||
@POST("boards/{boardId}/columns.json")
|
||||
suspend fun createColumn(
|
||||
@Path("boardId") boardId: String,
|
||||
@Body request: CreateColumnRequest
|
||||
): Response<Unit>
|
||||
|
||||
@PUT("boards/{boardId}/columns/{columnId}")
|
||||
suspend fun updateColumn(
|
||||
@Path("boardId") boardId: String,
|
||||
@Path("columnId") columnId: String,
|
||||
@Body request: UpdateColumnRequest
|
||||
): Response<Unit>
|
||||
|
||||
@DELETE("boards/{boardId}/columns/{columnId}.json")
|
||||
suspend fun deleteColumn(
|
||||
@Path("boardId") boardId: String,
|
||||
@Path("columnId") columnId: String
|
||||
): Response<Unit>
|
||||
|
||||
// ==================== Cards ====================
|
||||
|
||||
@GET("cards.json")
|
||||
suspend fun getCards(@Query("board_ids[]") boardId: String? = null): Response<CardsResponse>
|
||||
|
||||
@GET("cards/{cardNumber}.json")
|
||||
suspend fun getCard(@Path("cardNumber") cardNumber: Int): Response<CardResponse>
|
||||
|
||||
@POST("boards/{boardId}/cards.json")
|
||||
suspend fun createCard(
|
||||
@Path("boardId") boardId: String,
|
||||
@Body request: CreateCardRequest
|
||||
): Response<Unit>
|
||||
|
||||
@PUT("cards/{cardNumber}")
|
||||
suspend fun updateCard(
|
||||
@Path("cardNumber") cardNumber: Int,
|
||||
@Body request: UpdateCardRequest
|
||||
): Response<Unit>
|
||||
|
||||
@DELETE("cards/{cardNumber}.json")
|
||||
suspend fun deleteCard(@Path("cardNumber") cardNumber: Int): Response<Unit>
|
||||
|
||||
// ==================== Card Actions ====================
|
||||
|
||||
// Close/Reopen
|
||||
@POST("cards/{cardNumber}/closure")
|
||||
suspend fun closeCard(@Path("cardNumber") cardNumber: Int): Response<Unit>
|
||||
|
||||
@DELETE("cards/{cardNumber}/closure")
|
||||
suspend fun reopenCard(@Path("cardNumber") cardNumber: Int): Response<Unit>
|
||||
|
||||
// Not Now (defer/put aside)
|
||||
@POST("cards/{cardNumber}/not_now")
|
||||
suspend fun markCardNotNow(@Path("cardNumber") cardNumber: Int): Response<Unit>
|
||||
|
||||
// Triage (move to column)
|
||||
@POST("cards/{cardNumber}/triage")
|
||||
suspend fun triageCard(
|
||||
@Path("cardNumber") cardNumber: Int,
|
||||
@Body request: TriageCardRequest
|
||||
): Response<Unit>
|
||||
|
||||
@DELETE("cards/{cardNumber}/triage")
|
||||
suspend fun untriageCard(@Path("cardNumber") cardNumber: Int): Response<Unit>
|
||||
|
||||
// Priority (golden)
|
||||
@POST("cards/{cardNumber}/goldness")
|
||||
suspend fun markCardGolden(@Path("cardNumber") cardNumber: Int): Response<Unit>
|
||||
|
||||
@DELETE("cards/{cardNumber}/goldness")
|
||||
suspend fun unmarkCardGolden(@Path("cardNumber") cardNumber: Int): Response<Unit>
|
||||
|
||||
// Watch
|
||||
@POST("cards/{cardNumber}/watch")
|
||||
suspend fun watchCard(@Path("cardNumber") cardNumber: Int): Response<Unit>
|
||||
|
||||
@DELETE("cards/{cardNumber}/watch")
|
||||
suspend fun unwatchCard(@Path("cardNumber") cardNumber: Int): Response<Unit>
|
||||
|
||||
// ==================== Assignments ====================
|
||||
|
||||
@POST("cards/{cardNumber}/assignments")
|
||||
suspend fun addAssignment(
|
||||
@Path("cardNumber") cardNumber: Int,
|
||||
@Body request: AssignmentRequest
|
||||
): Response<Unit>
|
||||
|
||||
// Note: Removing assignment may require PUT cards/{cardNumber} with updated assignee list
|
||||
// or a separate endpoint - documentation unclear
|
||||
|
||||
// ==================== Tags (Account Level) ====================
|
||||
|
||||
@GET("tags")
|
||||
suspend fun getTags(): Response<List<TagDto>>
|
||||
|
||||
// ==================== Taggings (Card Tags) ====================
|
||||
|
||||
@POST("cards/{cardNumber}/taggings")
|
||||
suspend fun addTagging(
|
||||
@Path("cardNumber") cardNumber: Int,
|
||||
@Body request: TaggingRequest
|
||||
): Response<Unit>
|
||||
|
||||
@DELETE("cards/{cardNumber}/taggings/{taggingId}")
|
||||
suspend fun removeTagging(
|
||||
@Path("cardNumber") cardNumber: Int,
|
||||
@Path("taggingId") taggingId: String
|
||||
): Response<Unit>
|
||||
|
||||
// ==================== Steps ====================
|
||||
|
||||
@GET("cards/{cardNumber}/steps.json")
|
||||
suspend fun getSteps(@Path("cardNumber") cardNumber: Int): Response<StepsResponse>
|
||||
|
||||
@POST("cards/{cardNumber}/steps.json")
|
||||
suspend fun createStep(
|
||||
@Path("cardNumber") cardNumber: Int,
|
||||
@Body request: CreateStepRequest
|
||||
): Response<Unit>
|
||||
|
||||
@PUT("cards/{cardNumber}/steps/{stepId}")
|
||||
suspend fun updateStep(
|
||||
@Path("cardNumber") cardNumber: Int,
|
||||
@Path("stepId") stepId: String,
|
||||
@Body request: UpdateStepRequest
|
||||
): Response<Unit>
|
||||
|
||||
@DELETE("cards/{cardNumber}/steps/{stepId}.json")
|
||||
suspend fun deleteStep(
|
||||
@Path("cardNumber") cardNumber: Int,
|
||||
@Path("stepId") stepId: String
|
||||
): Response<Unit>
|
||||
|
||||
// ==================== Comments ====================
|
||||
|
||||
@GET("cards/{cardNumber}/comments.json")
|
||||
suspend fun getComments(@Path("cardNumber") cardNumber: Int): Response<CommentsResponse>
|
||||
|
||||
@POST("cards/{cardNumber}/comments.json")
|
||||
suspend fun createComment(
|
||||
@Path("cardNumber") cardNumber: Int,
|
||||
@Body request: CreateCommentRequest
|
||||
): Response<Unit>
|
||||
|
||||
@PUT("cards/{cardNumber}/comments/{commentId}")
|
||||
suspend fun updateComment(
|
||||
@Path("cardNumber") cardNumber: Int,
|
||||
@Path("commentId") commentId: String,
|
||||
@Body request: UpdateCommentRequest
|
||||
): Response<Unit>
|
||||
|
||||
@DELETE("cards/{cardNumber}/comments/{commentId}.json")
|
||||
suspend fun deleteComment(
|
||||
@Path("cardNumber") cardNumber: Int,
|
||||
@Path("commentId") commentId: String
|
||||
): Response<Unit>
|
||||
|
||||
// ==================== Reactions ====================
|
||||
|
||||
@POST("cards/{cardNumber}/comments/{commentId}/reactions")
|
||||
suspend fun addReaction(
|
||||
@Path("cardNumber") cardNumber: Int,
|
||||
@Path("commentId") commentId: String,
|
||||
@Body request: CreateReactionRequest
|
||||
): Response<Unit>
|
||||
|
||||
@DELETE("cards/{cardNumber}/comments/{commentId}/reactions/{reactionId}")
|
||||
suspend fun removeReaction(
|
||||
@Path("cardNumber") cardNumber: Int,
|
||||
@Path("commentId") commentId: String,
|
||||
@Path("reactionId") reactionId: String
|
||||
): Response<Unit>
|
||||
|
||||
// ==================== Notifications ====================
|
||||
|
||||
@GET("notifications.json")
|
||||
suspend fun getNotifications(): Response<NotificationsResponse>
|
||||
|
||||
@POST("notifications/{notificationId}/reading")
|
||||
suspend fun markNotificationRead(@Path("notificationId") notificationId: String): Response<Unit>
|
||||
|
||||
@DELETE("notifications/{notificationId}/reading")
|
||||
suspend fun markNotificationUnread(@Path("notificationId") notificationId: String): Response<Unit>
|
||||
|
||||
@POST("notifications/bulk_reading")
|
||||
suspend fun markAllNotificationsRead(): Response<Unit>
|
||||
}
|
||||
34
app/src/main/java/com/fizzy/android/data/api/dto/AuthDto.kt
Normal file
34
app/src/main/java/com/fizzy/android/data/api/dto/AuthDto.kt
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
package com.fizzy.android.data.api.dto
|
||||
|
||||
import com.squareup.moshi.Json
|
||||
import com.squareup.moshi.JsonClass
|
||||
|
||||
// Request magic link - Fizzy API uses email_address, not email
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class RequestMagicLinkRequest(
|
||||
@Json(name = "email_address") val emailAddress: String
|
||||
)
|
||||
|
||||
// Response contains pending_authentication_token for magic link flow
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class RequestMagicLinkResponse(
|
||||
@Json(name = "pending_authentication_token") val pendingAuthenticationToken: String
|
||||
)
|
||||
|
||||
// Verify magic link - send the code received via email
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class VerifyMagicLinkRequest(
|
||||
@Json(name = "code") val code: String
|
||||
)
|
||||
|
||||
// Verify response returns session_token
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class VerifyMagicLinkResponse(
|
||||
@Json(name = "session_token") val sessionToken: String
|
||||
)
|
||||
|
||||
// For Personal Access Token authentication (alternative to magic link)
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class PersonalAccessTokenRequest(
|
||||
@Json(name = "token") val token: String
|
||||
)
|
||||
102
app/src/main/java/com/fizzy/android/data/api/dto/BoardDto.kt
Normal file
102
app/src/main/java/com/fizzy/android/data/api/dto/BoardDto.kt
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
package com.fizzy.android.data.api.dto
|
||||
|
||||
import com.fizzy.android.domain.model.Board
|
||||
import com.fizzy.android.domain.model.User
|
||||
import com.squareup.moshi.Json
|
||||
import com.squareup.moshi.JsonClass
|
||||
import java.time.Instant
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class BoardDto(
|
||||
@Json(name = "id") val id: String,
|
||||
@Json(name = "name") val name: String,
|
||||
@Json(name = "description") val description: String? = null,
|
||||
@Json(name = "created_at") val createdAt: String,
|
||||
@Json(name = "updated_at") val updatedAt: String? = null,
|
||||
@Json(name = "cards_count") val cardsCount: Int = 0,
|
||||
@Json(name = "columns_count") val columnsCount: Int = 0,
|
||||
@Json(name = "creator") val creator: CreatorDto? = null,
|
||||
@Json(name = "all_access") val allAccess: Boolean = false,
|
||||
@Json(name = "url") val url: String? = null
|
||||
)
|
||||
|
||||
// Creator in board response has different structure than User
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class CreatorDto(
|
||||
@Json(name = "id") val id: String,
|
||||
@Json(name = "name") val name: String,
|
||||
@Json(name = "role") val role: String? = null,
|
||||
@Json(name = "active") val active: Boolean? = null,
|
||||
@Json(name = "email_address") val emailAddress: String? = null,
|
||||
@Json(name = "avatar_url") val avatarUrl: String? = null,
|
||||
@Json(name = "url") val url: String? = null
|
||||
)
|
||||
|
||||
// API returns direct array/object, not wrapped
|
||||
typealias BoardsResponse = List<BoardDto>
|
||||
typealias BoardResponse = BoardDto
|
||||
|
||||
// Wrapped request for creating boards (Fizzy API requires nested object)
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class CreateBoardRequest(
|
||||
@Json(name = "board") val board: BoardData
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class BoardData(
|
||||
@Json(name = "name") val name: String,
|
||||
@Json(name = "all_access") val allAccess: Boolean? = null
|
||||
)
|
||||
|
||||
// Wrapped request for updating boards
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class UpdateBoardRequest(
|
||||
@Json(name = "board") val board: UpdateBoardData
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class UpdateBoardData(
|
||||
@Json(name = "name") val name: String? = null,
|
||||
@Json(name = "all_access") val allAccess: Boolean? = null
|
||||
)
|
||||
|
||||
fun BoardDto.toDomain(): Board = Board(
|
||||
id = id,
|
||||
name = name,
|
||||
description = description,
|
||||
createdAt = Instant.parse(createdAt),
|
||||
updatedAt = updatedAt?.let { Instant.parse(it) },
|
||||
cardsCount = cardsCount,
|
||||
columnsCount = columnsCount,
|
||||
creator = creator?.toUser(),
|
||||
allAccess = allAccess,
|
||||
url = url
|
||||
)
|
||||
|
||||
fun CreatorDto.toUser(): User = User(
|
||||
id = 0L, // Fizzy uses string IDs
|
||||
name = name,
|
||||
email = emailAddress ?: "",
|
||||
avatarUrl = avatarUrl,
|
||||
admin = role == "owner" || role == "admin"
|
||||
)
|
||||
|
||||
// Helper function to create CreateBoardRequest with nested structure
|
||||
fun createBoardRequest(name: String, allAccess: Boolean? = null): CreateBoardRequest {
|
||||
return CreateBoardRequest(
|
||||
board = BoardData(
|
||||
name = name,
|
||||
allAccess = allAccess
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Helper function to create UpdateBoardRequest with nested structure
|
||||
fun updateBoardRequest(name: String? = null, allAccess: Boolean? = null): UpdateBoardRequest {
|
||||
return UpdateBoardRequest(
|
||||
board = UpdateBoardData(
|
||||
name = name,
|
||||
allAccess = allAccess
|
||||
)
|
||||
)
|
||||
}
|
||||
153
app/src/main/java/com/fizzy/android/data/api/dto/CardDto.kt
Normal file
153
app/src/main/java/com/fizzy/android/data/api/dto/CardDto.kt
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
package com.fizzy.android.data.api.dto
|
||||
|
||||
import android.util.Log
|
||||
import com.fizzy.android.domain.model.Card
|
||||
import com.fizzy.android.domain.model.CardStatus
|
||||
import com.squareup.moshi.Json
|
||||
import com.squareup.moshi.JsonClass
|
||||
import java.time.Instant
|
||||
|
||||
private const val TAG = "CardDto"
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class CardDto(
|
||||
@Json(name = "id") val id: String,
|
||||
@Json(name = "number") val number: Int = 0,
|
||||
@Json(name = "title") val title: String,
|
||||
@Json(name = "description") val description: String? = null,
|
||||
@Json(name = "position") val position: Int = 0,
|
||||
@Json(name = "column") val column: CardColumnDto? = null,
|
||||
@Json(name = "board") val board: CardBoardDto? = null,
|
||||
@Json(name = "status") val status: String = "active",
|
||||
@Json(name = "golden") val golden: Boolean = false,
|
||||
@Json(name = "closed") val closed: Boolean = false,
|
||||
@Json(name = "created_at") val createdAt: String = "",
|
||||
@Json(name = "last_active_at") val lastActiveAt: String? = null,
|
||||
@Json(name = "creator") val creator: UserDto? = null,
|
||||
@Json(name = "assignees") val assignees: List<UserDto>? = null,
|
||||
@Json(name = "tags") val tags: List<TagDto>? = null,
|
||||
@Json(name = "steps") val steps: List<StepDto>? = null,
|
||||
@Json(name = "url") val url: String? = null,
|
||||
@Json(name = "comments_url") val commentsUrl: String? = null
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class CardColumnDto(
|
||||
@Json(name = "id") val id: String,
|
||||
@Json(name = "name") val name: String? = null,
|
||||
@Json(name = "color") val color: ColumnColorDto? = null,
|
||||
@Json(name = "created_at") val createdAt: String? = null
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class CardBoardDto(
|
||||
@Json(name = "id") val id: String,
|
||||
@Json(name = "name") val name: String? = null,
|
||||
@Json(name = "all_access") val allAccess: Boolean = false,
|
||||
@Json(name = "created_at") val createdAt: String? = null,
|
||||
@Json(name = "url") val url: String? = null,
|
||||
@Json(name = "creator") val creator: UserDto? = null
|
||||
)
|
||||
|
||||
// API returns direct array, not wrapped
|
||||
typealias CardsResponse = List<CardDto>
|
||||
typealias CardResponse = CardDto
|
||||
|
||||
// Wrapped request for creating cards (Fizzy API requires nested object)
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class CreateCardRequest(
|
||||
@Json(name = "card") val card: CardData
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class CardData(
|
||||
@Json(name = "title") val title: String,
|
||||
@Json(name = "description") val description: String? = null,
|
||||
@Json(name = "status") val status: String? = null,
|
||||
@Json(name = "column_id") val columnId: String? = null,
|
||||
@Json(name = "tag_ids") val tagIds: List<String>? = null
|
||||
)
|
||||
|
||||
// Wrapped request for updating cards
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class UpdateCardRequest(
|
||||
@Json(name = "card") val card: UpdateCardData
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class UpdateCardData(
|
||||
@Json(name = "title") val title: String? = null,
|
||||
@Json(name = "description") val description: String? = null,
|
||||
@Json(name = "column_id") val columnId: String? = null,
|
||||
@Json(name = "position") val position: Int? = null
|
||||
)
|
||||
|
||||
// Triage card to a specific column
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class TriageCardRequest(
|
||||
@Json(name = "column_id") val columnId: String
|
||||
)
|
||||
|
||||
// Add assignment to a card
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class AssignmentRequest(
|
||||
@Json(name = "assignee_id") val assigneeId: String
|
||||
)
|
||||
|
||||
fun CardDto.toDomain(): Card {
|
||||
Log.d(TAG, "toDomain: title='$title', column=$column, columnId=${column?.id}")
|
||||
return Card(
|
||||
id = number.toLong(), // Use number for card identification (used in URLs)
|
||||
title = title,
|
||||
description = description,
|
||||
position = position,
|
||||
columnId = column?.id ?: "",
|
||||
boardId = board?.id ?: "",
|
||||
status = when {
|
||||
closed -> CardStatus.CLOSED
|
||||
status.lowercase() == "triaged" -> CardStatus.TRIAGED
|
||||
status.lowercase() == "deferred" -> CardStatus.DEFERRED
|
||||
else -> CardStatus.ACTIVE
|
||||
},
|
||||
priority = golden,
|
||||
watching = false, // Not in API response
|
||||
triageAt = null, // Not directly in API response
|
||||
deferUntil = null, // Not directly in API response
|
||||
createdAt = if (createdAt.isNotEmpty()) Instant.parse(createdAt) else Instant.now(),
|
||||
updatedAt = lastActiveAt?.let { Instant.parse(it) } ?: Instant.now(),
|
||||
creator = creator?.toDomain(),
|
||||
assignees = assignees?.map { it.toDomain() } ?: emptyList(),
|
||||
tags = tags?.map { it.toDomain() } ?: emptyList(),
|
||||
stepsTotal = steps?.size ?: 0,
|
||||
stepsCompleted = steps?.count { it.completed } ?: 0,
|
||||
commentsCount = 0 // Not in list response
|
||||
)
|
||||
}
|
||||
|
||||
// Helper function to create CreateCardRequest with nested structure
|
||||
fun createCardRequest(title: String, description: String?, columnId: String): CreateCardRequest {
|
||||
return CreateCardRequest(
|
||||
card = CardData(
|
||||
title = title,
|
||||
description = description,
|
||||
columnId = columnId
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Helper function to create UpdateCardRequest with nested structure
|
||||
fun updateCardRequest(
|
||||
title: String? = null,
|
||||
description: String? = null,
|
||||
columnId: String? = null,
|
||||
position: Int? = null
|
||||
): UpdateCardRequest {
|
||||
return UpdateCardRequest(
|
||||
card = UpdateCardData(
|
||||
title = title,
|
||||
description = description,
|
||||
columnId = columnId,
|
||||
position = position
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
package com.fizzy.android.data.api.dto
|
||||
|
||||
import com.fizzy.android.domain.model.Column
|
||||
import com.squareup.moshi.Json
|
||||
import com.squareup.moshi.JsonClass
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class ColumnDto(
|
||||
@Json(name = "id") val id: String,
|
||||
@Json(name = "name") val name: String,
|
||||
@Json(name = "position") val position: Int = 0,
|
||||
@Json(name = "board_id") val boardId: String = "",
|
||||
@Json(name = "cards") val cards: List<CardDto>? = null,
|
||||
@Json(name = "cards_count") val cardsCount: Int = 0,
|
||||
@Json(name = "color") val color: ColumnColorDto? = null,
|
||||
@Json(name = "created_at") val createdAt: String? = null
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class ColumnColorDto(
|
||||
@Json(name = "name") val name: String,
|
||||
@Json(name = "value") val value: String
|
||||
)
|
||||
|
||||
// API returns direct arrays, not wrapped
|
||||
typealias ColumnsResponse = List<ColumnDto>
|
||||
typealias ColumnResponse = ColumnDto
|
||||
|
||||
// Wrapped request for creating columns (Fizzy API requires nested object)
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class CreateColumnRequest(
|
||||
@Json(name = "column") val column: ColumnData
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class ColumnData(
|
||||
@Json(name = "name") val name: String,
|
||||
@Json(name = "color") val color: String? = null,
|
||||
@Json(name = "position") val position: Int? = null
|
||||
)
|
||||
|
||||
// Wrapped request for updating columns
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class UpdateColumnRequest(
|
||||
@Json(name = "column") val column: UpdateColumnData
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class UpdateColumnData(
|
||||
@Json(name = "name") val name: String? = null,
|
||||
@Json(name = "color") val color: String? = null,
|
||||
@Json(name = "position") val position: Int? = null
|
||||
)
|
||||
|
||||
fun ColumnDto.toDomain(): Column = Column(
|
||||
id = id,
|
||||
name = name,
|
||||
position = position,
|
||||
boardId = boardId,
|
||||
cards = cards?.map { it.toDomain() } ?: emptyList(),
|
||||
cardsCount = cardsCount
|
||||
)
|
||||
|
||||
// Helper function to create CreateColumnRequest with nested structure
|
||||
fun createColumnRequest(name: String, color: String? = null, position: Int? = null): CreateColumnRequest {
|
||||
return CreateColumnRequest(
|
||||
column = ColumnData(
|
||||
name = name,
|
||||
color = color,
|
||||
position = position
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Helper function to create UpdateColumnRequest with nested structure
|
||||
fun updateColumnRequest(name: String? = null, color: String? = null, position: Int? = null): UpdateColumnRequest {
|
||||
return UpdateColumnRequest(
|
||||
column = UpdateColumnData(
|
||||
name = name,
|
||||
color = color,
|
||||
position = position
|
||||
)
|
||||
)
|
||||
}
|
||||
100
app/src/main/java/com/fizzy/android/data/api/dto/CommentDto.kt
Normal file
100
app/src/main/java/com/fizzy/android/data/api/dto/CommentDto.kt
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
package com.fizzy.android.data.api.dto
|
||||
|
||||
import com.fizzy.android.domain.model.Comment
|
||||
import com.fizzy.android.domain.model.Reaction
|
||||
import com.squareup.moshi.Json
|
||||
import com.squareup.moshi.JsonClass
|
||||
import java.time.Instant
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class CommentDto(
|
||||
@Json(name = "id") val id: String,
|
||||
@Json(name = "content") val content: String,
|
||||
@Json(name = "card_id") val cardId: String? = null,
|
||||
@Json(name = "author") val author: UserDto? = null,
|
||||
@Json(name = "creator") val creator: UserDto? = null,
|
||||
@Json(name = "created_at") val createdAt: String,
|
||||
@Json(name = "updated_at") val updatedAt: String? = null,
|
||||
@Json(name = "reactions") val reactions: List<ReactionDto>? = null
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class ReactionDto(
|
||||
@Json(name = "id") val id: String? = null,
|
||||
@Json(name = "content") val content: String? = null,
|
||||
@Json(name = "emoji") val emoji: String? = null,
|
||||
@Json(name = "count") val count: Int = 1,
|
||||
@Json(name = "users") val users: List<UserDto>? = null,
|
||||
@Json(name = "reacted_by_me") val reactedByMe: Boolean = false,
|
||||
@Json(name = "creator") val creator: UserDto? = null
|
||||
)
|
||||
|
||||
// API returns direct arrays, not wrapped
|
||||
typealias CommentsResponse = List<CommentDto>
|
||||
typealias CommentResponse = CommentDto
|
||||
|
||||
// Wrapped request for creating comments (Fizzy API requires nested object)
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class CreateCommentRequest(
|
||||
@Json(name = "comment") val comment: CommentData
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class CommentData(
|
||||
@Json(name = "body") val body: String
|
||||
)
|
||||
|
||||
// Wrapped request for updating comments
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class UpdateCommentRequest(
|
||||
@Json(name = "comment") val comment: CommentData
|
||||
)
|
||||
|
||||
// Wrapped request for creating reactions
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class CreateReactionRequest(
|
||||
@Json(name = "reaction") val reaction: ReactionData
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class ReactionData(
|
||||
@Json(name = "content") val content: String
|
||||
)
|
||||
|
||||
fun ReactionDto.toDomain(): Reaction = Reaction(
|
||||
emoji = content ?: emoji ?: "",
|
||||
count = count,
|
||||
users = users?.map { it.toDomain() } ?: emptyList(),
|
||||
reactedByMe = reactedByMe
|
||||
)
|
||||
|
||||
fun CommentDto.toDomain(): Comment = Comment(
|
||||
id = id.toLongOrNull() ?: 0L,
|
||||
content = content,
|
||||
cardId = cardId?.toLongOrNull() ?: 0L,
|
||||
author = (author ?: creator)?.toDomain() ?: throw IllegalStateException("Comment must have author or creator"),
|
||||
createdAt = Instant.parse(createdAt),
|
||||
updatedAt = updatedAt?.let { Instant.parse(it) } ?: Instant.parse(createdAt),
|
||||
reactions = reactions?.map { it.toDomain() } ?: emptyList()
|
||||
)
|
||||
|
||||
// Helper function to create CreateCommentRequest with nested structure
|
||||
fun createCommentRequest(content: String): CreateCommentRequest {
|
||||
return CreateCommentRequest(
|
||||
comment = CommentData(body = content)
|
||||
)
|
||||
}
|
||||
|
||||
// Helper function to create UpdateCommentRequest with nested structure
|
||||
fun updateCommentRequest(content: String): UpdateCommentRequest {
|
||||
return UpdateCommentRequest(
|
||||
comment = CommentData(body = content)
|
||||
)
|
||||
}
|
||||
|
||||
// Helper function to create CreateReactionRequest with nested structure
|
||||
fun createReactionRequest(emoji: String): CreateReactionRequest {
|
||||
return CreateReactionRequest(
|
||||
reaction = ReactionData(content = emoji)
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
package com.fizzy.android.data.api.dto
|
||||
|
||||
import com.fizzy.android.domain.model.User
|
||||
import com.squareup.moshi.Json
|
||||
import com.squareup.moshi.JsonClass
|
||||
|
||||
/**
|
||||
* Response from GET /my/identity.json
|
||||
* Example:
|
||||
* {
|
||||
* "accounts": [{
|
||||
* "id": "03ff9o0317vymjugkux4h9wkj",
|
||||
* "name": "Paweł's Fizzy",
|
||||
* "slug": "/0000001",
|
||||
* "created_at": "2026-01-18T02:57:58.339Z",
|
||||
* "user": {
|
||||
* "id": "03ff9o042qaxblmwrxdgcs0fk",
|
||||
* "name": "Paweł",
|
||||
* "role": "owner",
|
||||
* "active": true,
|
||||
* "email_address": "pawel@orzech.me",
|
||||
* "created_at": "2026-01-18T02:57:58.597Z",
|
||||
* "url": "https://kanban.orzech.me/users/03ff9o042qaxblmwrxdgcs0fk"
|
||||
* }
|
||||
* }]
|
||||
* }
|
||||
*/
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class IdentityDto(
|
||||
@Json(name = "accounts") val accounts: List<IdentityAccountDto>? = null
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class IdentityAccountDto(
|
||||
@Json(name = "id") val id: String,
|
||||
@Json(name = "name") val name: String,
|
||||
@Json(name = "slug") val slug: String? = null,
|
||||
@Json(name = "created_at") val createdAt: String? = null,
|
||||
@Json(name = "user") val user: IdentityUserDto? = null
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class IdentityUserDto(
|
||||
@Json(name = "id") val id: String,
|
||||
@Json(name = "name") val name: String,
|
||||
@Json(name = "role") val role: String? = null,
|
||||
@Json(name = "active") val active: Boolean? = null,
|
||||
@Json(name = "email_address") val emailAddress: String? = null,
|
||||
@Json(name = "avatar_url") val avatarUrl: String? = null,
|
||||
@Json(name = "url") val url: String? = null
|
||||
)
|
||||
|
||||
fun IdentityDto.toUser(): User {
|
||||
val firstAccount = accounts?.firstOrNull()
|
||||
val user = firstAccount?.user
|
||||
return User(
|
||||
id = 0L, // Fizzy uses string IDs, we'll use 0 as placeholder
|
||||
name = user?.name ?: "Unknown",
|
||||
email = user?.emailAddress ?: "",
|
||||
avatarUrl = user?.avatarUrl,
|
||||
admin = user?.role == "owner" || user?.role == "admin"
|
||||
)
|
||||
}
|
||||
|
||||
fun IdentityDto.getUserId(): String? = accounts?.firstOrNull()?.user?.id
|
||||
|
||||
fun IdentityDto.getAccountId(): String? = accounts?.firstOrNull()?.id
|
||||
|
||||
fun IdentityDto.getAccountSlug(): String? = accounts?.firstOrNull()?.slug
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
package com.fizzy.android.data.api.dto
|
||||
|
||||
import com.fizzy.android.domain.model.Notification
|
||||
import com.fizzy.android.domain.model.NotificationType
|
||||
import com.squareup.moshi.Json
|
||||
import com.squareup.moshi.JsonClass
|
||||
import java.time.Instant
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class NotificationDto(
|
||||
@Json(name = "id") val id: String,
|
||||
@Json(name = "read") val read: Boolean,
|
||||
@Json(name = "read_at") val readAt: String? = null,
|
||||
@Json(name = "created_at") val createdAt: String,
|
||||
@Json(name = "title") val title: String,
|
||||
@Json(name = "body") val body: String,
|
||||
@Json(name = "type") val type: String? = null,
|
||||
@Json(name = "creator") val creator: UserDto? = null,
|
||||
@Json(name = "card") val card: NotificationCardDto? = null,
|
||||
@Json(name = "url") val url: String? = null
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class NotificationCardDto(
|
||||
@Json(name = "id") val id: String,
|
||||
@Json(name = "title") val title: String,
|
||||
@Json(name = "status") val status: String? = null,
|
||||
@Json(name = "url") val url: String? = null
|
||||
)
|
||||
|
||||
// API returns notifications list with unread count
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class NotificationsResponse(
|
||||
@Json(name = "notifications") val notifications: List<NotificationDto>,
|
||||
@Json(name = "unread_count") val unreadCount: Int = 0
|
||||
)
|
||||
|
||||
fun NotificationDto.toDomain(): Notification = Notification(
|
||||
id = id.toLongOrNull() ?: 0L,
|
||||
type = when (type?.lowercase()) {
|
||||
"card_assigned" -> NotificationType.CARD_ASSIGNED
|
||||
"card_mentioned" -> NotificationType.CARD_MENTIONED
|
||||
"card_commented" -> NotificationType.CARD_COMMENTED
|
||||
"card_moved" -> NotificationType.CARD_MOVED
|
||||
"card_updated" -> NotificationType.CARD_UPDATED
|
||||
"step_completed" -> NotificationType.STEP_COMPLETED
|
||||
"reaction_added" -> NotificationType.REACTION_ADDED
|
||||
"board_shared" -> NotificationType.BOARD_SHARED
|
||||
else -> NotificationType.OTHER
|
||||
},
|
||||
title = title,
|
||||
body = body,
|
||||
read = read,
|
||||
createdAt = Instant.parse(createdAt),
|
||||
cardId = card?.id?.toLongOrNull(),
|
||||
boardId = null, // Not directly in new API structure
|
||||
actor = creator?.toDomain()
|
||||
)
|
||||
78
app/src/main/java/com/fizzy/android/data/api/dto/StepDto.kt
Normal file
78
app/src/main/java/com/fizzy/android/data/api/dto/StepDto.kt
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
package com.fizzy.android.data.api.dto
|
||||
|
||||
import com.fizzy.android.domain.model.Step
|
||||
import com.squareup.moshi.Json
|
||||
import com.squareup.moshi.JsonClass
|
||||
import java.time.Instant
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class StepDto(
|
||||
@Json(name = "id") val id: String,
|
||||
@Json(name = "content") val content: String,
|
||||
@Json(name = "completed") val completed: Boolean,
|
||||
@Json(name = "position") val position: Int = 0,
|
||||
@Json(name = "card_id") val cardId: String? = null,
|
||||
@Json(name = "completed_by") val completedBy: UserDto? = null,
|
||||
@Json(name = "completed_at") val completedAt: String? = null
|
||||
)
|
||||
|
||||
// API returns direct arrays, not wrapped
|
||||
typealias StepsResponse = List<StepDto>
|
||||
typealias StepResponse = StepDto
|
||||
|
||||
// Wrapped request for creating steps (Fizzy API requires nested object)
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class CreateStepRequest(
|
||||
@Json(name = "step") val step: StepData
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class StepData(
|
||||
@Json(name = "content") val content: String,
|
||||
@Json(name = "completed") val completed: Boolean? = null
|
||||
)
|
||||
|
||||
// Wrapped request for updating steps
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class UpdateStepRequest(
|
||||
@Json(name = "step") val step: UpdateStepData
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class UpdateStepData(
|
||||
@Json(name = "content") val content: String? = null,
|
||||
@Json(name = "completed") val completed: Boolean? = null,
|
||||
@Json(name = "position") val position: Int? = null
|
||||
)
|
||||
|
||||
fun StepDto.toDomain(): Step = Step(
|
||||
id = id.toLongOrNull() ?: 0L,
|
||||
description = content, // Map content to description in domain model
|
||||
completed = completed,
|
||||
position = position,
|
||||
cardId = cardId?.toLongOrNull() ?: 0L,
|
||||
completedBy = completedBy?.toDomain(),
|
||||
completedAt = completedAt?.let { Instant.parse(it) }
|
||||
)
|
||||
|
||||
// Helper function to create CreateStepRequest with nested structure
|
||||
fun createStepRequest(content: String): CreateStepRequest {
|
||||
return CreateStepRequest(
|
||||
step = StepData(content = content)
|
||||
)
|
||||
}
|
||||
|
||||
// Helper function to create UpdateStepRequest with nested structure
|
||||
fun updateStepRequest(
|
||||
content: String? = null,
|
||||
completed: Boolean? = null,
|
||||
position: Int? = null
|
||||
): UpdateStepRequest {
|
||||
return UpdateStepRequest(
|
||||
step = UpdateStepData(
|
||||
content = content,
|
||||
completed = completed,
|
||||
position = position
|
||||
)
|
||||
)
|
||||
}
|
||||
35
app/src/main/java/com/fizzy/android/data/api/dto/TagDto.kt
Normal file
35
app/src/main/java/com/fizzy/android/data/api/dto/TagDto.kt
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
package com.fizzy.android.data.api.dto
|
||||
|
||||
import com.fizzy.android.domain.model.Tag
|
||||
import com.squareup.moshi.Json
|
||||
import com.squareup.moshi.JsonClass
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class TagDto(
|
||||
@Json(name = "id") val id: String,
|
||||
@Json(name = "title") val title: String? = null,
|
||||
@Json(name = "name") val name: String? = null,
|
||||
@Json(name = "color") val color: String? = null
|
||||
)
|
||||
|
||||
// API returns direct array for account-level tags
|
||||
typealias TagsResponse = List<TagDto>
|
||||
|
||||
// Tagging request - add tag to card by tag title
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class TaggingRequest(
|
||||
@Json(name = "tag_title") val tagTitle: String
|
||||
)
|
||||
|
||||
// Tagging DTO - represents a tag association on a card
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class TaggingDto(
|
||||
@Json(name = "id") val id: String,
|
||||
@Json(name = "tag") val tag: TagDto
|
||||
)
|
||||
|
||||
fun TagDto.toDomain(): Tag = Tag(
|
||||
id = id.toLongOrNull() ?: 0L,
|
||||
name = title ?: name ?: "",
|
||||
color = color ?: "#808080"
|
||||
)
|
||||
26
app/src/main/java/com/fizzy/android/data/api/dto/UserDto.kt
Normal file
26
app/src/main/java/com/fizzy/android/data/api/dto/UserDto.kt
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
package com.fizzy.android.data.api.dto
|
||||
|
||||
import com.fizzy.android.domain.model.User
|
||||
import com.squareup.moshi.Json
|
||||
import com.squareup.moshi.JsonClass
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class UserDto(
|
||||
@Json(name = "id") val id: String,
|
||||
@Json(name = "name") val name: String,
|
||||
@Json(name = "email_address") val emailAddress: String? = null,
|
||||
@Json(name = "avatar_url") val avatarUrl: String? = null,
|
||||
@Json(name = "role") val role: String? = null,
|
||||
@Json(name = "active") val active: Boolean = true,
|
||||
@Json(name = "admin") val admin: Boolean = false,
|
||||
@Json(name = "created_at") val createdAt: String? = null,
|
||||
@Json(name = "url") val url: String? = null
|
||||
)
|
||||
|
||||
fun UserDto.toDomain(): User = User(
|
||||
id = id.hashCode().toLong(), // Convert string ID to long for domain model
|
||||
name = name,
|
||||
email = emailAddress ?: "",
|
||||
avatarUrl = avatarUrl,
|
||||
admin = admin || role == "owner"
|
||||
)
|
||||
178
app/src/main/java/com/fizzy/android/data/local/AccountStorage.kt
Normal file
178
app/src/main/java/com/fizzy/android/data/local/AccountStorage.kt
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
package com.fizzy.android.data.local
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
import com.fizzy.android.domain.model.Account
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.squareup.moshi.Types
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
interface AccountStorage {
|
||||
val accounts: Flow<List<Account>>
|
||||
val activeAccount: Flow<Account?>
|
||||
|
||||
suspend fun addAccount(account: Account)
|
||||
suspend fun removeAccount(accountId: String)
|
||||
suspend fun setActiveAccount(accountId: String)
|
||||
suspend fun updateAccount(account: Account)
|
||||
suspend fun getAccount(accountId: String): Account?
|
||||
suspend fun getActiveAccount(): Account?
|
||||
suspend fun getAllAccounts(): List<Account>
|
||||
suspend fun clearAll()
|
||||
}
|
||||
|
||||
@Singleton
|
||||
class AccountStorageImpl @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val moshi: Moshi
|
||||
) : AccountStorage {
|
||||
|
||||
private val masterKey = MasterKey.Builder(context)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
.build()
|
||||
|
||||
private val prefs: SharedPreferences = EncryptedSharedPreferences.create(
|
||||
context,
|
||||
"fizzy_accounts",
|
||||
masterKey,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
|
||||
)
|
||||
|
||||
private val accountListType = Types.newParameterizedType(List::class.java, AccountData::class.java)
|
||||
private val accountAdapter = moshi.adapter<List<AccountData>>(accountListType)
|
||||
|
||||
private val _accountsFlow = MutableStateFlow<List<Account>>(loadAccounts())
|
||||
override val accounts: Flow<List<Account>> = _accountsFlow.asStateFlow()
|
||||
|
||||
override val activeAccount: Flow<Account?> = _accountsFlow.map { accounts ->
|
||||
accounts.find { it.isActive }
|
||||
}
|
||||
|
||||
private fun loadAccounts(): List<Account> {
|
||||
val json = prefs.getString(KEY_ACCOUNTS, null) ?: return emptyList()
|
||||
return try {
|
||||
accountAdapter.fromJson(json)?.map { it.toAccount() } ?: emptyList()
|
||||
} catch (e: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveAccounts(accounts: List<Account>) {
|
||||
val data = accounts.map { AccountData.fromAccount(it) }
|
||||
val json = accountAdapter.toJson(data)
|
||||
prefs.edit().putString(KEY_ACCOUNTS, json).apply()
|
||||
_accountsFlow.value = accounts
|
||||
}
|
||||
|
||||
override suspend fun addAccount(account: Account) {
|
||||
val currentAccounts = _accountsFlow.value.toMutableList()
|
||||
|
||||
// Deactivate all existing accounts
|
||||
val updatedAccounts = currentAccounts.map { it.copy(isActive = false) }.toMutableList()
|
||||
|
||||
// Add new account as active
|
||||
updatedAccounts.add(account.copy(isActive = true))
|
||||
|
||||
saveAccounts(updatedAccounts)
|
||||
}
|
||||
|
||||
override suspend fun removeAccount(accountId: String) {
|
||||
val currentAccounts = _accountsFlow.value.toMutableList()
|
||||
val wasActive = currentAccounts.find { it.id == accountId }?.isActive == true
|
||||
|
||||
currentAccounts.removeAll { it.id == accountId }
|
||||
|
||||
// If removed account was active, activate the first remaining account
|
||||
if (wasActive && currentAccounts.isNotEmpty()) {
|
||||
currentAccounts[0] = currentAccounts[0].copy(isActive = true)
|
||||
}
|
||||
|
||||
saveAccounts(currentAccounts)
|
||||
}
|
||||
|
||||
override suspend fun setActiveAccount(accountId: String) {
|
||||
val currentAccounts = _accountsFlow.value.map { account ->
|
||||
account.copy(isActive = account.id == accountId)
|
||||
}
|
||||
saveAccounts(currentAccounts)
|
||||
}
|
||||
|
||||
override suspend fun updateAccount(account: Account) {
|
||||
val currentAccounts = _accountsFlow.value.map { existing ->
|
||||
if (existing.id == account.id) account else existing
|
||||
}
|
||||
saveAccounts(currentAccounts)
|
||||
}
|
||||
|
||||
override suspend fun getAccount(accountId: String): Account? {
|
||||
return _accountsFlow.value.find { it.id == accountId }
|
||||
}
|
||||
|
||||
override suspend fun getActiveAccount(): Account? {
|
||||
return _accountsFlow.value.find { it.isActive }
|
||||
}
|
||||
|
||||
override suspend fun getAllAccounts(): List<Account> {
|
||||
return _accountsFlow.value
|
||||
}
|
||||
|
||||
override suspend fun clearAll() {
|
||||
prefs.edit().clear().apply()
|
||||
_accountsFlow.value = emptyList()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val KEY_ACCOUNTS = "accounts"
|
||||
}
|
||||
}
|
||||
|
||||
// Internal data class for JSON serialization
|
||||
private data class AccountData(
|
||||
val id: String,
|
||||
val instanceUrl: String,
|
||||
val email: String,
|
||||
val token: String,
|
||||
val userName: String,
|
||||
val userId: Long,
|
||||
val avatarUrl: String?,
|
||||
val isActive: Boolean,
|
||||
val fizzyAccountId: String? = null,
|
||||
val fizzyAccountSlug: String? = null
|
||||
) {
|
||||
fun toAccount() = Account(
|
||||
id = id,
|
||||
instanceUrl = instanceUrl,
|
||||
email = email,
|
||||
token = token,
|
||||
userName = userName,
|
||||
userId = userId,
|
||||
avatarUrl = avatarUrl,
|
||||
isActive = isActive,
|
||||
fizzyAccountId = fizzyAccountId,
|
||||
fizzyAccountSlug = fizzyAccountSlug
|
||||
)
|
||||
|
||||
companion object {
|
||||
fun fromAccount(account: Account) = AccountData(
|
||||
id = account.id,
|
||||
instanceUrl = account.instanceUrl,
|
||||
email = account.email,
|
||||
token = account.token,
|
||||
userName = account.userName,
|
||||
userId = account.userId,
|
||||
avatarUrl = account.avatarUrl,
|
||||
isActive = account.isActive,
|
||||
fizzyAccountId = account.fizzyAccountId,
|
||||
fizzyAccountSlug = account.fizzyAccountSlug
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
package com.fizzy.android.data.local
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
enum class ThemeMode {
|
||||
SYSTEM,
|
||||
LIGHT,
|
||||
DARK
|
||||
}
|
||||
|
||||
interface SettingsStorage {
|
||||
val themeMode: Flow<ThemeMode>
|
||||
suspend fun setThemeMode(mode: ThemeMode)
|
||||
}
|
||||
|
||||
@Singleton
|
||||
class SettingsStorageImpl @Inject constructor(
|
||||
private val dataStore: DataStore<Preferences>
|
||||
) : SettingsStorage {
|
||||
|
||||
override val themeMode: Flow<ThemeMode> = dataStore.data.map { preferences ->
|
||||
val value = preferences[KEY_THEME_MODE] ?: ThemeMode.SYSTEM.name
|
||||
try {
|
||||
ThemeMode.valueOf(value)
|
||||
} catch (e: Exception) {
|
||||
ThemeMode.SYSTEM
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun setThemeMode(mode: ThemeMode) {
|
||||
dataStore.edit { preferences ->
|
||||
preferences[KEY_THEME_MODE] = mode.name
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val KEY_THEME_MODE = stringPreferencesKey("theme_mode")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,179 @@
|
|||
package com.fizzy.android.data.repository
|
||||
|
||||
import com.fizzy.android.core.network.ApiResult
|
||||
import com.fizzy.android.core.network.InstanceManager
|
||||
import com.fizzy.android.data.api.FizzyApiService
|
||||
import com.fizzy.android.data.api.dto.RequestMagicLinkRequest
|
||||
import com.fizzy.android.data.api.dto.VerifyMagicLinkRequest
|
||||
import com.fizzy.android.data.api.dto.getAccountId
|
||||
import com.fizzy.android.data.api.dto.getAccountSlug
|
||||
import com.fizzy.android.data.api.dto.toDomain
|
||||
import com.fizzy.android.data.api.dto.toUser
|
||||
import com.fizzy.android.data.local.AccountStorage
|
||||
import com.fizzy.android.domain.model.Account
|
||||
import com.fizzy.android.domain.model.User
|
||||
import com.fizzy.android.domain.repository.AuthRepository
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class AuthRepositoryImpl @Inject constructor(
|
||||
private val apiService: FizzyApiService,
|
||||
private val accountStorage: AccountStorage,
|
||||
private val instanceManager: InstanceManager
|
||||
) : AuthRepository {
|
||||
|
||||
override val isLoggedIn: Flow<Boolean> = accountStorage.activeAccount.map { it != null }
|
||||
|
||||
override val currentAccount: Flow<Account?> = accountStorage.activeAccount
|
||||
|
||||
override val allAccounts: Flow<List<Account>> = accountStorage.accounts
|
||||
|
||||
// Store pending authentication token for magic link verification
|
||||
private var pendingAuthToken: String? = null
|
||||
private var pendingEmail: String? = null
|
||||
|
||||
override suspend fun requestMagicLink(instanceUrl: String, email: String): ApiResult<Unit> {
|
||||
// Temporarily set the instance for this request
|
||||
instanceManager.setInstance(instanceUrl, "")
|
||||
|
||||
return ApiResult.from {
|
||||
apiService.requestMagicLink(RequestMagicLinkRequest(emailAddress = email))
|
||||
}.map { response ->
|
||||
// Store the pending auth token and email for verification step
|
||||
pendingAuthToken = response.pendingAuthenticationToken
|
||||
pendingEmail = email
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun verifyMagicLink(instanceUrl: String, email: String, code: String): ApiResult<Account> {
|
||||
instanceManager.setInstance(instanceUrl, "")
|
||||
|
||||
return ApiResult.from {
|
||||
apiService.verifyMagicLink(VerifyMagicLinkRequest(code = code))
|
||||
}.mapSuspend { response ->
|
||||
val token = response.sessionToken
|
||||
|
||||
// Set the token to fetch user identity
|
||||
instanceManager.setInstance(instanceUrl, token)
|
||||
|
||||
// Fetch user identity with the new token
|
||||
val identityResult = ApiResult.from { apiService.getCurrentIdentity() }
|
||||
val identity = when (identityResult) {
|
||||
is ApiResult.Success -> identityResult.data
|
||||
is ApiResult.Error -> throw Exception("Failed to fetch identity: ${identityResult.message}")
|
||||
is ApiResult.Exception -> throw identityResult.throwable
|
||||
}
|
||||
|
||||
val user = identity.toUser()
|
||||
val accountId = identity.getAccountId()
|
||||
val accountSlug = identity.getAccountSlug()
|
||||
|
||||
val account = Account(
|
||||
id = UUID.randomUUID().toString(),
|
||||
instanceUrl = instanceManager.getBaseUrl() ?: instanceUrl,
|
||||
email = pendingEmail ?: email,
|
||||
token = token,
|
||||
userName = user.name,
|
||||
userId = user.id,
|
||||
avatarUrl = user.avatarUrl,
|
||||
isActive = true,
|
||||
fizzyAccountId = accountId,
|
||||
fizzyAccountSlug = accountSlug
|
||||
)
|
||||
|
||||
instanceManager.setInstance(account.instanceUrl, account.token, accountSlug)
|
||||
accountStorage.addAccount(account)
|
||||
|
||||
// Clear pending state
|
||||
pendingAuthToken = null
|
||||
pendingEmail = null
|
||||
|
||||
account
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun loginWithToken(instanceUrl: String, token: String): ApiResult<Account> {
|
||||
instanceManager.setInstance(instanceUrl, token)
|
||||
|
||||
val result = ApiResult.from {
|
||||
apiService.getCurrentIdentity()
|
||||
}
|
||||
|
||||
return when (result) {
|
||||
is ApiResult.Success -> {
|
||||
val identity = result.data
|
||||
val domainUser = identity.toUser()
|
||||
val accountId = identity.getAccountId()
|
||||
val accountSlug = identity.getAccountSlug()
|
||||
|
||||
android.util.Log.d("AuthRepository", "Identity loaded: ${domainUser.email}, accountId: $accountId, slug: $accountSlug")
|
||||
|
||||
val account = Account(
|
||||
id = UUID.randomUUID().toString(),
|
||||
instanceUrl = instanceManager.getBaseUrl() ?: instanceUrl,
|
||||
email = domainUser.email,
|
||||
token = token,
|
||||
userName = domainUser.name,
|
||||
userId = domainUser.id,
|
||||
avatarUrl = domainUser.avatarUrl,
|
||||
isActive = true,
|
||||
fizzyAccountId = accountId,
|
||||
fizzyAccountSlug = accountSlug
|
||||
)
|
||||
|
||||
// Update instance manager with account slug for API calls
|
||||
instanceManager.setInstance(account.instanceUrl, account.token, accountSlug)
|
||||
accountStorage.addAccount(account)
|
||||
ApiResult.Success(account)
|
||||
}
|
||||
is ApiResult.Error -> {
|
||||
android.util.Log.e("AuthRepository", "Token login failed: ${result.code} - ${result.message}")
|
||||
ApiResult.Error(result.code, "Auth failed (${result.code}): ${result.message}")
|
||||
}
|
||||
is ApiResult.Exception -> {
|
||||
android.util.Log.e("AuthRepository", "Token login exception", result.throwable)
|
||||
ApiResult.Exception(result.throwable)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getCurrentUser(): ApiResult<User> {
|
||||
return ApiResult.from {
|
||||
apiService.getCurrentUser()
|
||||
}.map { it.toDomain() }
|
||||
}
|
||||
|
||||
override suspend fun switchAccount(accountId: String) {
|
||||
val account = accountStorage.getAccount(accountId) ?: return
|
||||
accountStorage.setActiveAccount(accountId)
|
||||
instanceManager.setInstance(account.instanceUrl, account.token, account.fizzyAccountSlug)
|
||||
}
|
||||
|
||||
override suspend fun logout(accountId: String) {
|
||||
accountStorage.removeAccount(accountId)
|
||||
|
||||
// If there's another account, switch to it
|
||||
val remainingAccount = accountStorage.getActiveAccount()
|
||||
if (remainingAccount != null) {
|
||||
instanceManager.setInstance(remainingAccount.instanceUrl, remainingAccount.token)
|
||||
} else {
|
||||
instanceManager.clearInstance()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun logoutAll() {
|
||||
accountStorage.clearAll()
|
||||
instanceManager.clearInstance()
|
||||
}
|
||||
|
||||
override suspend fun initializeActiveAccount() {
|
||||
val activeAccount = accountStorage.getActiveAccount()
|
||||
if (activeAccount != null) {
|
||||
instanceManager.setInstance(activeAccount.instanceUrl, activeAccount.token, activeAccount.fizzyAccountSlug)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,200 @@
|
|||
package com.fizzy.android.data.repository
|
||||
|
||||
import com.fizzy.android.core.network.ApiResult
|
||||
import com.fizzy.android.data.api.FizzyApiService
|
||||
import com.fizzy.android.data.api.dto.*
|
||||
import com.fizzy.android.domain.model.Board
|
||||
import com.fizzy.android.domain.model.Column
|
||||
import com.fizzy.android.domain.model.Tag
|
||||
import com.fizzy.android.domain.model.User
|
||||
import com.fizzy.android.domain.repository.BoardRepository
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class BoardRepositoryImpl @Inject constructor(
|
||||
private val apiService: FizzyApiService
|
||||
) : BoardRepository {
|
||||
|
||||
private val _boardsFlow = MutableStateFlow<List<Board>>(emptyList())
|
||||
|
||||
override fun observeBoards(): Flow<List<Board>> = _boardsFlow.asStateFlow()
|
||||
|
||||
override suspend fun getBoards(): ApiResult<List<Board>> {
|
||||
return ApiResult.from {
|
||||
apiService.getBoards()
|
||||
}.map { response ->
|
||||
val boards = response.map { it.toDomain() }
|
||||
_boardsFlow.value = boards
|
||||
boards
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getBoard(boardId: String): ApiResult<Board> {
|
||||
return ApiResult.from {
|
||||
apiService.getBoard(boardId)
|
||||
}.map { response ->
|
||||
response.toDomain()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun createBoard(name: String, description: String?): ApiResult<Board> {
|
||||
val response = apiService.createBoard(createBoardRequest(name))
|
||||
return if (response.isSuccessful) {
|
||||
// Parse board ID from Location header: /0000001/boards/{id}.json
|
||||
val location = response.headers()["Location"]
|
||||
val boardId = location?.substringAfterLast("/boards/")?.removeSuffix(".json")
|
||||
|
||||
if (boardId != null) {
|
||||
// Refresh the boards list and return the new board
|
||||
val boardsResult = getBoards()
|
||||
if (boardsResult is ApiResult.Success) {
|
||||
val newBoard = boardsResult.data.find { it.id == boardId }
|
||||
if (newBoard != null) {
|
||||
ApiResult.Success(newBoard)
|
||||
} else {
|
||||
ApiResult.Error(0, "Board created but not found in list")
|
||||
}
|
||||
} else {
|
||||
ApiResult.Error(0, "Board created but failed to refresh list")
|
||||
}
|
||||
} else {
|
||||
ApiResult.Error(0, "Board created but no ID in response")
|
||||
}
|
||||
} else {
|
||||
ApiResult.Error(response.code(), response.message())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun updateBoard(boardId: String, name: String?, description: String?): ApiResult<Board> {
|
||||
val response = apiService.updateBoard(boardId, updateBoardRequest(name))
|
||||
return if (response.isSuccessful) {
|
||||
// API returns 204 No Content, so refetch the board
|
||||
val boardResult = getBoard(boardId)
|
||||
if (boardResult is ApiResult.Success) {
|
||||
val updatedBoard = boardResult.data
|
||||
_boardsFlow.value = _boardsFlow.value.map {
|
||||
if (it.id == boardId) updatedBoard else it
|
||||
}
|
||||
ApiResult.Success(updatedBoard)
|
||||
} else {
|
||||
boardResult
|
||||
}
|
||||
} else {
|
||||
ApiResult.Error(response.code(), response.message())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun deleteBoard(boardId: String): ApiResult<Unit> {
|
||||
return ApiResult.from {
|
||||
apiService.deleteBoard(boardId)
|
||||
}.map {
|
||||
_boardsFlow.value = _boardsFlow.value.filter { it.id != boardId }
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getColumns(boardId: String): ApiResult<List<Column>> {
|
||||
return ApiResult.from {
|
||||
apiService.getColumns(boardId)
|
||||
}.map { response ->
|
||||
response.map { it.toDomain() }.sortedBy { it.position }
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun createColumn(boardId: String, name: String, position: Int?): ApiResult<Column> {
|
||||
val response = apiService.createColumn(boardId, createColumnRequest(name, position = position))
|
||||
return if (response.isSuccessful) {
|
||||
// API returns 201 with empty body, parse column ID from Location header
|
||||
val location = response.headers()["Location"]
|
||||
val columnId = location?.substringAfterLast("/columns/")?.removeSuffix(".json")
|
||||
|
||||
if (columnId != null) {
|
||||
// Refetch the columns and return the new one
|
||||
val columnsResult = getColumns(boardId)
|
||||
if (columnsResult is ApiResult.Success) {
|
||||
val newColumn = columnsResult.data.find { it.id == columnId }
|
||||
if (newColumn != null) {
|
||||
ApiResult.Success(newColumn)
|
||||
} else {
|
||||
ApiResult.Error(0, "Column created but not found in list")
|
||||
}
|
||||
} else {
|
||||
ApiResult.Error(0, "Column created but failed to refresh list")
|
||||
}
|
||||
} else {
|
||||
ApiResult.Error(0, "Column created but no ID in response")
|
||||
}
|
||||
} else {
|
||||
ApiResult.Error(response.code(), response.message())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun updateColumn(
|
||||
boardId: String,
|
||||
columnId: String,
|
||||
name: String?,
|
||||
position: Int?
|
||||
): ApiResult<Column> {
|
||||
val response = apiService.updateColumn(boardId, columnId, updateColumnRequest(name, position = position))
|
||||
return if (response.isSuccessful) {
|
||||
// API returns 204 No Content, so refetch the columns
|
||||
val columnsResult = getColumns(boardId)
|
||||
if (columnsResult is ApiResult.Success) {
|
||||
val updatedColumn = columnsResult.data.find { it.id == columnId }
|
||||
if (updatedColumn != null) {
|
||||
ApiResult.Success(updatedColumn)
|
||||
} else {
|
||||
ApiResult.Error(0, "Column updated but not found in list")
|
||||
}
|
||||
} else {
|
||||
ApiResult.Error(0, "Column updated but failed to refresh list")
|
||||
}
|
||||
} else {
|
||||
ApiResult.Error(response.code(), response.message())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun deleteColumn(boardId: String, columnId: String): ApiResult<Unit> {
|
||||
return ApiResult.from {
|
||||
apiService.deleteColumn(boardId, columnId)
|
||||
}
|
||||
}
|
||||
|
||||
// Tags are now at account level, not board level
|
||||
override suspend fun getTags(boardId: String): ApiResult<List<Tag>> {
|
||||
return ApiResult.from {
|
||||
apiService.getTags()
|
||||
}.map { response ->
|
||||
response.map { it.toDomain() }
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun createTag(boardId: String, name: String, color: String): ApiResult<Tag> {
|
||||
// Note: Tag creation at account level is not currently supported in the API service
|
||||
// This would need a new endpoint like POST /tags with wrapped request
|
||||
android.util.Log.w("BoardRepository", "createTag: Account-level tag creation not implemented")
|
||||
return ApiResult.Error(501, "Tag creation at account level not implemented")
|
||||
}
|
||||
|
||||
override suspend fun deleteTag(boardId: String, tagId: Long): ApiResult<Unit> {
|
||||
// Note: Tag deletion at account level is not currently supported in the API service
|
||||
// This would need a new endpoint like DELETE /tags/{tagId}
|
||||
android.util.Log.w("BoardRepository", "deleteTag: Account-level tag deletion not implemented")
|
||||
return ApiResult.Error(501, "Tag deletion at account level not implemented")
|
||||
}
|
||||
|
||||
override suspend fun getBoardUsers(boardId: String): ApiResult<List<User>> {
|
||||
return ApiResult.from {
|
||||
apiService.getUsers()
|
||||
}.map { users ->
|
||||
users.map { it.toDomain() }
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun refreshBoards() {
|
||||
getBoards()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,384 @@
|
|||
package com.fizzy.android.data.repository
|
||||
|
||||
import android.util.Log
|
||||
import com.fizzy.android.core.network.ApiResult
|
||||
import com.fizzy.android.data.api.FizzyApiService
|
||||
import com.fizzy.android.data.api.dto.*
|
||||
import com.fizzy.android.domain.model.Card
|
||||
import com.fizzy.android.domain.model.Comment
|
||||
import com.fizzy.android.domain.model.Step
|
||||
import com.fizzy.android.domain.repository.CardRepository
|
||||
import java.time.LocalDate
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
private const val TAG = "CardRepositoryImpl"
|
||||
|
||||
@Singleton
|
||||
class CardRepositoryImpl @Inject constructor(
|
||||
private val apiService: FizzyApiService
|
||||
) : CardRepository {
|
||||
|
||||
override suspend fun getBoardCards(boardId: String): ApiResult<List<Card>> {
|
||||
val result = ApiResult.from {
|
||||
apiService.getCards(boardId)
|
||||
}
|
||||
Log.d(TAG, "getBoardCards result: $result")
|
||||
when (result) {
|
||||
is ApiResult.Success -> Log.d(TAG, "getBoardCards success: ${result.data.size} cards")
|
||||
is ApiResult.Error -> Log.e(TAG, "getBoardCards error: ${result.code} - ${result.message}")
|
||||
is ApiResult.Exception -> Log.e(TAG, "getBoardCards exception", result.throwable)
|
||||
}
|
||||
return result.map { response ->
|
||||
response.map { it.toDomain() }.sortedBy { it.position }
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getCard(cardId: Long): ApiResult<Card> {
|
||||
return ApiResult.from {
|
||||
apiService.getCard(cardId.toInt())
|
||||
}.map { response ->
|
||||
response.toDomain()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun createCard(
|
||||
boardId: String,
|
||||
columnId: String,
|
||||
title: String,
|
||||
description: String?
|
||||
): ApiResult<Card> {
|
||||
val response = apiService.createCard(boardId, createCardRequest(title, description, columnId))
|
||||
return if (response.isSuccessful) {
|
||||
// API returns 201 with empty body, parse card number from Location header
|
||||
val location = response.headers()["Location"]
|
||||
val cardNumberStr = location?.substringAfterLast("/cards/")?.removeSuffix(".json")
|
||||
val cardNumber = cardNumberStr?.toIntOrNull()
|
||||
|
||||
if (cardNumber != null) {
|
||||
// Fetch the new card
|
||||
getCard(cardNumber.toLong())
|
||||
} else {
|
||||
ApiResult.Error(0, "Card created but no number in response")
|
||||
}
|
||||
} else {
|
||||
ApiResult.Error(response.code(), response.message())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun updateCard(cardId: Long, title: String?, description: String?): ApiResult<Card> {
|
||||
val response = apiService.updateCard(cardId.toInt(), updateCardRequest(title = title, description = description))
|
||||
return if (response.isSuccessful) {
|
||||
// API returns 204 No Content, so refetch the card
|
||||
getCard(cardId)
|
||||
} else {
|
||||
ApiResult.Error(response.code(), response.message())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun deleteCard(cardId: Long): ApiResult<Unit> {
|
||||
return ApiResult.from {
|
||||
apiService.deleteCard(cardId.toInt())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun moveCard(cardId: Long, columnId: String, position: Int): ApiResult<Card> {
|
||||
// Use triage endpoint to move card to a column
|
||||
val response = apiService.triageCard(cardId.toInt(), TriageCardRequest(columnId))
|
||||
return if (response.isSuccessful) {
|
||||
// If we also need to update position, do it separately
|
||||
if (position > 0) {
|
||||
apiService.updateCard(cardId.toInt(), updateCardRequest(columnId = columnId, position = position))
|
||||
}
|
||||
getCard(cardId)
|
||||
} else {
|
||||
ApiResult.Error(response.code(), response.message())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun closeCard(cardId: Long): ApiResult<Card> {
|
||||
val response = apiService.closeCard(cardId.toInt())
|
||||
return if (response.isSuccessful) {
|
||||
getCard(cardId)
|
||||
} else {
|
||||
ApiResult.Error(response.code(), response.message())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun reopenCard(cardId: Long): ApiResult<Card> {
|
||||
val response = apiService.reopenCard(cardId.toInt())
|
||||
return if (response.isSuccessful) {
|
||||
getCard(cardId)
|
||||
} else {
|
||||
ApiResult.Error(response.code(), response.message())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun triageCard(cardId: Long, date: LocalDate): ApiResult<Card> {
|
||||
// Note: Fizzy API triage takes column_id, not date
|
||||
// This may need to be adjusted based on actual API behavior
|
||||
val response = apiService.markCardNotNow(cardId.toInt())
|
||||
return if (response.isSuccessful) {
|
||||
getCard(cardId)
|
||||
} else {
|
||||
ApiResult.Error(response.code(), response.message())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun deferCard(cardId: Long, date: LocalDate): ApiResult<Card> {
|
||||
// Use not_now endpoint for deferring
|
||||
val response = apiService.markCardNotNow(cardId.toInt())
|
||||
return if (response.isSuccessful) {
|
||||
getCard(cardId)
|
||||
} else {
|
||||
ApiResult.Error(response.code(), response.message())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun togglePriority(cardId: Long, priority: Boolean): ApiResult<Card> {
|
||||
val response = if (priority) {
|
||||
apiService.markCardGolden(cardId.toInt())
|
||||
} else {
|
||||
apiService.unmarkCardGolden(cardId.toInt())
|
||||
}
|
||||
return if (response.isSuccessful) {
|
||||
getCard(cardId)
|
||||
} else {
|
||||
ApiResult.Error(response.code(), response.message())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun toggleWatch(cardId: Long, watching: Boolean): ApiResult<Card> {
|
||||
val response = if (watching) {
|
||||
apiService.watchCard(cardId.toInt())
|
||||
} else {
|
||||
apiService.unwatchCard(cardId.toInt())
|
||||
}
|
||||
return if (response.isSuccessful) {
|
||||
getCard(cardId)
|
||||
} else {
|
||||
ApiResult.Error(response.code(), response.message())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun addAssignee(cardId: Long, userId: Long): ApiResult<Card> {
|
||||
val response = apiService.addAssignment(cardId.toInt(), AssignmentRequest(userId.toString()))
|
||||
return if (response.isSuccessful) {
|
||||
getCard(cardId)
|
||||
} else {
|
||||
ApiResult.Error(response.code(), response.message())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun removeAssignee(cardId: Long, userId: Long): ApiResult<Card> {
|
||||
// Note: The Fizzy API may not have a direct endpoint for removing assignees
|
||||
// This might need to be done via PUT cards/{cardNumber} with updated assignee list
|
||||
// For now, refetch the card (this is a stub that needs actual API clarification)
|
||||
Log.w(TAG, "removeAssignee: API endpoint unclear, operation may not work correctly")
|
||||
return getCard(cardId)
|
||||
}
|
||||
|
||||
override suspend fun addTag(cardId: Long, tagId: Long): ApiResult<Card> {
|
||||
// Note: Fizzy API uses tag_title for taggings, not tag_id
|
||||
// This might need a separate getTags call to get the title first
|
||||
// For now, assuming the tagId is actually the tag title or we have it cached
|
||||
Log.w(TAG, "addTag: Using tagId as tag title - may need adjustment")
|
||||
val response = apiService.addTagging(cardId.toInt(), TaggingRequest(tagId.toString()))
|
||||
return if (response.isSuccessful) {
|
||||
getCard(cardId)
|
||||
} else {
|
||||
ApiResult.Error(response.code(), response.message())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun removeTag(cardId: Long, tagId: Long): ApiResult<Card> {
|
||||
// Fizzy API uses taggingId to remove, not tagId
|
||||
// This needs the tagging ID from the card's tags list
|
||||
val response = apiService.removeTagging(cardId.toInt(), tagId.toString())
|
||||
return if (response.isSuccessful) {
|
||||
getCard(cardId)
|
||||
} else {
|
||||
ApiResult.Error(response.code(), response.message())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getSteps(cardId: Long): ApiResult<List<Step>> {
|
||||
return ApiResult.from {
|
||||
apiService.getSteps(cardId.toInt())
|
||||
}.map { response ->
|
||||
response.map { it.toDomain() }.sortedBy { it.position }
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun createStep(cardId: Long, description: String): ApiResult<Step> {
|
||||
val response = apiService.createStep(cardId.toInt(), createStepRequest(description))
|
||||
return if (response.isSuccessful) {
|
||||
// API returns 201 with empty body, parse step ID from Location header
|
||||
val location = response.headers()["Location"]
|
||||
val stepIdStr = location?.substringAfterLast("/steps/")?.removeSuffix(".json")
|
||||
val stepId = stepIdStr?.toLongOrNull()
|
||||
|
||||
if (stepId != null) {
|
||||
// Refetch the steps and return the new one
|
||||
val stepsResult = getSteps(cardId)
|
||||
if (stepsResult is ApiResult.Success) {
|
||||
val newStep = stepsResult.data.find { it.id == stepId }
|
||||
if (newStep != null) {
|
||||
ApiResult.Success(newStep)
|
||||
} else {
|
||||
ApiResult.Error(0, "Step created but not found in list")
|
||||
}
|
||||
} else {
|
||||
ApiResult.Error(0, "Step created but failed to refresh list")
|
||||
}
|
||||
} else {
|
||||
ApiResult.Error(0, "Step created but no ID in response")
|
||||
}
|
||||
} else {
|
||||
ApiResult.Error(response.code(), response.message())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun updateStep(
|
||||
cardId: Long,
|
||||
stepId: Long,
|
||||
description: String?,
|
||||
completed: Boolean?,
|
||||
position: Int?
|
||||
): ApiResult<Step> {
|
||||
val response = apiService.updateStep(
|
||||
cardId.toInt(),
|
||||
stepId.toString(),
|
||||
updateStepRequest(content = description, completed = completed, position = position)
|
||||
)
|
||||
return if (response.isSuccessful) {
|
||||
// API returns 204 No Content, so refetch the steps
|
||||
val stepsResult = getSteps(cardId)
|
||||
if (stepsResult is ApiResult.Success) {
|
||||
val updatedStep = stepsResult.data.find { it.id == stepId }
|
||||
if (updatedStep != null) {
|
||||
ApiResult.Success(updatedStep)
|
||||
} else {
|
||||
ApiResult.Error(0, "Step updated but not found in list")
|
||||
}
|
||||
} else {
|
||||
ApiResult.Error(0, "Step updated but failed to refresh list")
|
||||
}
|
||||
} else {
|
||||
ApiResult.Error(response.code(), response.message())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun deleteStep(cardId: Long, stepId: Long): ApiResult<Unit> {
|
||||
return ApiResult.from {
|
||||
apiService.deleteStep(cardId.toInt(), stepId.toString())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getComments(cardId: Long): ApiResult<List<Comment>> {
|
||||
return ApiResult.from {
|
||||
apiService.getComments(cardId.toInt())
|
||||
}.map { response ->
|
||||
response.map { it.toDomain() }
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun createComment(cardId: Long, content: String): ApiResult<Comment> {
|
||||
val response = apiService.createComment(cardId.toInt(), createCommentRequest(content))
|
||||
return if (response.isSuccessful) {
|
||||
// API returns 201 with empty body, parse comment ID from Location header
|
||||
val location = response.headers()["Location"]
|
||||
val commentIdStr = location?.substringAfterLast("/comments/")?.removeSuffix(".json")
|
||||
val commentId = commentIdStr?.toLongOrNull()
|
||||
|
||||
if (commentId != null) {
|
||||
// Refetch the comments and return the new one
|
||||
val commentsResult = getComments(cardId)
|
||||
if (commentsResult is ApiResult.Success) {
|
||||
val newComment = commentsResult.data.find { it.id == commentId }
|
||||
if (newComment != null) {
|
||||
ApiResult.Success(newComment)
|
||||
} else {
|
||||
ApiResult.Error(0, "Comment created but not found in list")
|
||||
}
|
||||
} else {
|
||||
ApiResult.Error(0, "Comment created but failed to refresh list")
|
||||
}
|
||||
} else {
|
||||
ApiResult.Error(0, "Comment created but no ID in response")
|
||||
}
|
||||
} else {
|
||||
ApiResult.Error(response.code(), response.message())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun updateComment(cardId: Long, commentId: Long, content: String): ApiResult<Comment> {
|
||||
val response = apiService.updateComment(cardId.toInt(), commentId.toString(), updateCommentRequest(content))
|
||||
return if (response.isSuccessful) {
|
||||
// API returns 204 No Content, so refetch the comments
|
||||
val commentsResult = getComments(cardId)
|
||||
if (commentsResult is ApiResult.Success) {
|
||||
val updatedComment = commentsResult.data.find { it.id == commentId }
|
||||
if (updatedComment != null) {
|
||||
ApiResult.Success(updatedComment)
|
||||
} else {
|
||||
ApiResult.Error(0, "Comment updated but not found in list")
|
||||
}
|
||||
} else {
|
||||
ApiResult.Error(0, "Comment updated but failed to refresh list")
|
||||
}
|
||||
} else {
|
||||
ApiResult.Error(response.code(), response.message())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun deleteComment(cardId: Long, commentId: Long): ApiResult<Unit> {
|
||||
return ApiResult.from {
|
||||
apiService.deleteComment(cardId.toInt(), commentId.toString())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun addReaction(cardId: Long, commentId: Long, emoji: String): ApiResult<Comment> {
|
||||
val response = apiService.addReaction(cardId.toInt(), commentId.toString(), createReactionRequest(emoji))
|
||||
return if (response.isSuccessful) {
|
||||
// Refetch comments and find the updated one
|
||||
val commentsResult = getComments(cardId)
|
||||
if (commentsResult is ApiResult.Success) {
|
||||
val comment = commentsResult.data.find { it.id == commentId }
|
||||
if (comment != null) {
|
||||
ApiResult.Success(comment)
|
||||
} else {
|
||||
ApiResult.Error(0, "Reaction added but comment not found")
|
||||
}
|
||||
} else {
|
||||
ApiResult.Error(0, "Reaction added but failed to refresh comments")
|
||||
}
|
||||
} else {
|
||||
ApiResult.Error(response.code(), response.message())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun removeReaction(cardId: Long, commentId: Long, emoji: String): ApiResult<Comment> {
|
||||
// Note: Fizzy API removes by reactionId, not emoji
|
||||
// This needs the reaction ID from the comment's reactions list
|
||||
// For now, treating emoji as reactionId (needs proper implementation)
|
||||
Log.w(TAG, "removeReaction: Using emoji as reactionId - may need adjustment")
|
||||
val response = apiService.removeReaction(cardId.toInt(), commentId.toString(), emoji)
|
||||
return if (response.isSuccessful) {
|
||||
// Refetch comments and find the updated one
|
||||
val commentsResult = getComments(cardId)
|
||||
if (commentsResult is ApiResult.Success) {
|
||||
val comment = commentsResult.data.find { it.id == commentId }
|
||||
if (comment != null) {
|
||||
ApiResult.Success(comment)
|
||||
} else {
|
||||
ApiResult.Error(0, "Reaction removed but comment not found")
|
||||
}
|
||||
} else {
|
||||
ApiResult.Error(0, "Reaction removed but failed to refresh comments")
|
||||
}
|
||||
} else {
|
||||
ApiResult.Error(response.code(), response.message())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
package com.fizzy.android.data.repository
|
||||
|
||||
import com.fizzy.android.core.network.ApiResult
|
||||
import com.fizzy.android.data.api.FizzyApiService
|
||||
import com.fizzy.android.data.api.dto.toDomain
|
||||
import com.fizzy.android.domain.model.Notification
|
||||
import com.fizzy.android.domain.repository.NotificationRepository
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class NotificationRepositoryImpl @Inject constructor(
|
||||
private val apiService: FizzyApiService
|
||||
) : NotificationRepository {
|
||||
|
||||
private val _unreadCount = MutableStateFlow(0)
|
||||
override val unreadCount: Flow<Int> = _unreadCount.asStateFlow()
|
||||
|
||||
override suspend fun getNotifications(): ApiResult<List<Notification>> {
|
||||
return ApiResult.from {
|
||||
apiService.getNotifications()
|
||||
}.map { response ->
|
||||
_unreadCount.value = response.unreadCount
|
||||
response.notifications.map { it.toDomain() }
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun markAsRead(notificationId: Long): ApiResult<Unit> {
|
||||
return ApiResult.from {
|
||||
apiService.markNotificationRead(notificationId.toString())
|
||||
}.map {
|
||||
_unreadCount.value = (_unreadCount.value - 1).coerceAtLeast(0)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun markAllAsRead(): ApiResult<Unit> {
|
||||
return ApiResult.from {
|
||||
apiService.markAllNotificationsRead()
|
||||
}.map {
|
||||
_unreadCount.value = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
23
app/src/main/java/com/fizzy/android/domain/model/Account.kt
Normal file
23
app/src/main/java/com/fizzy/android/domain/model/Account.kt
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
package com.fizzy.android.domain.model
|
||||
|
||||
data class Account(
|
||||
val id: String,
|
||||
val instanceUrl: String,
|
||||
val email: String,
|
||||
val token: String,
|
||||
val userName: String,
|
||||
val userId: Long,
|
||||
val avatarUrl: String? = null,
|
||||
val isActive: Boolean = false,
|
||||
val fizzyAccountId: String? = null,
|
||||
val fizzyAccountSlug: String? = null
|
||||
) {
|
||||
val displayName: String
|
||||
get() = "$userName ($instanceUrl)"
|
||||
|
||||
val instanceHost: String
|
||||
get() = instanceUrl
|
||||
.removePrefix("https://")
|
||||
.removePrefix("http://")
|
||||
.removeSuffix("/")
|
||||
}
|
||||
16
app/src/main/java/com/fizzy/android/domain/model/Board.kt
Normal file
16
app/src/main/java/com/fizzy/android/domain/model/Board.kt
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
package com.fizzy.android.domain.model
|
||||
|
||||
import java.time.Instant
|
||||
|
||||
data class Board(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val description: String? = null,
|
||||
val createdAt: Instant,
|
||||
val updatedAt: Instant? = null,
|
||||
val cardsCount: Int = 0,
|
||||
val columnsCount: Int = 0,
|
||||
val creator: User? = null,
|
||||
val allAccess: Boolean = false,
|
||||
val url: String? = null
|
||||
)
|
||||
42
app/src/main/java/com/fizzy/android/domain/model/Card.kt
Normal file
42
app/src/main/java/com/fizzy/android/domain/model/Card.kt
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
package com.fizzy.android.domain.model
|
||||
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
|
||||
data class Card(
|
||||
val id: Long,
|
||||
val title: String,
|
||||
val description: String? = null,
|
||||
val position: Int,
|
||||
val columnId: String,
|
||||
val boardId: String,
|
||||
val status: CardStatus = CardStatus.ACTIVE,
|
||||
val priority: Boolean = false,
|
||||
val watching: Boolean = false,
|
||||
val triageAt: LocalDate? = null,
|
||||
val deferUntil: LocalDate? = null,
|
||||
val createdAt: Instant,
|
||||
val updatedAt: Instant,
|
||||
val creator: User? = null,
|
||||
val assignees: List<User> = emptyList(),
|
||||
val tags: List<Tag> = emptyList(),
|
||||
val stepsTotal: Int = 0,
|
||||
val stepsCompleted: Int = 0,
|
||||
val commentsCount: Int = 0
|
||||
) {
|
||||
val hasSteps: Boolean
|
||||
get() = stepsTotal > 0
|
||||
|
||||
val stepsProgress: Float
|
||||
get() = if (stepsTotal > 0) stepsCompleted.toFloat() / stepsTotal else 0f
|
||||
|
||||
val stepsDisplay: String
|
||||
get() = "$stepsCompleted/$stepsTotal"
|
||||
}
|
||||
|
||||
enum class CardStatus {
|
||||
ACTIVE,
|
||||
CLOSED,
|
||||
TRIAGED,
|
||||
DEFERRED
|
||||
}
|
||||
10
app/src/main/java/com/fizzy/android/domain/model/Column.kt
Normal file
10
app/src/main/java/com/fizzy/android/domain/model/Column.kt
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
package com.fizzy.android.domain.model
|
||||
|
||||
data class Column(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val position: Int,
|
||||
val boardId: String,
|
||||
val cards: List<Card> = emptyList(),
|
||||
val cardsCount: Int = 0
|
||||
)
|
||||
20
app/src/main/java/com/fizzy/android/domain/model/Comment.kt
Normal file
20
app/src/main/java/com/fizzy/android/domain/model/Comment.kt
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
package com.fizzy.android.domain.model
|
||||
|
||||
import java.time.Instant
|
||||
|
||||
data class Comment(
|
||||
val id: Long,
|
||||
val content: String,
|
||||
val cardId: Long,
|
||||
val author: User,
|
||||
val createdAt: Instant,
|
||||
val updatedAt: Instant,
|
||||
val reactions: List<Reaction> = emptyList()
|
||||
)
|
||||
|
||||
data class Reaction(
|
||||
val emoji: String,
|
||||
val count: Int,
|
||||
val users: List<User> = emptyList(),
|
||||
val reactedByMe: Boolean = false
|
||||
)
|
||||
|
|
@ -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
|
||||
}
|
||||
13
app/src/main/java/com/fizzy/android/domain/model/Step.kt
Normal file
13
app/src/main/java/com/fizzy/android/domain/model/Step.kt
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
package com.fizzy.android.domain.model
|
||||
|
||||
import java.time.Instant
|
||||
|
||||
data class Step(
|
||||
val id: Long,
|
||||
val description: String,
|
||||
val completed: Boolean,
|
||||
val position: Int,
|
||||
val cardId: Long,
|
||||
val completedBy: User? = null,
|
||||
val completedAt: Instant? = null
|
||||
)
|
||||
24
app/src/main/java/com/fizzy/android/domain/model/Tag.kt
Normal file
24
app/src/main/java/com/fizzy/android/domain/model/Tag.kt
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
package com.fizzy.android.domain.model
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
data class Tag(
|
||||
val id: Long,
|
||||
val name: String,
|
||||
val color: String
|
||||
) {
|
||||
val backgroundColor: Color
|
||||
get() = try {
|
||||
Color(android.graphics.Color.parseColor(color))
|
||||
} catch (e: Exception) {
|
||||
Color(0xFF6B7280) // Default gray
|
||||
}
|
||||
|
||||
val textColor: Color
|
||||
get() {
|
||||
// Calculate luminance and return white or black
|
||||
val bgColor = backgroundColor
|
||||
val luminance = 0.299 * bgColor.red + 0.587 * bgColor.green + 0.114 * bgColor.blue
|
||||
return if (luminance > 0.5) Color.Black else Color.White
|
||||
}
|
||||
}
|
||||
9
app/src/main/java/com/fizzy/android/domain/model/User.kt
Normal file
9
app/src/main/java/com/fizzy/android/domain/model/User.kt
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
package com.fizzy.android.domain.model
|
||||
|
||||
data class User(
|
||||
val id: Long,
|
||||
val name: String,
|
||||
val email: String,
|
||||
val avatarUrl: String? = null,
|
||||
val admin: Boolean = false
|
||||
)
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
package com.fizzy.android.domain.repository
|
||||
|
||||
import com.fizzy.android.core.network.ApiResult
|
||||
import com.fizzy.android.domain.model.Account
|
||||
import com.fizzy.android.domain.model.User
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
interface AuthRepository {
|
||||
val isLoggedIn: Flow<Boolean>
|
||||
val currentAccount: Flow<Account?>
|
||||
val allAccounts: Flow<List<Account>>
|
||||
|
||||
suspend fun requestMagicLink(instanceUrl: String, email: String): ApiResult<Unit>
|
||||
suspend fun verifyMagicLink(instanceUrl: String, email: String, code: String): ApiResult<Account>
|
||||
suspend fun loginWithToken(instanceUrl: String, token: String): ApiResult<Account>
|
||||
suspend fun getCurrentUser(): ApiResult<User>
|
||||
|
||||
suspend fun switchAccount(accountId: String)
|
||||
suspend fun logout(accountId: String)
|
||||
suspend fun logoutAll()
|
||||
|
||||
suspend fun initializeActiveAccount()
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
package com.fizzy.android.domain.repository
|
||||
|
||||
import com.fizzy.android.core.network.ApiResult
|
||||
import com.fizzy.android.domain.model.Board
|
||||
import com.fizzy.android.domain.model.Column
|
||||
import com.fizzy.android.domain.model.Tag
|
||||
import com.fizzy.android.domain.model.User
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
interface BoardRepository {
|
||||
fun observeBoards(): Flow<List<Board>>
|
||||
|
||||
suspend fun getBoards(): ApiResult<List<Board>>
|
||||
suspend fun getBoard(boardId: String): ApiResult<Board>
|
||||
suspend fun createBoard(name: String, description: String?): ApiResult<Board>
|
||||
suspend fun updateBoard(boardId: String, name: String?, description: String?): ApiResult<Board>
|
||||
suspend fun deleteBoard(boardId: String): ApiResult<Unit>
|
||||
|
||||
suspend fun getColumns(boardId: String): ApiResult<List<Column>>
|
||||
suspend fun createColumn(boardId: String, name: String, position: Int?): ApiResult<Column>
|
||||
suspend fun updateColumn(boardId: String, columnId: String, name: String?, position: Int?): ApiResult<Column>
|
||||
suspend fun deleteColumn(boardId: String, columnId: String): ApiResult<Unit>
|
||||
|
||||
suspend fun getTags(boardId: String): ApiResult<List<Tag>>
|
||||
suspend fun createTag(boardId: String, name: String, color: String): ApiResult<Tag>
|
||||
suspend fun deleteTag(boardId: String, tagId: Long): ApiResult<Unit>
|
||||
|
||||
suspend fun getBoardUsers(boardId: String): ApiResult<List<User>>
|
||||
|
||||
suspend fun refreshBoards()
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
package com.fizzy.android.domain.repository
|
||||
|
||||
import com.fizzy.android.core.network.ApiResult
|
||||
import com.fizzy.android.domain.model.Card
|
||||
import com.fizzy.android.domain.model.Comment
|
||||
import com.fizzy.android.domain.model.Step
|
||||
import java.time.LocalDate
|
||||
|
||||
interface CardRepository {
|
||||
suspend fun getBoardCards(boardId: String): ApiResult<List<Card>>
|
||||
suspend fun getCard(cardId: Long): ApiResult<Card>
|
||||
suspend fun createCard(boardId: String, columnId: String, title: String, description: String?): ApiResult<Card>
|
||||
suspend fun updateCard(cardId: Long, title: String?, description: String?): ApiResult<Card>
|
||||
suspend fun deleteCard(cardId: Long): ApiResult<Unit>
|
||||
suspend fun moveCard(cardId: Long, columnId: String, position: Int): ApiResult<Card>
|
||||
|
||||
// Card actions
|
||||
suspend fun closeCard(cardId: Long): ApiResult<Card>
|
||||
suspend fun reopenCard(cardId: Long): ApiResult<Card>
|
||||
suspend fun triageCard(cardId: Long, date: LocalDate): ApiResult<Card>
|
||||
suspend fun deferCard(cardId: Long, date: LocalDate): ApiResult<Card>
|
||||
suspend fun togglePriority(cardId: Long, priority: Boolean): ApiResult<Card>
|
||||
suspend fun toggleWatch(cardId: Long, watching: Boolean): ApiResult<Card>
|
||||
|
||||
// Assignees
|
||||
suspend fun addAssignee(cardId: Long, userId: Long): ApiResult<Card>
|
||||
suspend fun removeAssignee(cardId: Long, userId: Long): ApiResult<Card>
|
||||
|
||||
// Tags
|
||||
suspend fun addTag(cardId: Long, tagId: Long): ApiResult<Card>
|
||||
suspend fun removeTag(cardId: Long, tagId: Long): ApiResult<Card>
|
||||
|
||||
// Steps
|
||||
suspend fun getSteps(cardId: Long): ApiResult<List<Step>>
|
||||
suspend fun createStep(cardId: Long, description: String): ApiResult<Step>
|
||||
suspend fun updateStep(cardId: Long, stepId: Long, description: String?, completed: Boolean?, position: Int?): ApiResult<Step>
|
||||
suspend fun deleteStep(cardId: Long, stepId: Long): ApiResult<Unit>
|
||||
|
||||
// Comments
|
||||
suspend fun getComments(cardId: Long): ApiResult<List<Comment>>
|
||||
suspend fun createComment(cardId: Long, content: String): ApiResult<Comment>
|
||||
suspend fun updateComment(cardId: Long, commentId: Long, content: String): ApiResult<Comment>
|
||||
suspend fun deleteComment(cardId: Long, commentId: Long): ApiResult<Unit>
|
||||
|
||||
// Reactions
|
||||
suspend fun addReaction(cardId: Long, commentId: Long, emoji: String): ApiResult<Comment>
|
||||
suspend fun removeReaction(cardId: Long, commentId: Long, emoji: String): ApiResult<Comment>
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
package com.fizzy.android.domain.repository
|
||||
|
||||
import com.fizzy.android.core.network.ApiResult
|
||||
import com.fizzy.android.domain.model.Notification
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
interface NotificationRepository {
|
||||
val unreadCount: Flow<Int>
|
||||
|
||||
suspend fun getNotifications(): ApiResult<List<Notification>>
|
||||
suspend fun markAsRead(notificationId: Long): ApiResult<Unit>
|
||||
suspend fun markAllAsRead(): ApiResult<Unit>
|
||||
}
|
||||
536
app/src/main/java/com/fizzy/android/feature/auth/AuthScreen.kt
Normal file
536
app/src/main/java/com/fizzy/android/feature/auth/AuthScreen.kt
Normal file
|
|
@ -0,0 +1,536 @@
|
|||
package com.fizzy.android.feature.auth
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.fizzy.android.core.ui.components.SmallLoadingIndicator
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AuthScreen(
|
||||
onAuthSuccess: () -> Unit,
|
||||
viewModel: AuthViewModel = hiltViewModel()
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.events.collectLatest { event ->
|
||||
when (event) {
|
||||
is AuthEvent.AuthSuccess -> onAuthSuccess()
|
||||
is AuthEvent.ShowError -> { /* Error shown in UI state */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BackHandler(enabled = uiState.step != AuthStep.INSTANCE_SELECTION) {
|
||||
viewModel.goBack()
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
if (uiState.step != AuthStep.INSTANCE_SELECTION) {
|
||||
TopAppBar(
|
||||
title = { },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { viewModel.goBack() }) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = "Back"
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
) { paddingValues ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
// Logo
|
||||
Icon(
|
||||
imageVector = Icons.Default.ViewKanban,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(80.dp),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
text = "Fizzy",
|
||||
style = MaterialTheme.typography.headlineLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "Kanban boards, simplified",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(48.dp))
|
||||
|
||||
AnimatedContent(
|
||||
targetState = uiState.step,
|
||||
transitionSpec = {
|
||||
slideInHorizontally { width -> width } + fadeIn() togetherWith
|
||||
slideOutHorizontally { width -> -width } + fadeOut()
|
||||
},
|
||||
label = "auth_step"
|
||||
) { step ->
|
||||
when (step) {
|
||||
AuthStep.INSTANCE_SELECTION -> InstanceSelectionContent(
|
||||
instanceUrl = uiState.instanceUrl,
|
||||
onInstanceUrlChange = viewModel::onInstanceUrlChange,
|
||||
onUseOfficial = viewModel::useOfficialInstance,
|
||||
onContinue = viewModel::onContinueWithInstance,
|
||||
error = uiState.error
|
||||
)
|
||||
AuthStep.EMAIL_INPUT -> EmailInputContent(
|
||||
email = uiState.email,
|
||||
onEmailChange = viewModel::onEmailChange,
|
||||
onContinue = viewModel::requestMagicLink,
|
||||
onToggleMethod = viewModel::toggleAuthMethod,
|
||||
isLoading = uiState.isLoading,
|
||||
error = uiState.error
|
||||
)
|
||||
AuthStep.CODE_VERIFICATION -> CodeVerificationContent(
|
||||
email = uiState.email,
|
||||
code = uiState.code,
|
||||
onCodeChange = viewModel::onCodeChange,
|
||||
onVerify = viewModel::verifyCode,
|
||||
onResend = viewModel::requestMagicLink,
|
||||
isLoading = uiState.isLoading,
|
||||
error = uiState.error
|
||||
)
|
||||
AuthStep.PERSONAL_TOKEN -> PersonalTokenContent(
|
||||
token = uiState.token,
|
||||
onTokenChange = viewModel::onTokenChange,
|
||||
onLogin = viewModel::loginWithToken,
|
||||
onToggleMethod = viewModel::toggleAuthMethod,
|
||||
isLoading = uiState.isLoading,
|
||||
error = uiState.error
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InstanceSelectionContent(
|
||||
instanceUrl: String,
|
||||
onInstanceUrlChange: (String) -> Unit,
|
||||
onUseOfficial: () -> Unit,
|
||||
onContinue: () -> Unit,
|
||||
error: String?
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = "Choose your instance",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = "Connect to the official Fizzy service or your self-hosted instance",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
Button(
|
||||
onClick = onUseOfficial,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Icon(Icons.Default.Cloud, contentDescription = null)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Use fizzy.com")
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
||||
|
||||
Text(
|
||||
text = "Or connect to self-hosted",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = instanceUrl,
|
||||
onValueChange = onInstanceUrlChange,
|
||||
label = { Text("Instance URL") },
|
||||
placeholder = { Text("https://fizzy.example.com") },
|
||||
leadingIcon = { Icon(Icons.Default.Language, contentDescription = null) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Uri,
|
||||
imeAction = ImeAction.Go
|
||||
),
|
||||
keyboardActions = KeyboardActions(onGo = { onContinue() }),
|
||||
isError = error != null
|
||||
)
|
||||
|
||||
if (error != null) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
OutlinedButton(
|
||||
onClick = onContinue,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = instanceUrl.isNotBlank()
|
||||
) {
|
||||
Text("Continue")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmailInputContent(
|
||||
email: String,
|
||||
onEmailChange: (String) -> Unit,
|
||||
onContinue: () -> Unit,
|
||||
onToggleMethod: () -> Unit,
|
||||
isLoading: Boolean,
|
||||
error: String?
|
||||
) {
|
||||
val focusManager = LocalFocusManager.current
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = "Sign in with email",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = "We'll send you a magic link to sign in",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = email,
|
||||
onValueChange = onEmailChange,
|
||||
label = { Text("Email address") },
|
||||
placeholder = { Text("you@example.com") },
|
||||
leadingIcon = { Icon(Icons.Default.Email, contentDescription = null) },
|
||||
singleLine = true,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRequester(focusRequester),
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Email,
|
||||
imeAction = ImeAction.Go
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onGo = {
|
||||
focusManager.clearFocus()
|
||||
onContinue()
|
||||
}
|
||||
),
|
||||
isError = error != null,
|
||||
enabled = !isLoading
|
||||
)
|
||||
|
||||
if (error != null) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
focusManager.clearFocus()
|
||||
onContinue()
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = email.isNotBlank() && !isLoading
|
||||
) {
|
||||
if (isLoading) {
|
||||
SmallLoadingIndicator()
|
||||
} else {
|
||||
Text("Send Magic Link")
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
TextButton(onClick = onToggleMethod) {
|
||||
Text("Use Personal Access Token instead")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CodeVerificationContent(
|
||||
email: String,
|
||||
code: String,
|
||||
onCodeChange: (String) -> Unit,
|
||||
onVerify: () -> Unit,
|
||||
onResend: () -> Unit,
|
||||
isLoading: Boolean,
|
||||
error: String?
|
||||
) {
|
||||
val focusManager = LocalFocusManager.current
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = "Enter your code",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = "We sent a 6-character code to\n$email",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = code,
|
||||
onValueChange = onCodeChange,
|
||||
label = { Text("Verification code") },
|
||||
placeholder = { Text("XXXXXX") },
|
||||
leadingIcon = { Icon(Icons.Default.Key, contentDescription = null) },
|
||||
singleLine = true,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRequester(focusRequester),
|
||||
textStyle = MaterialTheme.typography.headlineSmall.copy(
|
||||
textAlign = TextAlign.Center,
|
||||
letterSpacing = 8.sp
|
||||
),
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Text,
|
||||
imeAction = ImeAction.Go
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onGo = {
|
||||
focusManager.clearFocus()
|
||||
onVerify()
|
||||
}
|
||||
),
|
||||
isError = error != null,
|
||||
enabled = !isLoading
|
||||
)
|
||||
|
||||
if (error != null) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
focusManager.clearFocus()
|
||||
onVerify()
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = code.length == 6 && !isLoading
|
||||
) {
|
||||
if (isLoading) {
|
||||
SmallLoadingIndicator()
|
||||
} else {
|
||||
Text("Verify")
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
TextButton(onClick = onResend, enabled = !isLoading) {
|
||||
Text("Resend code")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PersonalTokenContent(
|
||||
token: String,
|
||||
onTokenChange: (String) -> Unit,
|
||||
onLogin: () -> Unit,
|
||||
onToggleMethod: () -> Unit,
|
||||
isLoading: Boolean,
|
||||
error: String?
|
||||
) {
|
||||
var showToken by remember { mutableStateOf(false) }
|
||||
val focusManager = LocalFocusManager.current
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = "Personal Access Token",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = "Enter your Personal Access Token to sign in",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = token,
|
||||
onValueChange = onTokenChange,
|
||||
label = { Text("Access Token") },
|
||||
leadingIcon = { Icon(Icons.Default.VpnKey, contentDescription = null) },
|
||||
trailingIcon = {
|
||||
IconButton(onClick = { showToken = !showToken }) {
|
||||
Icon(
|
||||
imageVector = if (showToken) Icons.Default.VisibilityOff else Icons.Default.Visibility,
|
||||
contentDescription = if (showToken) "Hide token" else "Show token"
|
||||
)
|
||||
}
|
||||
},
|
||||
singleLine = true,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRequester(focusRequester),
|
||||
visualTransformation = if (showToken) VisualTransformation.None else PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Password,
|
||||
imeAction = ImeAction.Go
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onGo = {
|
||||
focusManager.clearFocus()
|
||||
onLogin()
|
||||
}
|
||||
),
|
||||
isError = error != null,
|
||||
enabled = !isLoading
|
||||
)
|
||||
|
||||
if (error != null) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
focusManager.clearFocus()
|
||||
onLogin()
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = token.isNotBlank() && !isLoading
|
||||
) {
|
||||
if (isLoading) {
|
||||
SmallLoadingIndicator()
|
||||
} else {
|
||||
Text("Sign In")
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
TextButton(onClick = onToggleMethod) {
|
||||
Text("Use Magic Link instead")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,243 @@
|
|||
package com.fizzy.android.feature.auth
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.fizzy.android.core.network.ApiResult
|
||||
import com.fizzy.android.core.network.InstanceManager
|
||||
import com.fizzy.android.domain.repository.AuthRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
data class AuthUiState(
|
||||
val step: AuthStep = AuthStep.INSTANCE_SELECTION,
|
||||
val instanceUrl: String = InstanceManager.OFFICIAL_INSTANCE,
|
||||
val email: String = "",
|
||||
val code: String = "",
|
||||
val token: String = "",
|
||||
val isLoading: Boolean = false,
|
||||
val error: String? = null,
|
||||
val usePersonalToken: Boolean = false
|
||||
)
|
||||
|
||||
enum class AuthStep {
|
||||
INSTANCE_SELECTION,
|
||||
EMAIL_INPUT,
|
||||
CODE_VERIFICATION,
|
||||
PERSONAL_TOKEN
|
||||
}
|
||||
|
||||
sealed class AuthEvent {
|
||||
data object AuthSuccess : AuthEvent()
|
||||
data class ShowError(val message: String) : AuthEvent()
|
||||
}
|
||||
|
||||
@HiltViewModel
|
||||
class AuthViewModel @Inject constructor(
|
||||
private val authRepository: AuthRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(AuthUiState())
|
||||
val uiState: StateFlow<AuthUiState> = _uiState.asStateFlow()
|
||||
|
||||
private val _events = MutableSharedFlow<AuthEvent>()
|
||||
val events: SharedFlow<AuthEvent> = _events.asSharedFlow()
|
||||
|
||||
val isLoggedIn: StateFlow<Boolean> = authRepository.isLoggedIn
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
|
||||
|
||||
fun initializeAuth() {
|
||||
viewModelScope.launch {
|
||||
authRepository.initializeActiveAccount()
|
||||
}
|
||||
}
|
||||
|
||||
fun onInstanceUrlChange(url: String) {
|
||||
_uiState.update { it.copy(instanceUrl = url, error = null) }
|
||||
}
|
||||
|
||||
fun onEmailChange(email: String) {
|
||||
_uiState.update { it.copy(email = email, error = null) }
|
||||
}
|
||||
|
||||
fun onCodeChange(code: String) {
|
||||
_uiState.update { it.copy(code = code.uppercase().take(6), error = null) }
|
||||
}
|
||||
|
||||
fun onTokenChange(token: String) {
|
||||
_uiState.update { it.copy(token = token, error = null) }
|
||||
}
|
||||
|
||||
fun useOfficialInstance() {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
instanceUrl = InstanceManager.OFFICIAL_INSTANCE,
|
||||
step = AuthStep.EMAIL_INPUT,
|
||||
error = null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun useSelfHosted() {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
instanceUrl = "",
|
||||
error = null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun onContinueWithInstance() {
|
||||
val url = _uiState.value.instanceUrl.trim()
|
||||
if (url.isBlank()) {
|
||||
_uiState.update { it.copy(error = "Please enter a valid instance URL") }
|
||||
return
|
||||
}
|
||||
_uiState.update { it.copy(step = AuthStep.EMAIL_INPUT, error = null) }
|
||||
}
|
||||
|
||||
fun toggleAuthMethod() {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
usePersonalToken = !it.usePersonalToken,
|
||||
step = if (!it.usePersonalToken) AuthStep.PERSONAL_TOKEN else AuthStep.EMAIL_INPUT,
|
||||
error = null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun requestMagicLink() {
|
||||
val email = _uiState.value.email.trim()
|
||||
if (email.isBlank() || !email.contains("@")) {
|
||||
_uiState.update { it.copy(error = "Please enter a valid email address") }
|
||||
return
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isLoading = true, error = null) }
|
||||
|
||||
when (val result = authRepository.requestMagicLink(_uiState.value.instanceUrl, email)) {
|
||||
is ApiResult.Success -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
step = AuthStep.CODE_VERIFICATION
|
||||
)
|
||||
}
|
||||
}
|
||||
is ApiResult.Error -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
error = "Failed to send magic link: ${result.message}"
|
||||
)
|
||||
}
|
||||
}
|
||||
is ApiResult.Exception -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
error = "Network error. Please check your connection."
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun verifyCode() {
|
||||
val code = _uiState.value.code.trim()
|
||||
if (code.length != 6) {
|
||||
_uiState.update { it.copy(error = "Please enter the 6-character code") }
|
||||
return
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isLoading = true, error = null) }
|
||||
|
||||
when (val result = authRepository.verifyMagicLink(
|
||||
_uiState.value.instanceUrl,
|
||||
_uiState.value.email,
|
||||
code
|
||||
)) {
|
||||
is ApiResult.Success -> {
|
||||
_uiState.update { it.copy(isLoading = false) }
|
||||
_events.emit(AuthEvent.AuthSuccess)
|
||||
}
|
||||
is ApiResult.Error -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
error = if (result.code == 401) "Invalid code. Please try again." else "Verification failed: ${result.message}"
|
||||
)
|
||||
}
|
||||
}
|
||||
is ApiResult.Exception -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
error = "Network error. Please check your connection."
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun loginWithToken() {
|
||||
val token = _uiState.value.token.trim()
|
||||
if (token.isBlank()) {
|
||||
_uiState.update { it.copy(error = "Please enter your personal access token") }
|
||||
return
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isLoading = true, error = null) }
|
||||
|
||||
when (val result = authRepository.loginWithToken(_uiState.value.instanceUrl, token)) {
|
||||
is ApiResult.Success -> {
|
||||
_uiState.update { it.copy(isLoading = false) }
|
||||
_events.emit(AuthEvent.AuthSuccess)
|
||||
}
|
||||
is ApiResult.Error -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
error = result.message ?: "Invalid token or authentication failed"
|
||||
)
|
||||
}
|
||||
}
|
||||
is ApiResult.Exception -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
error = "Network error. Please check your connection."
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun goBack() {
|
||||
_uiState.update { state ->
|
||||
when (state.step) {
|
||||
AuthStep.EMAIL_INPUT, AuthStep.PERSONAL_TOKEN -> state.copy(
|
||||
step = AuthStep.INSTANCE_SELECTION,
|
||||
error = null
|
||||
)
|
||||
AuthStep.CODE_VERIFICATION -> state.copy(
|
||||
step = AuthStep.EMAIL_INPUT,
|
||||
code = "",
|
||||
error = null
|
||||
)
|
||||
else -> state
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clearError() {
|
||||
_uiState.update { it.copy(error = null) }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,426 @@
|
|||
package com.fizzy.android.feature.boards
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.fizzy.android.core.ui.components.EmptyState
|
||||
import com.fizzy.android.core.ui.components.ErrorMessage
|
||||
import com.fizzy.android.core.ui.components.LoadingIndicator
|
||||
import com.fizzy.android.domain.model.Board
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.FormatStyle
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun BoardListScreen(
|
||||
onBoardClick: (String) -> Unit,
|
||||
onNotificationsClick: () -> Unit,
|
||||
onSettingsClick: () -> Unit,
|
||||
viewModel: BoardListViewModel = hiltViewModel()
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
val unreadCount by viewModel.unreadNotificationsCount.collectAsState()
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
var showSearch by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.events.collectLatest { event ->
|
||||
when (event) {
|
||||
is BoardListEvent.NavigateToBoard -> onBoardClick(event.boardId)
|
||||
is BoardListEvent.ShowError -> snackbarHostState.showSnackbar(event.message)
|
||||
BoardListEvent.BoardCreated -> snackbarHostState.showSnackbar("Board created")
|
||||
BoardListEvent.BoardUpdated -> snackbarHostState.showSnackbar("Board updated")
|
||||
BoardListEvent.BoardDeleted -> snackbarHostState.showSnackbar("Board deleted")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
if (showSearch) {
|
||||
OutlinedTextField(
|
||||
value = uiState.searchQuery,
|
||||
onValueChange = viewModel::onSearchQueryChange,
|
||||
placeholder = { Text("Search boards...") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = MaterialTheme.colorScheme.primary,
|
||||
unfocusedBorderColor = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
)
|
||||
} else {
|
||||
Text("Boards")
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
if (showSearch) {
|
||||
IconButton(onClick = {
|
||||
showSearch = false
|
||||
viewModel.clearSearch()
|
||||
}) {
|
||||
Icon(Icons.Default.Close, contentDescription = "Close search")
|
||||
}
|
||||
} else {
|
||||
IconButton(onClick = { showSearch = true }) {
|
||||
Icon(Icons.Default.Search, contentDescription = "Search")
|
||||
}
|
||||
|
||||
IconButton(onClick = viewModel::refresh) {
|
||||
Icon(Icons.Default.Refresh, contentDescription = "Refresh")
|
||||
}
|
||||
|
||||
BadgedBox(
|
||||
badge = {
|
||||
if (unreadCount > 0) {
|
||||
Badge { Text(unreadCount.toString()) }
|
||||
}
|
||||
}
|
||||
) {
|
||||
IconButton(onClick = onNotificationsClick) {
|
||||
Icon(Icons.Default.Notifications, contentDescription = "Notifications")
|
||||
}
|
||||
}
|
||||
|
||||
IconButton(onClick = onSettingsClick) {
|
||||
Icon(Icons.Default.Settings, contentDescription = "Settings")
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
FloatingActionButton(
|
||||
onClick = { viewModel.showCreateDialog() },
|
||||
containerColor = MaterialTheme.colorScheme.primary
|
||||
) {
|
||||
Icon(Icons.Default.Add, contentDescription = "Create board")
|
||||
}
|
||||
},
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) }
|
||||
) { paddingValues ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
) {
|
||||
when {
|
||||
uiState.isLoading && uiState.boards.isEmpty() -> {
|
||||
LoadingIndicator()
|
||||
}
|
||||
uiState.error != null && uiState.boards.isEmpty() -> {
|
||||
ErrorMessage(
|
||||
message = uiState.error ?: "Unknown error",
|
||||
onRetry = viewModel::loadBoards
|
||||
)
|
||||
}
|
||||
uiState.filteredBoards.isEmpty() && uiState.searchQuery.isNotEmpty() -> {
|
||||
EmptyState(
|
||||
icon = Icons.Default.SearchOff,
|
||||
title = "No boards found",
|
||||
description = "Try a different search term"
|
||||
)
|
||||
}
|
||||
uiState.boards.isEmpty() -> {
|
||||
EmptyState(
|
||||
icon = Icons.Default.ViewKanban,
|
||||
title = "No boards yet",
|
||||
description = "Create your first board to get started",
|
||||
action = {
|
||||
Button(onClick = { viewModel.showCreateDialog() }) {
|
||||
Icon(Icons.Default.Add, contentDescription = null)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Create Board")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
BoardList(
|
||||
boards = uiState.filteredBoards,
|
||||
onBoardClick = onBoardClick,
|
||||
onEditClick = viewModel::showEditDialog,
|
||||
onDeleteClick = viewModel::showDeleteConfirmation
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Show loading indicator when refreshing
|
||||
if (uiState.isRefreshing) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.padding(16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create Board Dialog
|
||||
if (uiState.showCreateDialog) {
|
||||
BoardDialog(
|
||||
title = "Create Board",
|
||||
initialName = "",
|
||||
initialDescription = "",
|
||||
onDismiss = viewModel::hideCreateDialog,
|
||||
onConfirm = { name, description ->
|
||||
viewModel.createBoard(name, description)
|
||||
},
|
||||
isLoading = uiState.isLoading
|
||||
)
|
||||
}
|
||||
|
||||
// Edit Board Dialog
|
||||
uiState.showEditDialog?.let { board ->
|
||||
BoardDialog(
|
||||
title = "Edit Board",
|
||||
initialName = board.name,
|
||||
initialDescription = board.description ?: "",
|
||||
onDismiss = viewModel::hideEditDialog,
|
||||
onConfirm = { name, description ->
|
||||
viewModel.updateBoard(board.id, name, description)
|
||||
},
|
||||
isLoading = uiState.isLoading
|
||||
)
|
||||
}
|
||||
|
||||
// Delete Confirmation Dialog
|
||||
uiState.showDeleteConfirmation?.let { board ->
|
||||
AlertDialog(
|
||||
onDismissRequest = viewModel::hideDeleteConfirmation,
|
||||
title = { Text("Delete Board") },
|
||||
text = { Text("Are you sure you want to delete \"${board.name}\"? This action cannot be undone.") },
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = { viewModel.deleteBoard(board.id) },
|
||||
colors = ButtonDefaults.textButtonColors(
|
||||
contentColor = MaterialTheme.colorScheme.error
|
||||
)
|
||||
) {
|
||||
Text("Delete")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = viewModel::hideDeleteConfirmation) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BoardList(
|
||||
boards: List<Board>,
|
||||
onBoardClick: (String) -> Unit,
|
||||
onEditClick: (Board) -> Unit,
|
||||
onDeleteClick: (Board) -> Unit
|
||||
) {
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
items(boards, key = { it.id }) { board ->
|
||||
BoardCard(
|
||||
board = board,
|
||||
onClick = { onBoardClick(board.id) },
|
||||
onEditClick = { onEditClick(board) },
|
||||
onDeleteClick = { onDeleteClick(board) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BoardCard(
|
||||
board: Board,
|
||||
onClick: () -> Unit,
|
||||
onEditClick: () -> Unit,
|
||||
onDeleteClick: () -> Unit
|
||||
) {
|
||||
var showMenu by remember { mutableStateOf(false) }
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = board.name,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
|
||||
Box {
|
||||
IconButton(onClick = { showMenu = true }) {
|
||||
Icon(
|
||||
Icons.Default.MoreVert,
|
||||
contentDescription = "More options"
|
||||
)
|
||||
}
|
||||
|
||||
DropdownMenu(
|
||||
expanded = showMenu,
|
||||
onDismissRequest = { showMenu = false }
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Edit") },
|
||||
leadingIcon = { Icon(Icons.Default.Edit, contentDescription = null) },
|
||||
onClick = {
|
||||
showMenu = false
|
||||
onEditClick()
|
||||
}
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("Delete") },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
Icons.Default.Delete,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
showMenu = false
|
||||
onDeleteClick()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!board.description.isNullOrBlank()) {
|
||||
Text(
|
||||
text = board.description,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
Icons.Default.ViewColumn,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
text = "${board.columnsCount} columns",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
}
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
Icons.Default.CreditCard,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
text = "${board.cardsCount} cards",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
Text(
|
||||
text = (board.updatedAt ?: board.createdAt).atZone(java.time.ZoneId.systemDefault())
|
||||
.format(DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BoardDialog(
|
||||
title: String,
|
||||
initialName: String,
|
||||
initialDescription: String,
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: (name: String, description: String?) -> Unit,
|
||||
isLoading: Boolean
|
||||
) {
|
||||
var name by remember { mutableStateOf(initialName) }
|
||||
var description by remember { mutableStateOf(initialDescription) }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(title) },
|
||||
text = {
|
||||
Column {
|
||||
OutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
label = { Text("Name") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = !isLoading
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = description,
|
||||
onValueChange = { description = it },
|
||||
label = { Text("Description (optional)") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
minLines = 2,
|
||||
maxLines = 4,
|
||||
enabled = !isLoading
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = { onConfirm(name, description) },
|
||||
enabled = name.isNotBlank() && !isLoading
|
||||
) {
|
||||
Text(if (initialName.isEmpty()) "Create" else "Save")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss, enabled = !isLoading) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,219 @@
|
|||
package com.fizzy.android.feature.boards
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.fizzy.android.core.network.ApiResult
|
||||
import com.fizzy.android.domain.model.Board
|
||||
import com.fizzy.android.domain.repository.BoardRepository
|
||||
import com.fizzy.android.domain.repository.NotificationRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
data class BoardListUiState(
|
||||
val boards: List<Board> = emptyList(),
|
||||
val filteredBoards: List<Board> = emptyList(),
|
||||
val searchQuery: String = "",
|
||||
val isLoading: Boolean = false,
|
||||
val isRefreshing: Boolean = false,
|
||||
val error: String? = null,
|
||||
val showCreateDialog: Boolean = false,
|
||||
val showEditDialog: Board? = null,
|
||||
val showDeleteConfirmation: Board? = null
|
||||
)
|
||||
|
||||
sealed class BoardListEvent {
|
||||
data class NavigateToBoard(val boardId: String) : BoardListEvent()
|
||||
data class ShowError(val message: String) : BoardListEvent()
|
||||
data object BoardCreated : BoardListEvent()
|
||||
data object BoardUpdated : BoardListEvent()
|
||||
data object BoardDeleted : BoardListEvent()
|
||||
}
|
||||
|
||||
@HiltViewModel
|
||||
class BoardListViewModel @Inject constructor(
|
||||
private val boardRepository: BoardRepository,
|
||||
private val notificationRepository: NotificationRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(BoardListUiState())
|
||||
val uiState: StateFlow<BoardListUiState> = _uiState.asStateFlow()
|
||||
|
||||
private val _events = MutableSharedFlow<BoardListEvent>()
|
||||
val events: SharedFlow<BoardListEvent> = _events.asSharedFlow()
|
||||
|
||||
val unreadNotificationsCount: StateFlow<Int> = notificationRepository.unreadCount
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0)
|
||||
|
||||
init {
|
||||
loadBoards()
|
||||
observeBoards()
|
||||
}
|
||||
|
||||
private fun observeBoards() {
|
||||
viewModelScope.launch {
|
||||
boardRepository.observeBoards().collect { boards ->
|
||||
_uiState.update { state ->
|
||||
state.copy(
|
||||
boards = boards,
|
||||
filteredBoards = filterBoards(boards, state.searchQuery)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun loadBoards() {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isLoading = true, error = null) }
|
||||
|
||||
when (val result = boardRepository.getBoards()) {
|
||||
is ApiResult.Success -> {
|
||||
_uiState.update { it.copy(isLoading = false) }
|
||||
}
|
||||
is ApiResult.Error -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
error = "Failed to load boards: ${result.message}"
|
||||
)
|
||||
}
|
||||
}
|
||||
is ApiResult.Exception -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
error = "Network error. Please check your connection."
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isRefreshing = true) }
|
||||
|
||||
boardRepository.refreshBoards()
|
||||
notificationRepository.getNotifications()
|
||||
|
||||
_uiState.update { it.copy(isRefreshing = false) }
|
||||
}
|
||||
}
|
||||
|
||||
fun onSearchQueryChange(query: String) {
|
||||
_uiState.update { state ->
|
||||
state.copy(
|
||||
searchQuery = query,
|
||||
filteredBoards = filterBoards(state.boards, query)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun clearSearch() {
|
||||
_uiState.update { state ->
|
||||
state.copy(
|
||||
searchQuery = "",
|
||||
filteredBoards = state.boards
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun filterBoards(boards: List<Board>, query: String): List<Board> {
|
||||
if (query.isBlank()) return boards
|
||||
return boards.filter { board ->
|
||||
board.name.contains(query, ignoreCase = true) ||
|
||||
board.description?.contains(query, ignoreCase = true) == true
|
||||
}
|
||||
}
|
||||
|
||||
fun showCreateDialog() {
|
||||
_uiState.update { it.copy(showCreateDialog = true) }
|
||||
}
|
||||
|
||||
fun hideCreateDialog() {
|
||||
_uiState.update { it.copy(showCreateDialog = false) }
|
||||
}
|
||||
|
||||
fun createBoard(name: String, description: String?) {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isLoading = true) }
|
||||
|
||||
when (val result = boardRepository.createBoard(name, description?.takeIf { it.isNotBlank() })) {
|
||||
is ApiResult.Success -> {
|
||||
_uiState.update { it.copy(isLoading = false, showCreateDialog = false) }
|
||||
_events.emit(BoardListEvent.BoardCreated)
|
||||
_events.emit(BoardListEvent.NavigateToBoard(result.data.id))
|
||||
}
|
||||
is ApiResult.Error -> {
|
||||
_uiState.update { it.copy(isLoading = false) }
|
||||
_events.emit(BoardListEvent.ShowError("Failed to create board: ${result.message}"))
|
||||
}
|
||||
is ApiResult.Exception -> {
|
||||
_uiState.update { it.copy(isLoading = false) }
|
||||
_events.emit(BoardListEvent.ShowError("Network error"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun showEditDialog(board: Board) {
|
||||
_uiState.update { it.copy(showEditDialog = board) }
|
||||
}
|
||||
|
||||
fun hideEditDialog() {
|
||||
_uiState.update { it.copy(showEditDialog = null) }
|
||||
}
|
||||
|
||||
fun updateBoard(boardId: String, name: String, description: String?) {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isLoading = true) }
|
||||
|
||||
when (boardRepository.updateBoard(boardId, name, description?.takeIf { it.isNotBlank() })) {
|
||||
is ApiResult.Success -> {
|
||||
_uiState.update { it.copy(isLoading = false, showEditDialog = null) }
|
||||
_events.emit(BoardListEvent.BoardUpdated)
|
||||
}
|
||||
is ApiResult.Error -> {
|
||||
_uiState.update { it.copy(isLoading = false) }
|
||||
_events.emit(BoardListEvent.ShowError("Failed to update board"))
|
||||
}
|
||||
is ApiResult.Exception -> {
|
||||
_uiState.update { it.copy(isLoading = false) }
|
||||
_events.emit(BoardListEvent.ShowError("Network error"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun showDeleteConfirmation(board: Board) {
|
||||
_uiState.update { it.copy(showDeleteConfirmation = board) }
|
||||
}
|
||||
|
||||
fun hideDeleteConfirmation() {
|
||||
_uiState.update { it.copy(showDeleteConfirmation = null) }
|
||||
}
|
||||
|
||||
fun deleteBoard(boardId: String) {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isLoading = true) }
|
||||
|
||||
when (boardRepository.deleteBoard(boardId)) {
|
||||
is ApiResult.Success -> {
|
||||
_uiState.update { it.copy(isLoading = false, showDeleteConfirmation = null) }
|
||||
_events.emit(BoardListEvent.BoardDeleted)
|
||||
}
|
||||
is ApiResult.Error -> {
|
||||
_uiState.update { it.copy(isLoading = false) }
|
||||
_events.emit(BoardListEvent.ShowError("Failed to delete board"))
|
||||
}
|
||||
is ApiResult.Exception -> {
|
||||
_uiState.update { it.copy(isLoading = false) }
|
||||
_events.emit(BoardListEvent.ShowError("Network error"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
1058
app/src/main/java/com/fizzy/android/feature/card/CardDetailScreen.kt
Normal file
1058
app/src/main/java/com/fizzy/android/feature/card/CardDetailScreen.kt
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,495 @@
|
|||
package com.fizzy.android.feature.card
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.fizzy.android.core.network.ApiResult
|
||||
import com.fizzy.android.domain.model.*
|
||||
import com.fizzy.android.domain.repository.BoardRepository
|
||||
import com.fizzy.android.domain.repository.CardRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
import java.time.LocalDate
|
||||
import javax.inject.Inject
|
||||
|
||||
data class CardDetailUiState(
|
||||
val card: Card? = null,
|
||||
val steps: List<Step> = emptyList(),
|
||||
val comments: List<Comment> = emptyList(),
|
||||
val boardTags: List<Tag> = emptyList(),
|
||||
val boardUsers: List<User> = emptyList(),
|
||||
val isLoading: Boolean = false,
|
||||
val error: String? = null,
|
||||
val selectedTab: CardDetailTab = CardDetailTab.STEPS,
|
||||
val isEditing: Boolean = false,
|
||||
val editTitle: String = "",
|
||||
val editDescription: String = "",
|
||||
val showDatePicker: DatePickerAction? = null,
|
||||
val showTagPicker: Boolean = false,
|
||||
val showAssigneePicker: Boolean = false,
|
||||
val showAddStepDialog: Boolean = false,
|
||||
val showAddCommentDialog: Boolean = false,
|
||||
val editingStep: Step? = null,
|
||||
val showEmojiPicker: Long? = null // commentId
|
||||
)
|
||||
|
||||
enum class CardDetailTab {
|
||||
STEPS, COMMENTS, ACTIVITY
|
||||
}
|
||||
|
||||
enum class DatePickerAction {
|
||||
TRIAGE, DEFER
|
||||
}
|
||||
|
||||
sealed class CardDetailEvent {
|
||||
data class ShowError(val message: String) : CardDetailEvent()
|
||||
data object CardUpdated : CardDetailEvent()
|
||||
data object CardClosed : CardDetailEvent()
|
||||
data object CardReopened : CardDetailEvent()
|
||||
data object NavigateBack : CardDetailEvent()
|
||||
}
|
||||
|
||||
@HiltViewModel
|
||||
class CardDetailViewModel @Inject constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
private val cardRepository: CardRepository,
|
||||
private val boardRepository: BoardRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val cardId: Long = checkNotNull(savedStateHandle["cardId"])
|
||||
|
||||
private val _uiState = MutableStateFlow(CardDetailUiState())
|
||||
val uiState: StateFlow<CardDetailUiState> = _uiState.asStateFlow()
|
||||
|
||||
private val _events = MutableSharedFlow<CardDetailEvent>()
|
||||
val events: SharedFlow<CardDetailEvent> = _events.asSharedFlow()
|
||||
|
||||
init {
|
||||
loadCard()
|
||||
}
|
||||
|
||||
fun loadCard() {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isLoading = true, error = null) }
|
||||
|
||||
when (val result = cardRepository.getCard(cardId)) {
|
||||
is ApiResult.Success -> {
|
||||
val card = result.data
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
card = card,
|
||||
editTitle = card.title,
|
||||
editDescription = card.description ?: ""
|
||||
)
|
||||
}
|
||||
loadCardDetails(card.boardId)
|
||||
}
|
||||
is ApiResult.Error -> {
|
||||
_uiState.update {
|
||||
it.copy(isLoading = false, error = "Failed to load card")
|
||||
}
|
||||
}
|
||||
is ApiResult.Exception -> {
|
||||
_uiState.update {
|
||||
it.copy(isLoading = false, error = "Network error")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadCardDetails(boardId: String) {
|
||||
viewModelScope.launch {
|
||||
// Load steps
|
||||
when (val result = cardRepository.getSteps(cardId)) {
|
||||
is ApiResult.Success -> {
|
||||
_uiState.update { it.copy(steps = result.data) }
|
||||
}
|
||||
else -> { /* Ignore */ }
|
||||
}
|
||||
|
||||
// Load comments
|
||||
when (val result = cardRepository.getComments(cardId)) {
|
||||
is ApiResult.Success -> {
|
||||
_uiState.update { it.copy(comments = result.data) }
|
||||
}
|
||||
else -> { /* Ignore */ }
|
||||
}
|
||||
|
||||
// Load board tags
|
||||
when (val result = boardRepository.getTags(boardId)) {
|
||||
is ApiResult.Success -> {
|
||||
_uiState.update { it.copy(boardTags = result.data) }
|
||||
}
|
||||
else -> { /* Ignore */ }
|
||||
}
|
||||
|
||||
// Load board users
|
||||
when (val result = boardRepository.getBoardUsers(boardId)) {
|
||||
is ApiResult.Success -> {
|
||||
_uiState.update { it.copy(boardUsers = result.data) }
|
||||
}
|
||||
else -> { /* Ignore */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun selectTab(tab: CardDetailTab) {
|
||||
_uiState.update { it.copy(selectedTab = tab) }
|
||||
}
|
||||
|
||||
// Edit mode
|
||||
fun startEditing() {
|
||||
val card = _uiState.value.card ?: return
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isEditing = true,
|
||||
editTitle = card.title,
|
||||
editDescription = card.description ?: ""
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelEditing() {
|
||||
_uiState.update { it.copy(isEditing = false) }
|
||||
}
|
||||
|
||||
fun onTitleChange(title: String) {
|
||||
_uiState.update { it.copy(editTitle = title) }
|
||||
}
|
||||
|
||||
fun onDescriptionChange(description: String) {
|
||||
_uiState.update { it.copy(editDescription = description) }
|
||||
}
|
||||
|
||||
fun saveChanges() {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isLoading = true) }
|
||||
|
||||
val title = _uiState.value.editTitle.trim()
|
||||
val description = _uiState.value.editDescription.trim().takeIf { it.isNotEmpty() }
|
||||
|
||||
when (val result = cardRepository.updateCard(cardId, title, description)) {
|
||||
is ApiResult.Success -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
isEditing = false,
|
||||
card = result.data
|
||||
)
|
||||
}
|
||||
_events.emit(CardDetailEvent.CardUpdated)
|
||||
}
|
||||
is ApiResult.Error -> {
|
||||
_uiState.update { it.copy(isLoading = false) }
|
||||
_events.emit(CardDetailEvent.ShowError("Failed to update card"))
|
||||
}
|
||||
is ApiResult.Exception -> {
|
||||
_uiState.update { it.copy(isLoading = false) }
|
||||
_events.emit(CardDetailEvent.ShowError("Network error"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Card actions
|
||||
fun togglePriority() {
|
||||
viewModelScope.launch {
|
||||
val card = _uiState.value.card ?: return@launch
|
||||
when (val result = cardRepository.togglePriority(cardId, !card.priority)) {
|
||||
is ApiResult.Success -> {
|
||||
_uiState.update { it.copy(card = result.data) }
|
||||
}
|
||||
else -> _events.emit(CardDetailEvent.ShowError("Failed to update priority"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleWatch() {
|
||||
viewModelScope.launch {
|
||||
val card = _uiState.value.card ?: return@launch
|
||||
when (val result = cardRepository.toggleWatch(cardId, !card.watching)) {
|
||||
is ApiResult.Success -> {
|
||||
_uiState.update { it.copy(card = result.data) }
|
||||
}
|
||||
else -> _events.emit(CardDetailEvent.ShowError("Failed to update watch status"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun closeCard() {
|
||||
viewModelScope.launch {
|
||||
when (val result = cardRepository.closeCard(cardId)) {
|
||||
is ApiResult.Success -> {
|
||||
_uiState.update { it.copy(card = result.data) }
|
||||
_events.emit(CardDetailEvent.CardClosed)
|
||||
}
|
||||
else -> _events.emit(CardDetailEvent.ShowError("Failed to close card"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun reopenCard() {
|
||||
viewModelScope.launch {
|
||||
when (val result = cardRepository.reopenCard(cardId)) {
|
||||
is ApiResult.Success -> {
|
||||
_uiState.update { it.copy(card = result.data) }
|
||||
_events.emit(CardDetailEvent.CardReopened)
|
||||
}
|
||||
else -> _events.emit(CardDetailEvent.ShowError("Failed to reopen card"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun showTriageDatePicker() {
|
||||
_uiState.update { it.copy(showDatePicker = DatePickerAction.TRIAGE) }
|
||||
}
|
||||
|
||||
fun showDeferDatePicker() {
|
||||
_uiState.update { it.copy(showDatePicker = DatePickerAction.DEFER) }
|
||||
}
|
||||
|
||||
fun hideDatePicker() {
|
||||
_uiState.update { it.copy(showDatePicker = null) }
|
||||
}
|
||||
|
||||
fun triageCard(date: LocalDate) {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(showDatePicker = null) }
|
||||
when (val result = cardRepository.triageCard(cardId, date)) {
|
||||
is ApiResult.Success -> {
|
||||
_uiState.update { it.copy(card = result.data) }
|
||||
}
|
||||
else -> _events.emit(CardDetailEvent.ShowError("Failed to triage card"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun deferCard(date: LocalDate) {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(showDatePicker = null) }
|
||||
when (val result = cardRepository.deferCard(cardId, date)) {
|
||||
is ApiResult.Success -> {
|
||||
_uiState.update { it.copy(card = result.data) }
|
||||
}
|
||||
else -> _events.emit(CardDetailEvent.ShowError("Failed to defer card"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteCard() {
|
||||
viewModelScope.launch {
|
||||
when (cardRepository.deleteCard(cardId)) {
|
||||
is ApiResult.Success -> {
|
||||
_events.emit(CardDetailEvent.NavigateBack)
|
||||
}
|
||||
else -> _events.emit(CardDetailEvent.ShowError("Failed to delete card"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tags
|
||||
fun showTagPicker() {
|
||||
_uiState.update { it.copy(showTagPicker = true) }
|
||||
}
|
||||
|
||||
fun hideTagPicker() {
|
||||
_uiState.update { it.copy(showTagPicker = false) }
|
||||
}
|
||||
|
||||
fun addTag(tagId: Long) {
|
||||
viewModelScope.launch {
|
||||
when (val result = cardRepository.addTag(cardId, tagId)) {
|
||||
is ApiResult.Success -> {
|
||||
_uiState.update { it.copy(card = result.data) }
|
||||
}
|
||||
else -> _events.emit(CardDetailEvent.ShowError("Failed to add tag"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun removeTag(tagId: Long) {
|
||||
viewModelScope.launch {
|
||||
when (val result = cardRepository.removeTag(cardId, tagId)) {
|
||||
is ApiResult.Success -> {
|
||||
_uiState.update { it.copy(card = result.data) }
|
||||
}
|
||||
else -> _events.emit(CardDetailEvent.ShowError("Failed to remove tag"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Assignees
|
||||
fun showAssigneePicker() {
|
||||
_uiState.update { it.copy(showAssigneePicker = true) }
|
||||
}
|
||||
|
||||
fun hideAssigneePicker() {
|
||||
_uiState.update { it.copy(showAssigneePicker = false) }
|
||||
}
|
||||
|
||||
fun addAssignee(userId: Long) {
|
||||
viewModelScope.launch {
|
||||
when (val result = cardRepository.addAssignee(cardId, userId)) {
|
||||
is ApiResult.Success -> {
|
||||
_uiState.update { it.copy(card = result.data) }
|
||||
}
|
||||
else -> _events.emit(CardDetailEvent.ShowError("Failed to add assignee"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun removeAssignee(userId: Long) {
|
||||
viewModelScope.launch {
|
||||
when (val result = cardRepository.removeAssignee(cardId, userId)) {
|
||||
is ApiResult.Success -> {
|
||||
_uiState.update { it.copy(card = result.data) }
|
||||
}
|
||||
else -> _events.emit(CardDetailEvent.ShowError("Failed to remove assignee"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Steps
|
||||
fun showAddStepDialog() {
|
||||
_uiState.update { it.copy(showAddStepDialog = true) }
|
||||
}
|
||||
|
||||
fun hideAddStepDialog() {
|
||||
_uiState.update { it.copy(showAddStepDialog = false) }
|
||||
}
|
||||
|
||||
fun createStep(description: String) {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(showAddStepDialog = false) }
|
||||
when (val result = cardRepository.createStep(cardId, description)) {
|
||||
is ApiResult.Success -> {
|
||||
_uiState.update { state ->
|
||||
state.copy(steps = state.steps + result.data)
|
||||
}
|
||||
refreshCard()
|
||||
}
|
||||
else -> _events.emit(CardDetailEvent.ShowError("Failed to create step"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleStepCompleted(step: Step) {
|
||||
viewModelScope.launch {
|
||||
when (val result = cardRepository.updateStep(cardId, step.id, null, !step.completed, null)) {
|
||||
is ApiResult.Success -> {
|
||||
_uiState.update { state ->
|
||||
state.copy(steps = state.steps.map {
|
||||
if (it.id == step.id) result.data else it
|
||||
})
|
||||
}
|
||||
refreshCard()
|
||||
}
|
||||
else -> _events.emit(CardDetailEvent.ShowError("Failed to update step"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteStep(stepId: Long) {
|
||||
viewModelScope.launch {
|
||||
when (cardRepository.deleteStep(cardId, stepId)) {
|
||||
is ApiResult.Success -> {
|
||||
_uiState.update { state ->
|
||||
state.copy(steps = state.steps.filter { it.id != stepId })
|
||||
}
|
||||
refreshCard()
|
||||
}
|
||||
else -> _events.emit(CardDetailEvent.ShowError("Failed to delete step"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Comments
|
||||
fun showAddCommentDialog() {
|
||||
_uiState.update { it.copy(showAddCommentDialog = true) }
|
||||
}
|
||||
|
||||
fun hideAddCommentDialog() {
|
||||
_uiState.update { it.copy(showAddCommentDialog = false) }
|
||||
}
|
||||
|
||||
fun createComment(content: String) {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(showAddCommentDialog = false) }
|
||||
when (val result = cardRepository.createComment(cardId, content)) {
|
||||
is ApiResult.Success -> {
|
||||
_uiState.update { state ->
|
||||
state.copy(comments = listOf(result.data) + state.comments)
|
||||
}
|
||||
}
|
||||
else -> _events.emit(CardDetailEvent.ShowError("Failed to create comment"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteComment(commentId: Long) {
|
||||
viewModelScope.launch {
|
||||
when (cardRepository.deleteComment(cardId, commentId)) {
|
||||
is ApiResult.Success -> {
|
||||
_uiState.update { state ->
|
||||
state.copy(comments = state.comments.filter { it.id != commentId })
|
||||
}
|
||||
}
|
||||
else -> _events.emit(CardDetailEvent.ShowError("Failed to delete comment"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reactions
|
||||
fun showEmojiPicker(commentId: Long) {
|
||||
_uiState.update { it.copy(showEmojiPicker = commentId) }
|
||||
}
|
||||
|
||||
fun hideEmojiPicker() {
|
||||
_uiState.update { it.copy(showEmojiPicker = null) }
|
||||
}
|
||||
|
||||
fun addReaction(commentId: Long, emoji: String) {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(showEmojiPicker = null) }
|
||||
when (val result = cardRepository.addReaction(cardId, commentId, emoji)) {
|
||||
is ApiResult.Success -> {
|
||||
_uiState.update { state ->
|
||||
state.copy(comments = state.comments.map {
|
||||
if (it.id == commentId) result.data else it
|
||||
})
|
||||
}
|
||||
}
|
||||
else -> _events.emit(CardDetailEvent.ShowError("Failed to add reaction"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun removeReaction(commentId: Long, emoji: String) {
|
||||
viewModelScope.launch {
|
||||
when (val result = cardRepository.removeReaction(cardId, commentId, emoji)) {
|
||||
is ApiResult.Success -> {
|
||||
_uiState.update { state ->
|
||||
state.copy(comments = state.comments.map {
|
||||
if (it.id == commentId) result.data else it
|
||||
})
|
||||
}
|
||||
}
|
||||
else -> _events.emit(CardDetailEvent.ShowError("Failed to remove reaction"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshCard() {
|
||||
viewModelScope.launch {
|
||||
when (val result = cardRepository.getCard(cardId)) {
|
||||
is ApiResult.Success -> {
|
||||
_uiState.update { it.copy(card = result.data) }
|
||||
}
|
||||
else -> { /* Ignore */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,629 @@
|
|||
package com.fizzy.android.feature.kanban
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.fizzy.android.core.ui.components.ErrorMessage
|
||||
import com.fizzy.android.core.ui.components.LoadingIndicator
|
||||
import com.fizzy.android.core.ui.theme.FizzyGold
|
||||
import com.fizzy.android.domain.model.Card
|
||||
import com.fizzy.android.domain.model.CardStatus
|
||||
import com.fizzy.android.domain.model.Column
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun KanbanScreen(
|
||||
boardId: String,
|
||||
onBackClick: () -> Unit,
|
||||
onCardClick: (Long) -> Unit,
|
||||
viewModel: KanbanViewModel = hiltViewModel()
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.events.collectLatest { event ->
|
||||
when (event) {
|
||||
is KanbanEvent.ShowError -> snackbarHostState.showSnackbar(event.message)
|
||||
is KanbanEvent.NavigateToCard -> onCardClick(event.cardId)
|
||||
KanbanEvent.ColumnCreated -> snackbarHostState.showSnackbar("Column created")
|
||||
KanbanEvent.ColumnUpdated -> snackbarHostState.showSnackbar("Column updated")
|
||||
KanbanEvent.ColumnDeleted -> snackbarHostState.showSnackbar("Column deleted")
|
||||
KanbanEvent.CardCreated -> snackbarHostState.showSnackbar("Card created")
|
||||
KanbanEvent.CardMoved -> { /* Silent */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(uiState.board?.name ?: "Board") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBackClick) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = viewModel::refresh) {
|
||||
Icon(Icons.Default.Refresh, contentDescription = "Refresh")
|
||||
}
|
||||
IconButton(onClick = viewModel::showAddColumnDialog) {
|
||||
Icon(Icons.Default.AddCircleOutline, contentDescription = "Add column")
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) }
|
||||
) { paddingValues ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
) {
|
||||
when {
|
||||
uiState.isLoading && uiState.columns.isEmpty() -> {
|
||||
LoadingIndicator()
|
||||
}
|
||||
uiState.error != null && uiState.columns.isEmpty() -> {
|
||||
ErrorMessage(
|
||||
message = uiState.error ?: "Unknown error",
|
||||
onRetry = viewModel::loadBoard
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
KanbanBoard(
|
||||
columns = uiState.columns,
|
||||
dragState = uiState.dragState,
|
||||
onCardClick = viewModel::onCardClick,
|
||||
onCardLongPress = viewModel::startDragging,
|
||||
onDragEnd = viewModel::endDragging,
|
||||
onDragCancel = viewModel::cancelDragging,
|
||||
onDragTargetUpdate = viewModel::updateDragTarget,
|
||||
onAddCard = viewModel::showAddCardDialog,
|
||||
onEditColumn = viewModel::showEditColumnDialog,
|
||||
onDeleteColumn = viewModel::deleteColumn
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Show loading indicator when refreshing
|
||||
if (uiState.isRefreshing) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.padding(16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add Column Dialog
|
||||
if (uiState.showAddColumnDialog) {
|
||||
ColumnDialog(
|
||||
title = "Add Column",
|
||||
initialName = "",
|
||||
onDismiss = viewModel::hideAddColumnDialog,
|
||||
onConfirm = viewModel::createColumn
|
||||
)
|
||||
}
|
||||
|
||||
// Edit Column Dialog
|
||||
uiState.editingColumn?.let { column ->
|
||||
ColumnDialog(
|
||||
title = "Edit Column",
|
||||
initialName = column.name,
|
||||
onDismiss = viewModel::hideEditColumnDialog,
|
||||
onConfirm = { name -> viewModel.updateColumn(column.id, name) }
|
||||
)
|
||||
}
|
||||
|
||||
// Add Card Dialog
|
||||
uiState.showAddCardDialog?.let { columnId ->
|
||||
CardQuickAddDialog(
|
||||
onDismiss = viewModel::hideAddCardDialog,
|
||||
onConfirm = { title -> viewModel.createCard(columnId, title) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun KanbanBoard(
|
||||
columns: List<Column>,
|
||||
dragState: DragState,
|
||||
onCardClick: (Long) -> Unit,
|
||||
onCardLongPress: (Card) -> Unit,
|
||||
onDragEnd: () -> Unit,
|
||||
onDragCancel: () -> Unit,
|
||||
onDragTargetUpdate: (String, Int) -> Unit,
|
||||
onAddCard: (String) -> Unit,
|
||||
onEditColumn: (Column) -> Unit,
|
||||
onDeleteColumn: (String) -> Unit
|
||||
) {
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.horizontalScroll(scrollState)
|
||||
.padding(8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
columns.forEach { column ->
|
||||
KanbanColumn(
|
||||
column = column,
|
||||
isDragTarget = dragState.targetColumnId == column.id,
|
||||
onCardClick = onCardClick,
|
||||
onCardLongPress = onCardLongPress,
|
||||
onDragEnd = onDragEnd,
|
||||
onDragCancel = onDragCancel,
|
||||
onDragTargetUpdate = { position -> onDragTargetUpdate(column.id, position) },
|
||||
onAddCard = { onAddCard(column.id) },
|
||||
onEditColumn = { onEditColumn(column) },
|
||||
onDeleteColumn = { onDeleteColumn(column.id) },
|
||||
draggingCard = if (dragState.sourceColumnId == column.id) dragState.draggingCard else null
|
||||
)
|
||||
}
|
||||
|
||||
// Add column button at the end
|
||||
AddColumnButton(onClick = { /* Handled by FAB */ })
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun KanbanColumn(
|
||||
column: Column,
|
||||
isDragTarget: Boolean,
|
||||
onCardClick: (Long) -> Unit,
|
||||
onCardLongPress: (Card) -> Unit,
|
||||
onDragEnd: () -> Unit,
|
||||
onDragCancel: () -> Unit,
|
||||
onDragTargetUpdate: (Int) -> Unit,
|
||||
onAddCard: () -> Unit,
|
||||
onEditColumn: () -> Unit,
|
||||
onDeleteColumn: () -> Unit,
|
||||
draggingCard: Card?
|
||||
) {
|
||||
var showMenu by remember { mutableStateOf(false) }
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.width(300.dp)
|
||||
.fillMaxHeight(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = if (isDragTarget)
|
||||
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)
|
||||
else
|
||||
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
|
||||
)
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
// Column Header
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(12.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = column.name,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Surface(
|
||||
shape = CircleShape,
|
||||
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.2f)
|
||||
) {
|
||||
Text(
|
||||
text = column.cards.size.toString(),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Box {
|
||||
IconButton(onClick = { showMenu = true }) {
|
||||
Icon(
|
||||
Icons.Default.MoreVert,
|
||||
contentDescription = "Column options",
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
|
||||
DropdownMenu(
|
||||
expanded = showMenu,
|
||||
onDismissRequest = { showMenu = false }
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Edit") },
|
||||
leadingIcon = { Icon(Icons.Default.Edit, contentDescription = null) },
|
||||
onClick = {
|
||||
showMenu = false
|
||||
onEditColumn()
|
||||
}
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("Delete") },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
Icons.Default.Delete,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
showMenu = false
|
||||
onDeleteColumn()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cards List
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(horizontal = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
itemsIndexed(column.cards, key = { _, card -> card.id }) { index, card ->
|
||||
KanbanCard(
|
||||
card = card,
|
||||
isDragging = draggingCard?.id == card.id,
|
||||
onClick = { onCardClick(card.id) },
|
||||
onLongPress = { onCardLongPress(card) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Add Card Button
|
||||
TextButton(
|
||||
onClick = onAddCard,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(8.dp)
|
||||
) {
|
||||
Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text("Add Card")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun KanbanCard(
|
||||
card: Card,
|
||||
isDragging: Boolean,
|
||||
onClick: () -> Unit,
|
||||
onLongPress: () -> Unit
|
||||
) {
|
||||
var offsetX by remember { mutableFloatStateOf(0f) }
|
||||
var offsetY by remember { mutableFloatStateOf(0f) }
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.graphicsLayer {
|
||||
if (isDragging) {
|
||||
alpha = 0.5f
|
||||
}
|
||||
}
|
||||
.pointerInput(Unit) {
|
||||
detectDragGesturesAfterLongPress(
|
||||
onDragStart = { onLongPress() },
|
||||
onDragEnd = { },
|
||||
onDragCancel = { },
|
||||
onDrag = { change, dragAmount ->
|
||||
change.consume()
|
||||
offsetX += dragAmount.x
|
||||
offsetY += dragAmount.y
|
||||
}
|
||||
)
|
||||
}
|
||||
.clickable(onClick = onClick),
|
||||
elevation = CardDefaults.cardElevation(
|
||||
defaultElevation = if (isDragging) 8.dp else 2.dp
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(12.dp)
|
||||
) {
|
||||
// Priority indicator
|
||||
if (card.priority) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(bottom = 4.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Star,
|
||||
contentDescription = "Priority",
|
||||
modifier = Modifier.size(14.dp),
|
||||
tint = FizzyGold
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
text = "Priority",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = FizzyGold
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Title
|
||||
Text(
|
||||
text = card.title,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
|
||||
// Tags
|
||||
if (card.tags.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = Modifier.horizontalScroll(rememberScrollState())
|
||||
) {
|
||||
card.tags.take(3).forEach { tag ->
|
||||
Surface(
|
||||
shape = RoundedCornerShape(4.dp),
|
||||
color = tag.backgroundColor
|
||||
) {
|
||||
Text(
|
||||
text = tag.name,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = tag.textColor,
|
||||
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
if (card.tags.size > 3) {
|
||||
Text(
|
||||
text = "+${card.tags.size - 3}",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Bottom indicators
|
||||
if (card.hasSteps || card.commentsCount > 0 || card.assignees.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Steps progress
|
||||
if (card.hasSteps) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
Icons.Default.CheckBox,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(14.dp),
|
||||
tint = if (card.stepsCompleted == card.stepsTotal)
|
||||
MaterialTheme.colorScheme.primary
|
||||
else
|
||||
MaterialTheme.colorScheme.outline
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
text = card.stepsDisplay,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Comments count
|
||||
if (card.commentsCount > 0) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
Icons.Default.ChatBubbleOutline,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(14.dp),
|
||||
tint = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
text = card.commentsCount.toString(),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
// Assignees avatars
|
||||
if (card.assignees.isNotEmpty()) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy((-8).dp)
|
||||
) {
|
||||
card.assignees.take(3).forEach { user ->
|
||||
Surface(
|
||||
modifier = Modifier.size(24.dp),
|
||||
shape = CircleShape,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
text = user.name.first().uppercase(),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onPrimary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Status badge
|
||||
if (card.status != CardStatus.ACTIVE) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
StatusBadge(status = card.status)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatusBadge(status: CardStatus) {
|
||||
val (text, color) = when (status) {
|
||||
CardStatus.CLOSED -> "Closed" to MaterialTheme.colorScheme.error
|
||||
CardStatus.TRIAGED -> "Triaged" to Color(0xFFF97316)
|
||||
CardStatus.DEFERRED -> "Deferred" to Color(0xFF8B5CF6)
|
||||
CardStatus.ACTIVE -> return
|
||||
}
|
||||
|
||||
Surface(
|
||||
shape = RoundedCornerShape(4.dp),
|
||||
color = color.copy(alpha = 0.15f)
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = color,
|
||||
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AddColumnButton(onClick: () -> Unit) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.width(280.dp)
|
||||
.height(100.dp)
|
||||
.clickable(onClick = onClick),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)
|
||||
)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
Icons.Default.Add,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = "Add Column",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ColumnDialog(
|
||||
title: String,
|
||||
initialName: String,
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: (String) -> Unit
|
||||
) {
|
||||
var name by remember { mutableStateOf(initialName) }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(title) },
|
||||
text = {
|
||||
OutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
label = { Text("Column name") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = { onConfirm(name) },
|
||||
enabled = name.isNotBlank()
|
||||
) {
|
||||
Text(if (initialName.isEmpty()) "Create" else "Save")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CardQuickAddDialog(
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: (String) -> Unit
|
||||
) {
|
||||
var title by remember { mutableStateOf("") }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Add Card") },
|
||||
text = {
|
||||
OutlinedTextField(
|
||||
value = title,
|
||||
onValueChange = { title = it },
|
||||
label = { Text("Card title") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = { onConfirm(title) },
|
||||
enabled = title.isNotBlank()
|
||||
) {
|
||||
Text("Add")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,413 @@
|
|||
package com.fizzy.android.feature.kanban
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.fizzy.android.core.network.ApiResult
|
||||
import com.fizzy.android.domain.model.Board
|
||||
import com.fizzy.android.domain.model.Card
|
||||
import com.fizzy.android.domain.model.Column
|
||||
import com.fizzy.android.domain.repository.BoardRepository
|
||||
import com.fizzy.android.domain.repository.CardRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val TAG = "KanbanViewModel"
|
||||
|
||||
data class KanbanUiState(
|
||||
val board: Board? = null,
|
||||
val columns: List<Column> = emptyList(),
|
||||
val isLoading: Boolean = false,
|
||||
val isRefreshing: Boolean = false,
|
||||
val error: String? = null,
|
||||
val showAddColumnDialog: Boolean = false,
|
||||
val editingColumn: Column? = null,
|
||||
val showAddCardDialog: String? = null, // columnId
|
||||
val dragState: DragState = DragState()
|
||||
)
|
||||
|
||||
data class DragState(
|
||||
val isDragging: Boolean = false,
|
||||
val draggingCard: Card? = null,
|
||||
val sourceColumnId: String? = null,
|
||||
val targetColumnId: String? = null,
|
||||
val targetPosition: Int? = null
|
||||
)
|
||||
|
||||
sealed class KanbanEvent {
|
||||
data class ShowError(val message: String) : KanbanEvent()
|
||||
data class NavigateToCard(val cardId: Long) : KanbanEvent()
|
||||
data object ColumnCreated : KanbanEvent()
|
||||
data object ColumnUpdated : KanbanEvent()
|
||||
data object ColumnDeleted : KanbanEvent()
|
||||
data object CardCreated : KanbanEvent()
|
||||
data object CardMoved : KanbanEvent()
|
||||
}
|
||||
|
||||
@HiltViewModel
|
||||
class KanbanViewModel @Inject constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
private val boardRepository: BoardRepository,
|
||||
private val cardRepository: CardRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val boardId: String = checkNotNull(savedStateHandle["boardId"])
|
||||
|
||||
private val _uiState = MutableStateFlow(KanbanUiState())
|
||||
val uiState: StateFlow<KanbanUiState> = _uiState.asStateFlow()
|
||||
|
||||
private val _events = MutableSharedFlow<KanbanEvent>()
|
||||
val events: SharedFlow<KanbanEvent> = _events.asSharedFlow()
|
||||
|
||||
private var pollingJob: Job? = null
|
||||
|
||||
init {
|
||||
loadBoard()
|
||||
startPolling()
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
pollingJob?.cancel()
|
||||
}
|
||||
|
||||
private fun startPolling() {
|
||||
pollingJob = viewModelScope.launch {
|
||||
while (true) {
|
||||
delay(10_000) // Poll every 10 seconds
|
||||
if (!_uiState.value.isDragging()) {
|
||||
silentRefresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun silentRefresh() {
|
||||
val columnsResult = boardRepository.getColumns(boardId)
|
||||
val cardsResult = cardRepository.getBoardCards(boardId)
|
||||
|
||||
if (columnsResult is ApiResult.Success) {
|
||||
val cards = if (cardsResult is ApiResult.Success) cardsResult.data else emptyList()
|
||||
val columnsWithCards = distributeCardsToColumns(columnsResult.data, cards)
|
||||
_uiState.update { state ->
|
||||
state.copy(columns = columnsWithCards)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun KanbanUiState.isDragging() = dragState.isDragging
|
||||
|
||||
fun loadBoard() {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isLoading = true, error = null) }
|
||||
|
||||
val boardResult = boardRepository.getBoard(boardId)
|
||||
val columnsResult = boardRepository.getColumns(boardId)
|
||||
val cardsResult = cardRepository.getBoardCards(boardId)
|
||||
Log.d(TAG, "loadBoard cardsResult: $cardsResult")
|
||||
|
||||
when {
|
||||
boardResult is ApiResult.Success && columnsResult is ApiResult.Success -> {
|
||||
// Get cards (may fail, that's ok - show empty)
|
||||
val cards = if (cardsResult is ApiResult.Success) cardsResult.data else emptyList()
|
||||
val columnsWithCards = distributeCardsToColumns(columnsResult.data, cards)
|
||||
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
board = boardResult.data,
|
||||
columns = columnsWithCards
|
||||
)
|
||||
}
|
||||
}
|
||||
boardResult is ApiResult.Error -> {
|
||||
_uiState.update {
|
||||
it.copy(isLoading = false, error = "Failed to load board: ${boardResult.message}")
|
||||
}
|
||||
}
|
||||
columnsResult is ApiResult.Error -> {
|
||||
_uiState.update {
|
||||
it.copy(isLoading = false, error = "Failed to load columns: ${(columnsResult as ApiResult.Error).message}")
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
_uiState.update {
|
||||
it.copy(isLoading = false, error = "Network error")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun distributeCardsToColumns(columns: List<Column>, cards: List<Card>): List<Column> {
|
||||
Log.d(TAG, "distributeCardsToColumns: ${cards.size} cards, ${columns.size} columns")
|
||||
Log.d(TAG, "Column IDs: ${columns.map { "${it.name}=${it.id}" }}")
|
||||
Log.d(TAG, "Card columnIds: ${cards.map { "${it.title}→${it.columnId}" }}")
|
||||
|
||||
val cardsByColumn = cards.groupBy { it.columnId }
|
||||
Log.d(TAG, "Cards grouped by column: ${cardsByColumn.mapValues { it.value.map { c -> c.title } }}")
|
||||
|
||||
return columns.map { column ->
|
||||
val columnCards = cardsByColumn[column.id]?.sortedBy { it.position } ?: emptyList()
|
||||
Log.d(TAG, "Column '${column.name}' (${column.id}): ${columnCards.size} cards")
|
||||
column.copy(cards = columnCards)
|
||||
}
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isRefreshing = true) }
|
||||
loadBoardData()
|
||||
_uiState.update { it.copy(isRefreshing = false) }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadBoardData() {
|
||||
val columnsResult = boardRepository.getColumns(boardId)
|
||||
val cardsResult = cardRepository.getBoardCards(boardId)
|
||||
|
||||
if (columnsResult is ApiResult.Success) {
|
||||
val cards = if (cardsResult is ApiResult.Success) cardsResult.data else emptyList()
|
||||
val columnsWithCards = distributeCardsToColumns(columnsResult.data, cards)
|
||||
_uiState.update { it.copy(columns = columnsWithCards) }
|
||||
}
|
||||
}
|
||||
|
||||
// Column operations
|
||||
fun showAddColumnDialog() {
|
||||
_uiState.update { it.copy(showAddColumnDialog = true) }
|
||||
}
|
||||
|
||||
fun hideAddColumnDialog() {
|
||||
_uiState.update { it.copy(showAddColumnDialog = false) }
|
||||
}
|
||||
|
||||
fun createColumn(name: String) {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isLoading = true) }
|
||||
|
||||
val position = _uiState.value.columns.maxOfOrNull { it.position }?.plus(1) ?: 0
|
||||
|
||||
when (val result = boardRepository.createColumn(boardId, name, position)) {
|
||||
is ApiResult.Success -> {
|
||||
_uiState.update { state ->
|
||||
state.copy(
|
||||
isLoading = false,
|
||||
showAddColumnDialog = false,
|
||||
columns = state.columns + result.data
|
||||
)
|
||||
}
|
||||
_events.emit(KanbanEvent.ColumnCreated)
|
||||
}
|
||||
is ApiResult.Error -> {
|
||||
_uiState.update { it.copy(isLoading = false) }
|
||||
_events.emit(KanbanEvent.ShowError("Failed to create column"))
|
||||
}
|
||||
is ApiResult.Exception -> {
|
||||
_uiState.update { it.copy(isLoading = false) }
|
||||
_events.emit(KanbanEvent.ShowError("Network error"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun showEditColumnDialog(column: Column) {
|
||||
_uiState.update { it.copy(editingColumn = column) }
|
||||
}
|
||||
|
||||
fun hideEditColumnDialog() {
|
||||
_uiState.update { it.copy(editingColumn = null) }
|
||||
}
|
||||
|
||||
fun updateColumn(columnId: String, name: String) {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isLoading = true) }
|
||||
|
||||
when (val result = boardRepository.updateColumn(boardId, columnId, name, null)) {
|
||||
is ApiResult.Success -> {
|
||||
_uiState.update { state ->
|
||||
state.copy(
|
||||
isLoading = false,
|
||||
editingColumn = null,
|
||||
columns = state.columns.map {
|
||||
if (it.id == columnId) result.data else it
|
||||
}
|
||||
)
|
||||
}
|
||||
_events.emit(KanbanEvent.ColumnUpdated)
|
||||
}
|
||||
is ApiResult.Error -> {
|
||||
_uiState.update { it.copy(isLoading = false) }
|
||||
_events.emit(KanbanEvent.ShowError("Failed to update column"))
|
||||
}
|
||||
is ApiResult.Exception -> {
|
||||
_uiState.update { it.copy(isLoading = false) }
|
||||
_events.emit(KanbanEvent.ShowError("Network error"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteColumn(columnId: String) {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isLoading = true) }
|
||||
|
||||
when (boardRepository.deleteColumn(boardId, columnId)) {
|
||||
is ApiResult.Success -> {
|
||||
_uiState.update { state ->
|
||||
state.copy(
|
||||
isLoading = false,
|
||||
columns = state.columns.filter { it.id != columnId }
|
||||
)
|
||||
}
|
||||
_events.emit(KanbanEvent.ColumnDeleted)
|
||||
}
|
||||
is ApiResult.Error -> {
|
||||
_uiState.update { it.copy(isLoading = false) }
|
||||
_events.emit(KanbanEvent.ShowError("Failed to delete column"))
|
||||
}
|
||||
is ApiResult.Exception -> {
|
||||
_uiState.update { it.copy(isLoading = false) }
|
||||
_events.emit(KanbanEvent.ShowError("Network error"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Card operations
|
||||
fun showAddCardDialog(columnId: String) {
|
||||
_uiState.update { it.copy(showAddCardDialog = columnId) }
|
||||
}
|
||||
|
||||
fun hideAddCardDialog() {
|
||||
_uiState.update { it.copy(showAddCardDialog = null) }
|
||||
}
|
||||
|
||||
fun createCard(columnId: String, title: String) {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isLoading = true) }
|
||||
|
||||
when (val result = cardRepository.createCard(boardId, columnId, title, null)) {
|
||||
is ApiResult.Success -> {
|
||||
_uiState.update { state ->
|
||||
state.copy(
|
||||
isLoading = false,
|
||||
showAddCardDialog = null,
|
||||
columns = state.columns.map { column ->
|
||||
if (column.id == columnId) {
|
||||
column.copy(cards = column.cards + result.data)
|
||||
} else column
|
||||
}
|
||||
)
|
||||
}
|
||||
_events.emit(KanbanEvent.CardCreated)
|
||||
}
|
||||
is ApiResult.Error -> {
|
||||
_uiState.update { it.copy(isLoading = false) }
|
||||
_events.emit(KanbanEvent.ShowError("Failed to create card"))
|
||||
}
|
||||
is ApiResult.Exception -> {
|
||||
_uiState.update { it.copy(isLoading = false) }
|
||||
_events.emit(KanbanEvent.ShowError("Network error"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Drag and drop
|
||||
fun startDragging(card: Card) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
dragState = DragState(
|
||||
isDragging = true,
|
||||
draggingCard = card,
|
||||
sourceColumnId = card.columnId
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateDragTarget(columnId: String, position: Int) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
dragState = it.dragState.copy(
|
||||
targetColumnId = columnId,
|
||||
targetPosition = position
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun endDragging() {
|
||||
val dragState = _uiState.value.dragState
|
||||
val card = dragState.draggingCard
|
||||
val targetColumnId = dragState.targetColumnId
|
||||
val targetPosition = dragState.targetPosition
|
||||
|
||||
if (card != null && targetColumnId != null && targetPosition != null) {
|
||||
// Check if actually moved
|
||||
if (card.columnId != targetColumnId || card.position != targetPosition) {
|
||||
moveCard(card.id, targetColumnId, targetPosition)
|
||||
}
|
||||
}
|
||||
|
||||
_uiState.update { it.copy(dragState = DragState()) }
|
||||
}
|
||||
|
||||
fun cancelDragging() {
|
||||
_uiState.update { it.copy(dragState = DragState()) }
|
||||
}
|
||||
|
||||
private fun moveCard(cardId: Long, columnId: String, position: Int) {
|
||||
viewModelScope.launch {
|
||||
// Optimistic update
|
||||
_uiState.update { state ->
|
||||
val card = state.columns.flatMap { it.cards }.find { it.id == cardId } ?: return@update state
|
||||
|
||||
val updatedColumns = state.columns.map { column ->
|
||||
when {
|
||||
column.id == card.columnId && column.id != columnId -> {
|
||||
// Remove from source column
|
||||
column.copy(cards = column.cards.filter { it.id != cardId })
|
||||
}
|
||||
column.id == columnId -> {
|
||||
// Add to target column
|
||||
val cardsWithoutCard = column.cards.filter { it.id != cardId }
|
||||
val updatedCard = card.copy(columnId = columnId, position = position)
|
||||
val newCards = cardsWithoutCard.toMutableList().apply {
|
||||
add(position.coerceIn(0, size), updatedCard)
|
||||
}
|
||||
column.copy(cards = newCards)
|
||||
}
|
||||
else -> column
|
||||
}
|
||||
}
|
||||
|
||||
state.copy(columns = updatedColumns)
|
||||
}
|
||||
|
||||
// API call
|
||||
when (cardRepository.moveCard(cardId, columnId, position)) {
|
||||
is ApiResult.Success -> {
|
||||
_events.emit(KanbanEvent.CardMoved)
|
||||
}
|
||||
is ApiResult.Error, is ApiResult.Exception -> {
|
||||
// Rollback - reload data
|
||||
loadBoardData()
|
||||
_events.emit(KanbanEvent.ShowError("Failed to move card"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onCardClick(cardId: Long) {
|
||||
viewModelScope.launch {
|
||||
_events.emit(KanbanEvent.NavigateToCard(cardId))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,324 @@
|
|||
package com.fizzy.android.feature.notifications
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.fizzy.android.core.ui.components.EmptyState
|
||||
import com.fizzy.android.core.ui.components.ErrorMessage
|
||||
import com.fizzy.android.core.ui.components.LoadingIndicator
|
||||
import com.fizzy.android.domain.model.Notification
|
||||
import com.fizzy.android.domain.model.NotificationType
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.FormatStyle
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun NotificationsScreen(
|
||||
onBackClick: () -> Unit,
|
||||
onNotificationClick: (Notification) -> Unit,
|
||||
viewModel: NotificationsViewModel = hiltViewModel()
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.events.collectLatest { event ->
|
||||
when (event) {
|
||||
is NotificationsEvent.ShowError -> snackbarHostState.showSnackbar(event.message)
|
||||
is NotificationsEvent.NavigateToCard -> {
|
||||
val notification = uiState.notifications.find { it.cardId == event.cardId }
|
||||
notification?.let { onNotificationClick(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val unreadCount = uiState.notifications.count { !it.read }
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Notifications") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBackClick) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = viewModel::refresh) {
|
||||
Icon(Icons.Default.Refresh, contentDescription = "Refresh")
|
||||
}
|
||||
if (unreadCount > 0) {
|
||||
TextButton(onClick = viewModel::markAllAsRead) {
|
||||
Text("Mark all read")
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) }
|
||||
) { paddingValues ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
) {
|
||||
when {
|
||||
uiState.isLoading && uiState.notifications.isEmpty() -> {
|
||||
LoadingIndicator()
|
||||
}
|
||||
uiState.error != null && uiState.notifications.isEmpty() -> {
|
||||
ErrorMessage(
|
||||
message = uiState.error ?: "Unknown error",
|
||||
onRetry = viewModel::loadNotifications
|
||||
)
|
||||
}
|
||||
uiState.notifications.isEmpty() -> {
|
||||
EmptyState(
|
||||
icon = Icons.Default.Notifications,
|
||||
title = "No notifications",
|
||||
description = "You're all caught up!"
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
NotificationsList(
|
||||
groupedNotifications = uiState.groupedNotifications,
|
||||
onNotificationClick = { notification ->
|
||||
viewModel.onNotificationClick(notification)
|
||||
},
|
||||
onMarkAsRead = viewModel::markAsRead
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Show loading indicator when refreshing
|
||||
if (uiState.isRefreshing) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.padding(16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NotificationsList(
|
||||
groupedNotifications: Map<LocalDate, List<Notification>>,
|
||||
onNotificationClick: (Notification) -> Unit,
|
||||
onMarkAsRead: (Long) -> Unit
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
groupedNotifications.forEach { (date, notifications) ->
|
||||
item(key = "header_$date") {
|
||||
DateHeader(date = date)
|
||||
}
|
||||
|
||||
items(
|
||||
items = notifications,
|
||||
key = { it.id }
|
||||
) { notification ->
|
||||
NotificationItem(
|
||||
notification = notification,
|
||||
onClick = { onNotificationClick(notification) },
|
||||
onMarkAsRead = { onMarkAsRead(notification.id) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DateHeader(date: LocalDate) {
|
||||
val today = LocalDate.now()
|
||||
val yesterday = today.minusDays(1)
|
||||
|
||||
val dateText = when (date) {
|
||||
today -> "Today"
|
||||
yesterday -> "Yesterday"
|
||||
else -> date.format(DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM))
|
||||
}
|
||||
|
||||
Text(
|
||||
text = dateText,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f))
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NotificationItem(
|
||||
notification: Notification,
|
||||
onClick: () -> Unit,
|
||||
onMarkAsRead: () -> Unit
|
||||
) {
|
||||
val icon = when (notification.type) {
|
||||
NotificationType.CARD_ASSIGNED -> Icons.Default.PersonAdd
|
||||
NotificationType.CARD_MENTIONED -> Icons.Default.AlternateEmail
|
||||
NotificationType.CARD_COMMENTED -> Icons.Default.Comment
|
||||
NotificationType.CARD_MOVED -> Icons.Default.MoveDown
|
||||
NotificationType.CARD_UPDATED -> Icons.Default.Edit
|
||||
NotificationType.STEP_COMPLETED -> Icons.Default.CheckCircle
|
||||
NotificationType.REACTION_ADDED -> Icons.Default.ThumbUp
|
||||
NotificationType.BOARD_SHARED -> Icons.Default.Share
|
||||
NotificationType.OTHER -> Icons.Default.Notifications
|
||||
}
|
||||
|
||||
val iconColor = when (notification.type) {
|
||||
NotificationType.CARD_ASSIGNED -> MaterialTheme.colorScheme.primary
|
||||
NotificationType.CARD_MENTIONED -> MaterialTheme.colorScheme.secondary
|
||||
NotificationType.CARD_COMMENTED -> MaterialTheme.colorScheme.tertiary
|
||||
NotificationType.STEP_COMPLETED -> MaterialTheme.colorScheme.primary
|
||||
else -> MaterialTheme.colorScheme.outline
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.background(
|
||||
if (!notification.read)
|
||||
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.1f)
|
||||
else
|
||||
MaterialTheme.colorScheme.surface
|
||||
)
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.Top
|
||||
) {
|
||||
// Unread indicator
|
||||
if (!notification.read) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(top = 6.dp)
|
||||
.size(8.dp)
|
||||
.background(
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
shape = CircleShape
|
||||
)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
}
|
||||
|
||||
// Icon
|
||||
Surface(
|
||||
modifier = Modifier.size(40.dp),
|
||||
shape = CircleShape,
|
||||
color = iconColor.copy(alpha = 0.15f)
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = iconColor
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
|
||||
// Content
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = notification.title,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = if (!notification.read) FontWeight.SemiBold else FontWeight.Normal,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
|
||||
Text(
|
||||
text = notification.body,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
if (notification.actor != null) {
|
||||
Surface(
|
||||
modifier = Modifier.size(16.dp),
|
||||
shape = CircleShape,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
text = notification.actor.name.first().uppercase(),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onPrimary
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
text = notification.actor.name,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
Text(
|
||||
text = " • ",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = notification.createdAt
|
||||
.atZone(ZoneId.systemDefault())
|
||||
.format(DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Mark as read button (if unread)
|
||||
if (!notification.read) {
|
||||
IconButton(
|
||||
onClick = onMarkAsRead,
|
||||
modifier = Modifier.size(32.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.MarkEmailRead,
|
||||
contentDescription = "Mark as read",
|
||||
modifier = Modifier.size(18.dp),
|
||||
tint = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
}
|
||||
|
|
@ -0,0 +1,187 @@
|
|||
package com.fizzy.android.feature.notifications
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.fizzy.android.core.network.ApiResult
|
||||
import com.fizzy.android.domain.model.Notification
|
||||
import com.fizzy.android.domain.repository.NotificationRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
import javax.inject.Inject
|
||||
|
||||
data class NotificationsUiState(
|
||||
val notifications: List<Notification> = emptyList(),
|
||||
val groupedNotifications: Map<LocalDate, List<Notification>> = emptyMap(),
|
||||
val isLoading: Boolean = false,
|
||||
val isRefreshing: Boolean = false,
|
||||
val error: String? = null
|
||||
)
|
||||
|
||||
sealed class NotificationsEvent {
|
||||
data class ShowError(val message: String) : NotificationsEvent()
|
||||
data class NavigateToCard(val cardId: Long) : NotificationsEvent()
|
||||
}
|
||||
|
||||
@HiltViewModel
|
||||
class NotificationsViewModel @Inject constructor(
|
||||
private val notificationRepository: NotificationRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(NotificationsUiState())
|
||||
val uiState: StateFlow<NotificationsUiState> = _uiState.asStateFlow()
|
||||
|
||||
private val _events = MutableSharedFlow<NotificationsEvent>()
|
||||
val events: SharedFlow<NotificationsEvent> = _events.asSharedFlow()
|
||||
|
||||
private var pollingJob: Job? = null
|
||||
|
||||
init {
|
||||
loadNotifications()
|
||||
startPolling()
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
pollingJob?.cancel()
|
||||
}
|
||||
|
||||
private fun startPolling() {
|
||||
pollingJob = viewModelScope.launch {
|
||||
while (true) {
|
||||
delay(30_000) // Poll every 30 seconds
|
||||
silentRefresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun silentRefresh() {
|
||||
when (val result = notificationRepository.getNotifications()) {
|
||||
is ApiResult.Success -> {
|
||||
updateNotifications(result.data)
|
||||
}
|
||||
else -> { /* Ignore silent refresh failures */ }
|
||||
}
|
||||
}
|
||||
|
||||
fun loadNotifications() {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isLoading = true, error = null) }
|
||||
|
||||
when (val result = notificationRepository.getNotifications()) {
|
||||
is ApiResult.Success -> {
|
||||
updateNotifications(result.data)
|
||||
_uiState.update { it.copy(isLoading = false) }
|
||||
}
|
||||
is ApiResult.Error -> {
|
||||
_uiState.update {
|
||||
it.copy(isLoading = false, error = "Failed to load notifications")
|
||||
}
|
||||
}
|
||||
is ApiResult.Exception -> {
|
||||
_uiState.update {
|
||||
it.copy(isLoading = false, error = "Network error")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isRefreshing = true) }
|
||||
|
||||
when (val result = notificationRepository.getNotifications()) {
|
||||
is ApiResult.Success -> {
|
||||
updateNotifications(result.data)
|
||||
}
|
||||
else -> { /* Ignore */ }
|
||||
}
|
||||
|
||||
_uiState.update { it.copy(isRefreshing = false) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateNotifications(notifications: List<Notification>) {
|
||||
val grouped = notifications.groupBy { notification ->
|
||||
notification.createdAt
|
||||
.atZone(ZoneId.systemDefault())
|
||||
.toLocalDate()
|
||||
}.toSortedMap(compareByDescending { it })
|
||||
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
notifications = notifications,
|
||||
groupedNotifications = grouped
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun markAsRead(notificationId: Long) {
|
||||
viewModelScope.launch {
|
||||
when (notificationRepository.markAsRead(notificationId)) {
|
||||
is ApiResult.Success -> {
|
||||
_uiState.update { state ->
|
||||
val updated = state.notifications.map { notification ->
|
||||
if (notification.id == notificationId) {
|
||||
notification.copy(read = true)
|
||||
} else notification
|
||||
}
|
||||
val grouped = updated.groupBy { notification ->
|
||||
notification.createdAt
|
||||
.atZone(ZoneId.systemDefault())
|
||||
.toLocalDate()
|
||||
}.toSortedMap(compareByDescending { it })
|
||||
|
||||
state.copy(
|
||||
notifications = updated,
|
||||
groupedNotifications = grouped
|
||||
)
|
||||
}
|
||||
}
|
||||
else -> _events.emit(NotificationsEvent.ShowError("Failed to mark as read"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun markAllAsRead() {
|
||||
viewModelScope.launch {
|
||||
when (notificationRepository.markAllAsRead()) {
|
||||
is ApiResult.Success -> {
|
||||
_uiState.update { state ->
|
||||
val updated = state.notifications.map { it.copy(read = true) }
|
||||
val grouped = updated.groupBy { notification ->
|
||||
notification.createdAt
|
||||
.atZone(ZoneId.systemDefault())
|
||||
.toLocalDate()
|
||||
}.toSortedMap(compareByDescending { it })
|
||||
|
||||
state.copy(
|
||||
notifications = updated,
|
||||
groupedNotifications = grouped
|
||||
)
|
||||
}
|
||||
}
|
||||
else -> _events.emit(NotificationsEvent.ShowError("Failed to mark all as read"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onNotificationClick(notification: Notification) {
|
||||
viewModelScope.launch {
|
||||
// Mark as read
|
||||
if (!notification.read) {
|
||||
markAsRead(notification.id)
|
||||
}
|
||||
|
||||
// Navigate to card if applicable
|
||||
notification.cardId?.let { cardId ->
|
||||
_events.emit(NotificationsEvent.NavigateToCard(cardId))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,415 @@
|
|||
package com.fizzy.android.feature.settings
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.Logout
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.fizzy.android.data.local.ThemeMode
|
||||
import com.fizzy.android.domain.model.Account
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SettingsScreen(
|
||||
onBackClick: () -> Unit,
|
||||
onLogout: () -> Unit,
|
||||
viewModel: SettingsViewModel = hiltViewModel()
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.events.collectLatest { event ->
|
||||
when (event) {
|
||||
SettingsEvent.Logout -> onLogout()
|
||||
SettingsEvent.AccountSwitched -> { /* Stay on screen */ }
|
||||
SettingsEvent.NavigateToAddAccount -> {
|
||||
// Would navigate to auth screen for adding new account
|
||||
// For simplicity, just log out and let user re-auth
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Settings") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBackClick) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
// Current Account Section
|
||||
SettingsSectionHeader("Account")
|
||||
|
||||
uiState.currentAccount?.let { account ->
|
||||
AccountItem(
|
||||
account = account,
|
||||
isActive = true,
|
||||
showSwitcher = uiState.allAccounts.size > 1,
|
||||
onSwitcherClick = viewModel::showAccountSwitcher,
|
||||
onLogoutClick = { viewModel.showLogoutConfirmation(account) }
|
||||
)
|
||||
}
|
||||
|
||||
// Multiple Accounts Management
|
||||
if (uiState.allAccounts.size > 1) {
|
||||
ListItem(
|
||||
headlineContent = { Text("Switch Account") },
|
||||
leadingContent = {
|
||||
Icon(Icons.Default.SwitchAccount, contentDescription = null)
|
||||
},
|
||||
trailingContent = {
|
||||
Text(
|
||||
"${uiState.allAccounts.size} accounts",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
},
|
||||
modifier = Modifier.clickable { viewModel.showAccountSwitcher() }
|
||||
)
|
||||
}
|
||||
|
||||
ListItem(
|
||||
headlineContent = { Text("Add Account") },
|
||||
leadingContent = {
|
||||
Icon(Icons.Default.PersonAdd, contentDescription = null)
|
||||
},
|
||||
modifier = Modifier.clickable { viewModel.navigateToAddAccount() }
|
||||
)
|
||||
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
||||
|
||||
// Appearance Section
|
||||
SettingsSectionHeader("Appearance")
|
||||
|
||||
ThemeSelector(
|
||||
currentTheme = uiState.themeMode,
|
||||
onThemeSelect = viewModel::setThemeMode
|
||||
)
|
||||
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
||||
|
||||
// About Section
|
||||
SettingsSectionHeader("About")
|
||||
|
||||
ListItem(
|
||||
headlineContent = { Text("Version") },
|
||||
supportingContent = { Text("1.0.0") },
|
||||
leadingContent = {
|
||||
Icon(Icons.Default.Info, contentDescription = null)
|
||||
}
|
||||
)
|
||||
|
||||
ListItem(
|
||||
headlineContent = { Text("Open Source Licenses") },
|
||||
leadingContent = {
|
||||
Icon(Icons.Default.Description, contentDescription = null)
|
||||
},
|
||||
modifier = Modifier.clickable { /* Open licenses */ }
|
||||
)
|
||||
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
||||
|
||||
// Danger Zone
|
||||
SettingsSectionHeader("Danger Zone", isDestructive = true)
|
||||
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text(
|
||||
"Log out from all accounts",
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
},
|
||||
leadingContent = {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.Logout,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
)
|
||||
},
|
||||
modifier = Modifier.clickable { viewModel.showLogoutAllConfirmation() }
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
}
|
||||
}
|
||||
|
||||
// Account Switcher Dialog
|
||||
if (uiState.showAccountSwitcher) {
|
||||
AccountSwitcherDialog(
|
||||
accounts = uiState.allAccounts,
|
||||
currentAccountId = uiState.currentAccount?.id,
|
||||
onAccountSelect = viewModel::switchAccount,
|
||||
onAddAccount = viewModel::showAddAccount,
|
||||
onDismiss = viewModel::hideAccountSwitcher
|
||||
)
|
||||
}
|
||||
|
||||
// Logout Confirmation Dialog
|
||||
uiState.showLogoutConfirmation?.let { account ->
|
||||
AlertDialog(
|
||||
onDismissRequest = viewModel::hideLogoutConfirmation,
|
||||
title = { Text("Log out?") },
|
||||
text = {
|
||||
Text("Are you sure you want to log out from ${account.email} on ${account.instanceHost}?")
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = { viewModel.logout(account.id) },
|
||||
colors = ButtonDefaults.textButtonColors(
|
||||
contentColor = MaterialTheme.colorScheme.error
|
||||
)
|
||||
) {
|
||||
Text("Log out")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = viewModel::hideLogoutConfirmation) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Logout All Confirmation Dialog
|
||||
if (uiState.showLogoutAllConfirmation) {
|
||||
AlertDialog(
|
||||
onDismissRequest = viewModel::hideLogoutAllConfirmation,
|
||||
title = { Text("Log out from all accounts?") },
|
||||
text = {
|
||||
Text("You will be logged out from all ${uiState.allAccounts.size} accounts. You will need to sign in again.")
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = viewModel::logoutAll,
|
||||
colors = ButtonDefaults.textButtonColors(
|
||||
contentColor = MaterialTheme.colorScheme.error
|
||||
)
|
||||
) {
|
||||
Text("Log out all")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = viewModel::hideLogoutAllConfirmation) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingsSectionHeader(
|
||||
title: String,
|
||||
isDestructive: Boolean = false
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = if (isDestructive)
|
||||
MaterialTheme.colorScheme.error
|
||||
else
|
||||
MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AccountItem(
|
||||
account: Account,
|
||||
isActive: Boolean,
|
||||
showSwitcher: Boolean,
|
||||
onSwitcherClick: () -> Unit,
|
||||
onLogoutClick: () -> Unit
|
||||
) {
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text(
|
||||
text = account.userName,
|
||||
fontWeight = if (isActive) FontWeight.SemiBold else FontWeight.Normal
|
||||
)
|
||||
},
|
||||
supportingContent = {
|
||||
Column {
|
||||
Text(account.email)
|
||||
Text(
|
||||
account.instanceHost,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
}
|
||||
},
|
||||
leadingContent = {
|
||||
Surface(
|
||||
modifier = Modifier.size(48.dp),
|
||||
shape = CircleShape,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
text = account.userName.first().uppercase(),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onPrimary
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
trailingContent = {
|
||||
Row {
|
||||
if (showSwitcher) {
|
||||
IconButton(onClick = onSwitcherClick) {
|
||||
Icon(
|
||||
Icons.Default.SwitchAccount,
|
||||
contentDescription = "Switch account"
|
||||
)
|
||||
}
|
||||
}
|
||||
IconButton(onClick = onLogoutClick) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.Logout,
|
||||
contentDescription = "Log out",
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ThemeSelector(
|
||||
currentTheme: ThemeMode,
|
||||
onThemeSelect: (ThemeMode) -> Unit
|
||||
) {
|
||||
Column {
|
||||
ThemeMode.entries.forEach { theme ->
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text(
|
||||
when (theme) {
|
||||
ThemeMode.SYSTEM -> "System default"
|
||||
ThemeMode.LIGHT -> "Light"
|
||||
ThemeMode.DARK -> "Dark"
|
||||
}
|
||||
)
|
||||
},
|
||||
leadingContent = {
|
||||
RadioButton(
|
||||
selected = currentTheme == theme,
|
||||
onClick = { onThemeSelect(theme) }
|
||||
)
|
||||
},
|
||||
modifier = Modifier.clickable { onThemeSelect(theme) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AccountSwitcherDialog(
|
||||
accounts: List<Account>,
|
||||
currentAccountId: String?,
|
||||
onAccountSelect: (String) -> Unit,
|
||||
onAddAccount: () -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Switch Account") },
|
||||
text = {
|
||||
LazyColumn {
|
||||
items(accounts) { account ->
|
||||
ListItem(
|
||||
headlineContent = { Text(account.userName) },
|
||||
supportingContent = {
|
||||
Column {
|
||||
Text(account.email)
|
||||
Text(
|
||||
account.instanceHost,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
}
|
||||
},
|
||||
leadingContent = {
|
||||
Surface(
|
||||
modifier = Modifier.size(40.dp),
|
||||
shape = CircleShape,
|
||||
color = if (account.id == currentAccountId)
|
||||
MaterialTheme.colorScheme.primary
|
||||
else
|
||||
MaterialTheme.colorScheme.surfaceVariant
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
text = account.userName.first().uppercase(),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = if (account.id == currentAccountId)
|
||||
MaterialTheme.colorScheme.onPrimary
|
||||
else
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
trailingContent = {
|
||||
if (account.id == currentAccountId) {
|
||||
Icon(
|
||||
Icons.Default.Check,
|
||||
contentDescription = "Active",
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.clickable {
|
||||
if (account.id != currentAccountId) {
|
||||
onAccountSelect(account.id)
|
||||
}
|
||||
}
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
|
||||
item {
|
||||
ListItem(
|
||||
headlineContent = { Text("Add account") },
|
||||
leadingContent = {
|
||||
Icon(Icons.Default.PersonAdd, contentDescription = null)
|
||||
},
|
||||
modifier = Modifier.clickable { onAddAccount() }
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Close")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,145 @@
|
|||
package com.fizzy.android.feature.settings
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.fizzy.android.data.local.SettingsStorage
|
||||
import com.fizzy.android.data.local.ThemeMode
|
||||
import com.fizzy.android.domain.model.Account
|
||||
import com.fizzy.android.domain.repository.AuthRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
data class SettingsUiState(
|
||||
val currentAccount: Account? = null,
|
||||
val allAccounts: List<Account> = emptyList(),
|
||||
val themeMode: ThemeMode = ThemeMode.SYSTEM,
|
||||
val showAccountSwitcher: Boolean = false,
|
||||
val showAddAccount: Boolean = false,
|
||||
val showLogoutConfirmation: Account? = null,
|
||||
val showLogoutAllConfirmation: Boolean = false
|
||||
)
|
||||
|
||||
sealed class SettingsEvent {
|
||||
data object Logout : SettingsEvent()
|
||||
data object AccountSwitched : SettingsEvent()
|
||||
data object NavigateToAddAccount : SettingsEvent()
|
||||
}
|
||||
|
||||
@HiltViewModel
|
||||
class SettingsViewModel @Inject constructor(
|
||||
private val authRepository: AuthRepository,
|
||||
private val settingsStorage: SettingsStorage
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(SettingsUiState())
|
||||
val uiState: StateFlow<SettingsUiState> = _uiState.asStateFlow()
|
||||
|
||||
private val _events = MutableSharedFlow<SettingsEvent>()
|
||||
val events: SharedFlow<SettingsEvent> = _events.asSharedFlow()
|
||||
|
||||
init {
|
||||
observeAccounts()
|
||||
observeTheme()
|
||||
}
|
||||
|
||||
private fun observeAccounts() {
|
||||
viewModelScope.launch {
|
||||
combine(
|
||||
authRepository.currentAccount,
|
||||
authRepository.allAccounts
|
||||
) { current, all ->
|
||||
Pair(current, all)
|
||||
}.collect { (current, all) ->
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
currentAccount = current,
|
||||
allAccounts = all
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun observeTheme() {
|
||||
viewModelScope.launch {
|
||||
settingsStorage.themeMode.collect { theme ->
|
||||
_uiState.update { it.copy(themeMode = theme) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun showAccountSwitcher() {
|
||||
_uiState.update { it.copy(showAccountSwitcher = true) }
|
||||
}
|
||||
|
||||
fun hideAccountSwitcher() {
|
||||
_uiState.update { it.copy(showAccountSwitcher = false) }
|
||||
}
|
||||
|
||||
fun switchAccount(accountId: String) {
|
||||
viewModelScope.launch {
|
||||
authRepository.switchAccount(accountId)
|
||||
_uiState.update { it.copy(showAccountSwitcher = false) }
|
||||
_events.emit(SettingsEvent.AccountSwitched)
|
||||
}
|
||||
}
|
||||
|
||||
fun showAddAccount() {
|
||||
_uiState.update { it.copy(showAccountSwitcher = false, showAddAccount = true) }
|
||||
}
|
||||
|
||||
fun hideAddAccount() {
|
||||
_uiState.update { it.copy(showAddAccount = false) }
|
||||
}
|
||||
|
||||
fun navigateToAddAccount() {
|
||||
viewModelScope.launch {
|
||||
_events.emit(SettingsEvent.NavigateToAddAccount)
|
||||
}
|
||||
}
|
||||
|
||||
fun showLogoutConfirmation(account: Account) {
|
||||
_uiState.update { it.copy(showLogoutConfirmation = account) }
|
||||
}
|
||||
|
||||
fun hideLogoutConfirmation() {
|
||||
_uiState.update { it.copy(showLogoutConfirmation = null) }
|
||||
}
|
||||
|
||||
fun logout(accountId: String) {
|
||||
viewModelScope.launch {
|
||||
authRepository.logout(accountId)
|
||||
_uiState.update { it.copy(showLogoutConfirmation = null) }
|
||||
|
||||
// Check if there are any accounts left
|
||||
val remaining = _uiState.value.allAccounts.filter { it.id != accountId }
|
||||
if (remaining.isEmpty()) {
|
||||
_events.emit(SettingsEvent.Logout)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun showLogoutAllConfirmation() {
|
||||
_uiState.update { it.copy(showLogoutAllConfirmation = true) }
|
||||
}
|
||||
|
||||
fun hideLogoutAllConfirmation() {
|
||||
_uiState.update { it.copy(showLogoutAllConfirmation = false) }
|
||||
}
|
||||
|
||||
fun logoutAll() {
|
||||
viewModelScope.launch {
|
||||
authRepository.logoutAll()
|
||||
_uiState.update { it.copy(showLogoutAllConfirmation = false) }
|
||||
_events.emit(SettingsEvent.Logout)
|
||||
}
|
||||
}
|
||||
|
||||
fun setThemeMode(mode: ThemeMode) {
|
||||
viewModelScope.launch {
|
||||
settingsStorage.setThemeMode(mode)
|
||||
}
|
||||
}
|
||||
}
|
||||
10
app/src/main/res/drawable/ic_launcher_background.xml
Normal file
10
app/src/main/res/drawable/ic_launcher_background.xml
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#2563EB"
|
||||
android:pathData="M0,0h108v108h-108z"/>
|
||||
</vector>
|
||||
56
app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
56
app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
|
||||
<!-- Background circle -->
|
||||
<path
|
||||
android:fillColor="#2563EB"
|
||||
android:pathData="M54,54m-40,0a40,40 0,1 1,80 0a40,40 0,1 1,-80 0"/>
|
||||
|
||||
<!-- Kanban board icon -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M30,35h48v38h-48z"
|
||||
android:strokeWidth="2"
|
||||
android:strokeColor="#FFFFFF"/>
|
||||
|
||||
<!-- Column dividers -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M46,35v38"/>
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:strokeWidth="1"
|
||||
android:strokeColor="#FFFFFF"
|
||||
android:pathData="M46,35L46,73"/>
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:strokeWidth="1"
|
||||
android:strokeColor="#FFFFFF"
|
||||
android:pathData="M62,35L62,73"/>
|
||||
|
||||
<!-- Cards in columns -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:fillAlpha="0.7"
|
||||
android:pathData="M33,40h10v6h-10z"/>
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:fillAlpha="0.7"
|
||||
android:pathData="M33,49h10v8h-10z"/>
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:fillAlpha="0.7"
|
||||
android:pathData="M49,40h10v10h-10z"/>
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:fillAlpha="0.7"
|
||||
android:pathData="M49,53h10v6h-10z"/>
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:fillAlpha="0.7"
|
||||
android:pathData="M65,40h10v5h-10z"/>
|
||||
</vector>
|
||||
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal file
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
Normal file
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
7
app/src/main/res/values/colors.xml
Normal file
7
app/src/main/res/values/colors.xml
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="splash_background">#FFFFFF</color>
|
||||
<color name="fizzy_primary">#2563EB</color>
|
||||
<color name="fizzy_primary_dark">#1D4ED8</color>
|
||||
<color name="fizzy_secondary">#10B981</color>
|
||||
</resources>
|
||||
4
app/src/main/res/values/strings.xml
Normal file
4
app/src/main/res/values/strings.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Fizzy</string>
|
||||
</resources>
|
||||
13
app/src/main/res/values/themes.xml
Normal file
13
app/src/main/res/values/themes.xml
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.Fizzy" parent="android:Theme.Material.Light.NoActionBar">
|
||||
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||
</style>
|
||||
|
||||
<style name="Theme.Fizzy.Splash" parent="Theme.SplashScreen">
|
||||
<item name="windowSplashScreenBackground">@color/splash_background</item>
|
||||
<item name="windowSplashScreenAnimatedIcon">@drawable/ic_launcher_foreground</item>
|
||||
<item name="postSplashScreenTheme">@style/Theme.Fizzy</item>
|
||||
</style>
|
||||
</resources>
|
||||
6
build.gradle.kts
Normal file
6
build.gradle.kts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
plugins {
|
||||
id("com.android.application") version "8.2.2" apply false
|
||||
id("org.jetbrains.kotlin.android") version "1.9.22" apply false
|
||||
id("com.google.dagger.hilt.android") version "2.50" apply false
|
||||
id("com.google.devtools.ksp") version "1.9.22-1.0.17" apply false
|
||||
}
|
||||
5
gradle.properties
Normal file
5
gradle.properties
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
android.useAndroidX=true
|
||||
kotlin.code.style=official
|
||||
android.nonTransitiveRClass=true
|
||||
org.gradle.java.home=/Applications/Android Studio.app/Contents/jbr/Contents/Home
|
||||
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
7
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
7
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
183
gradlew
vendored
Executable file
183
gradlew
vendored
Executable file
|
|
@ -0,0 +1,183 @@
|
|||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015-2021 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#)}; t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# temporary directory check times (less one)
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command;
|
||||
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
|
||||
# temporary directory checks; they're typically GRADLE_OPTS="${GRADLE_OPTS} -Djava.io.tmpdir=/some/path"
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-classpath "$CLASSPATH" \
|
||||
org.gradle.wrapper.GradleWrapperMain \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n://www.gradle.org/docs/current/userguide/gradle_command_line.html
|
||||
#
|
||||
# The default xargs://www.gnu.org/software/findutils/manual/html_node/find_html/Invoking-xargs.html
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[`528444444]\\\[]~\\\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
18
settings.gradle.kts
Normal file
18
settings.gradle.kts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
pluginManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = "Fizzy"
|
||||
include(":app")
|
||||
Loading…
Reference in a new issue