feat: Update Gradle wrapper to version 8.13
feat: Add JitPack repository to dependency resolution management feat: Create ViewStyle enum for bookmark display styles feat: Implement EditLinkScreen with ViewModel for editing links feat: Add EditLinkViewModel to manage link editing state and logic feat: Create LinkItemViews for displaying links in various formats chore: Add build output and compile output logs
This commit is contained in:
+11
-5
@@ -48,8 +48,8 @@ android {
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
isMinifyEnabled = false
|
||||
isShrinkResources = false
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
@@ -63,11 +63,11 @@ android {
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_1_8
|
||||
targetCompatibility = JavaVersion.VERSION_1_8
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = "1.8"
|
||||
jvmTarget = "17"
|
||||
}
|
||||
buildFeatures {
|
||||
compose = true
|
||||
@@ -87,12 +87,15 @@ dependencies {
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.material)
|
||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.ktx)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation(platform(libs.androidx.compose.bom))
|
||||
implementation(libs.androidx.ui)
|
||||
implementation(libs.androidx.ui.graphics)
|
||||
implementation(libs.androidx.ui.tooling.preview)
|
||||
implementation(libs.androidx.material3)
|
||||
implementation("androidx.compose.material:material-icons-extended")
|
||||
implementation(libs.androidx.compose.material)
|
||||
|
||||
// Navigation
|
||||
implementation(libs.androidx.navigation.compose)
|
||||
@@ -121,6 +124,9 @@ dependencies {
|
||||
|
||||
// Splash Screen
|
||||
implementation("androidx.core:core-splashscreen:1.0.1")
|
||||
|
||||
// Markdown
|
||||
implementation(libs.compose.markdown)
|
||||
|
||||
testImplementation(libs.junit)
|
||||
androidTestImplementation(libs.androidx.junit)
|
||||
|
||||
Vendored
+60
-6
@@ -33,13 +33,37 @@
|
||||
# Moshi
|
||||
-keep class com.squareup.moshi.** { *; }
|
||||
-keep interface com.squareup.moshi.** { *; }
|
||||
-keep class com.shaarit.data.dto.** { *; }
|
||||
-keepclassmembers class com.shaarit.data.dto.** { *; }
|
||||
|
||||
# Keep Moshi @JsonClass annotated classes and their generated adapters
|
||||
-keep @com.squareup.moshi.JsonClass class * { *; }
|
||||
-keep class **JsonAdapter { *; }
|
||||
-keep class **$JsonAdapter { *; }
|
||||
-keepnames @com.squareup.moshi.JsonClass class *
|
||||
|
||||
# Keep Moshi-generated adapter factories
|
||||
-keep class *JsonAdapterFactory { *; }
|
||||
|
||||
# Keep all DTOs with their fields and constructors
|
||||
-keep class com.shaarit.data.dto.** {
|
||||
<init>(...);
|
||||
<fields>;
|
||||
}
|
||||
-keepclassmembers class com.shaarit.data.dto.** {
|
||||
<init>(...);
|
||||
<fields>;
|
||||
}
|
||||
|
||||
# Keep Kotlin data class component functions and copy methods
|
||||
-keepclassmembers class com.shaarit.data.dto.** {
|
||||
public ** component*();
|
||||
public ** copy(...);
|
||||
}
|
||||
|
||||
# Keep Kotlin Metadata
|
||||
-keep class kotlin.Metadata { *; }
|
||||
-keepattributes RuntimeVisibleAnnotations
|
||||
|
||||
# Hilt
|
||||
# Hilt / Dagger
|
||||
-keepclasseswithmembers class * {
|
||||
@dagger.* <methods>;
|
||||
}
|
||||
@@ -49,14 +73,44 @@
|
||||
}
|
||||
-keep class dagger.* { *; }
|
||||
-keep class javax.inject.* { *; }
|
||||
-keep class * extends dagger.hilt.android.internal.managers.ComponentSupplier { *; }
|
||||
-keep class * extends dagger.hilt.android.internal.managers.ViewComponentManager$FragmentContextWrapper { *; }
|
||||
-keep class * implements dagger.hilt.internal.GeneratedComponent { *; }
|
||||
-keep class * implements dagger.hilt.internal.GeneratedComponentManager { *; }
|
||||
-keep,allowobfuscation,allowshrinking @dagger.hilt.android.EarlyEntryPoint class *
|
||||
-keep,allowobfuscation,allowshrinking @dagger.hilt.android.EntryPoint class *
|
||||
-keep,allowobfuscation,allowshrinking @dagger.hilt.InstallIn class *
|
||||
-dontwarn dagger.internal.codegen.**
|
||||
|
||||
# Hilt ViewModel
|
||||
-keep class * extends androidx.lifecycle.ViewModel { *; }
|
||||
-keep class * extends androidx.lifecycle.AndroidViewModel { *; }
|
||||
-keepclassmembers class * extends androidx.lifecycle.ViewModel {
|
||||
<init>(...);
|
||||
}
|
||||
|
||||
# JWT (io.jsonwebtoken)
|
||||
-keep class io.jsonwebtoken.** { *; }
|
||||
|
||||
# Compose
|
||||
-keep class androidx.compose.** { *; }
|
||||
-dontwarn androidx.compose.**
|
||||
|
||||
# Kotlin coroutines
|
||||
-keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {}
|
||||
-keepnames class kotlinx.coroutines.CoroutineExceptionHandler {}
|
||||
-keepclassmembernames class kotlinx.** {
|
||||
volatile <fields>;
|
||||
}
|
||||
-dontwarn kotlinx.coroutines.**
|
||||
|
||||
# Keep ALL Application classes to prevent runtime crashes
|
||||
-keep class com.shaarit.** { *; }
|
||||
-keepclassmembers class com.shaarit.** { *; }
|
||||
|
||||
# Keep Hilt generated classes
|
||||
-keep class com.shaarit.Hilt_* { *; }
|
||||
-keep class **_HiltModules_* { *; }
|
||||
-keep class **_Factory { *; }
|
||||
-keep class **_MembersInjector { *; }
|
||||
|
||||
# Application classes
|
||||
-keep class com.shaarit.domain.model.** { *; }
|
||||
-keep class com.shaarit.data.api.** { *; }
|
||||
|
||||
@@ -20,7 +20,7 @@ import dagger.hilt.android.AndroidEntryPoint
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
// Install splash screen before super.onCreate
|
||||
val splashScreen = installSplashScreen()
|
||||
installSplashScreen()
|
||||
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.shaarit.core.storage
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.util.Log
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
@@ -27,7 +28,18 @@ class TokenManagerImpl @Inject constructor(@ApplicationContext private val conte
|
||||
val masterKey =
|
||||
MasterKey.Builder(context).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build()
|
||||
|
||||
EncryptedSharedPreferences.create(
|
||||
try {
|
||||
createEncryptedPrefs(masterKey)
|
||||
} catch (e: Exception) {
|
||||
Log.e("TokenManager", "Error creating EncryptedSharedPreferences, wiping data", e)
|
||||
// If encryption fails (corrupted key or incompatible transition), clear the file and retry
|
||||
context.getSharedPreferences("secret_prefs", Context.MODE_PRIVATE).edit().clear().apply()
|
||||
createEncryptedPrefs(masterKey)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createEncryptedPrefs(masterKey: MasterKey): SharedPreferences {
|
||||
return EncryptedSharedPreferences.create(
|
||||
context,
|
||||
"secret_prefs",
|
||||
masterKey,
|
||||
@@ -49,7 +61,7 @@ class TokenManagerImpl @Inject constructor(@ApplicationContext private val conte
|
||||
}
|
||||
|
||||
override fun saveBaseUrl(url: String) {
|
||||
// Remove trailing slash if present for consistency, though better done in logic
|
||||
// Remove trailing slash if present for consistency
|
||||
val cleanUrl = if (url.endsWith("/")) url.dropLast(1) else url
|
||||
sharedPreferences.edit().putString(KEY_BASE_URL, cleanUrl).apply()
|
||||
}
|
||||
|
||||
@@ -33,6 +33,9 @@ interface ShaarliApi {
|
||||
|
||||
@DELETE("/api/v1/links/{id}") suspend fun deleteLink(@Path("id") id: Int): Response<Unit>
|
||||
|
||||
@GET("/api/v1/links/{id}")
|
||||
suspend fun getLink(@Path("id") id: Int): LinkDto
|
||||
|
||||
/** Get all tags with their occurrence count. */
|
||||
@GET("/api/v1/tags")
|
||||
suspend fun getTags(
|
||||
|
||||
@@ -20,7 +20,7 @@ constructor(private val api: ShaarliApi, private val tokenManager: TokenManager)
|
||||
// 3. Verify credentials by calling the API info endpoint
|
||||
// This endpoint requires valid authentication
|
||||
return try {
|
||||
val info = api.getInfo()
|
||||
api.getInfo()
|
||||
// If we get here, authentication worked
|
||||
// The info contains Shaarli version and settings
|
||||
Result.success(true)
|
||||
|
||||
@@ -152,6 +152,15 @@ constructor(private val api: ShaarliApi, private val moshi: Moshi) : LinkReposit
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getLink(id: Int): Result<ShaarliLink> {
|
||||
return try {
|
||||
val link = api.getLink(id)
|
||||
Result.success(LinkMapper.toDomain(link))
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getTags(): Result<List<ShaarliTag>> {
|
||||
return try {
|
||||
val tags = api.getTags(offset = 0, limit = 500)
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.shaarit.domain.model
|
||||
|
||||
/**
|
||||
* Enum representing the different view styles for displaying bookmarks
|
||||
*/
|
||||
enum class ViewStyle {
|
||||
LIST,
|
||||
GRID,
|
||||
COMPACT;
|
||||
|
||||
companion object {
|
||||
fun fromOrdinal(ordinal: Int): ViewStyle {
|
||||
return entries.getOrElse(ordinal) { LIST }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,8 @@ interface LinkRepository {
|
||||
|
||||
suspend fun deleteLink(id: Int): Result<Unit>
|
||||
|
||||
suspend fun getLink(id: Int): Result<ShaarliLink>
|
||||
|
||||
suspend fun getTags(): Result<List<ShaarliTag>>
|
||||
|
||||
suspend fun getLinksByTag(tag: String): Result<List<ShaarliLink>>
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
package com.shaarit.presentation.edit
|
||||
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.shaarit.ui.components.GlassCard
|
||||
import com.shaarit.ui.components.GradientButton
|
||||
import com.shaarit.ui.components.PremiumTextField
|
||||
import com.shaarit.ui.components.SectionHeader
|
||||
import com.shaarit.ui.components.TagChip
|
||||
import com.shaarit.ui.theme.*
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun EditLinkScreen(
|
||||
onNavigateBack: () -> Unit,
|
||||
viewModel: EditLinkViewModel = hiltViewModel()
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
val url by viewModel.url.collectAsState()
|
||||
val title by viewModel.title.collectAsState()
|
||||
val description by viewModel.description.collectAsState()
|
||||
val selectedTags by viewModel.selectedTags.collectAsState()
|
||||
val newTagInput by viewModel.newTagInput.collectAsState()
|
||||
val availableTags by viewModel.availableTags.collectAsState()
|
||||
val isPrivate by viewModel.isPrivate.collectAsState()
|
||||
val tagSuggestions by viewModel.tagSuggestions.collectAsState()
|
||||
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
LaunchedEffect(uiState) {
|
||||
when (val state = uiState) {
|
||||
is EditLinkUiState.Success -> {
|
||||
onNavigateBack()
|
||||
}
|
||||
is EditLinkUiState.Error -> {
|
||||
snackbarHostState.showSnackbar(state.message)
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
brush = Brush.verticalGradient(
|
||||
colors = listOf(DeepNavy, DarkNavy)
|
||||
)
|
||||
)
|
||||
) {
|
||||
Scaffold(
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
"Edit Link",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onNavigateBack) {
|
||||
Icon(
|
||||
Icons.Default.ArrowBack,
|
||||
contentDescription = "Back",
|
||||
tint = TextPrimary
|
||||
)
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = DeepNavy.copy(alpha = 0.9f),
|
||||
titleContentColor = TextPrimary
|
||||
)
|
||||
)
|
||||
},
|
||||
containerColor = android.graphics.Color.TRANSPARENT.let {
|
||||
androidx.compose.ui.graphics.Color.Transparent
|
||||
}
|
||||
) { paddingValues ->
|
||||
when (uiState) {
|
||||
is EditLinkUiState.Loading -> {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
CircularProgressIndicator(color = CyanPrimary)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
"Loading link...",
|
||||
color = TextSecondary,
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(paddingValues)
|
||||
.padding(16.dp)
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(20.dp)
|
||||
) {
|
||||
// URL Section
|
||||
GlassCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Column {
|
||||
SectionHeader(title = "URL", subtitle = "Required")
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
PremiumTextField(
|
||||
value = url,
|
||||
onValueChange = { viewModel.url.value = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
placeholder = "https://example.com",
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
Icons.Default.Edit,
|
||||
contentDescription = null,
|
||||
tint = CyanPrimary
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Title Section
|
||||
GlassCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Column {
|
||||
SectionHeader(
|
||||
title = "Title",
|
||||
subtitle = "Optional"
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
PremiumTextField(
|
||||
value = title,
|
||||
onValueChange = { viewModel.title.value = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
placeholder = "Page title"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Description Section
|
||||
GlassCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Column {
|
||||
SectionHeader(
|
||||
title = "Description",
|
||||
subtitle = "Optional - Supports Markdown"
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
PremiumTextField(
|
||||
value = description,
|
||||
onValueChange = { viewModel.description.value = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
placeholder = "Add a description...",
|
||||
singleLine = false,
|
||||
minLines = 3
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Tags Section
|
||||
GlassCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Column {
|
||||
SectionHeader(title = "Tags", subtitle = "Organize your links")
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Selected tags
|
||||
if (selectedTags.isNotEmpty()) {
|
||||
LazyRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.padding(bottom = 12.dp)
|
||||
) {
|
||||
items(selectedTags) { tag ->
|
||||
TagChip(
|
||||
tag = tag,
|
||||
isSelected = true,
|
||||
onClick = { viewModel.removeTag(tag) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// New tag input
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
PremiumTextField(
|
||||
value = newTagInput,
|
||||
onValueChange = { viewModel.onNewTagInputChanged(it) },
|
||||
modifier = Modifier.weight(1f),
|
||||
placeholder = "Add tag..."
|
||||
)
|
||||
IconButton(
|
||||
onClick = { viewModel.addNewTag() },
|
||||
enabled = newTagInput.isNotBlank()
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Add,
|
||||
contentDescription = "Add tag",
|
||||
tint = if (newTagInput.isNotBlank()) CyanPrimary
|
||||
else TextMuted
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Tag suggestions
|
||||
AnimatedVisibility(
|
||||
visible = tagSuggestions.isNotEmpty(),
|
||||
enter = expandVertically() + fadeIn(),
|
||||
exit = shrinkVertically() + fadeOut()
|
||||
) {
|
||||
Column(modifier = Modifier.padding(top = 12.dp)) {
|
||||
Text(
|
||||
"Suggestions",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = TextMuted,
|
||||
modifier = Modifier.padding(bottom = 8.dp)
|
||||
)
|
||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
items(tagSuggestions.take(10)) { tag ->
|
||||
TagChip(
|
||||
tag = tag.name,
|
||||
isSelected = false,
|
||||
onClick = { viewModel.addTag(tag.name) },
|
||||
count = tag.occurrences
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Popular tags from existing
|
||||
if (tagSuggestions.isEmpty() && availableTags.isNotEmpty()) {
|
||||
Column(modifier = Modifier.padding(top = 12.dp)) {
|
||||
Text(
|
||||
"Popular tags",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = TextMuted,
|
||||
modifier = Modifier.padding(bottom = 8.dp)
|
||||
)
|
||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
items(
|
||||
availableTags
|
||||
.filter { it.name !in selectedTags }
|
||||
.take(10)
|
||||
) { tag ->
|
||||
TagChip(
|
||||
tag = tag.name,
|
||||
isSelected = false,
|
||||
onClick = { viewModel.addTag(tag.name) },
|
||||
count = tag.occurrences
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Privacy Section
|
||||
GlassCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column {
|
||||
Text(
|
||||
"Private",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = TextPrimary
|
||||
)
|
||||
Text(
|
||||
"Only you can see this link",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = TextSecondary
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = isPrivate,
|
||||
onCheckedChange = { viewModel.isPrivate.value = it },
|
||||
colors = SwitchDefaults.colors(
|
||||
checkedThumbColor = CyanPrimary,
|
||||
checkedTrackColor = CyanPrimary.copy(alpha = 0.3f),
|
||||
uncheckedThumbColor = TextMuted,
|
||||
uncheckedTrackColor = SurfaceVariant
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
// Update Button
|
||||
GradientButton(
|
||||
text = if (uiState is EditLinkUiState.Saving) "Saving..." else "Update Link",
|
||||
onClick = { viewModel.updateLink() },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = url.isNotBlank() && uiState !is EditLinkUiState.Saving
|
||||
)
|
||||
|
||||
if (uiState is EditLinkUiState.Saving) {
|
||||
LinearProgressIndicator(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = CyanPrimary,
|
||||
trackColor = SurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package com.shaarit.presentation.edit
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.shaarit.domain.model.ShaarliTag
|
||||
import com.shaarit.domain.repository.LinkRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@HiltViewModel
|
||||
class EditLinkViewModel
|
||||
@Inject
|
||||
constructor(
|
||||
private val linkRepository: LinkRepository,
|
||||
savedStateHandle: SavedStateHandle
|
||||
) : ViewModel() {
|
||||
|
||||
private val linkId: Int = savedStateHandle["linkId"] ?: -1
|
||||
|
||||
private val _uiState = MutableStateFlow<EditLinkUiState>(EditLinkUiState.Loading)
|
||||
val uiState = _uiState.asStateFlow()
|
||||
|
||||
var url = MutableStateFlow("")
|
||||
var title = MutableStateFlow("")
|
||||
var description = MutableStateFlow("")
|
||||
var isPrivate = MutableStateFlow(false)
|
||||
|
||||
private val _selectedTags = MutableStateFlow<List<String>>(emptyList())
|
||||
val selectedTags = _selectedTags.asStateFlow()
|
||||
|
||||
private val _newTagInput = MutableStateFlow("")
|
||||
val newTagInput = _newTagInput.asStateFlow()
|
||||
|
||||
private val _availableTags = MutableStateFlow<List<ShaarliTag>>(emptyList())
|
||||
val availableTags = _availableTags.asStateFlow()
|
||||
|
||||
private val _tagSuggestions = MutableStateFlow<List<ShaarliTag>>(emptyList())
|
||||
val tagSuggestions = _tagSuggestions.asStateFlow()
|
||||
|
||||
init {
|
||||
loadLink()
|
||||
loadAvailableTags()
|
||||
}
|
||||
|
||||
private fun loadLink() {
|
||||
viewModelScope.launch {
|
||||
_uiState.value = EditLinkUiState.Loading
|
||||
|
||||
linkRepository.getLink(linkId).fold(
|
||||
onSuccess = { link ->
|
||||
url.value = link.url
|
||||
title.value = link.title
|
||||
description.value = link.description
|
||||
isPrivate.value = link.isPrivate
|
||||
_selectedTags.value = link.tags
|
||||
_uiState.value = EditLinkUiState.Loaded
|
||||
},
|
||||
onFailure = { error ->
|
||||
_uiState.value = EditLinkUiState.Error(error.message ?: "Failed to load link")
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadAvailableTags() {
|
||||
viewModelScope.launch {
|
||||
linkRepository
|
||||
.getTags()
|
||||
.fold(
|
||||
onSuccess = { tags ->
|
||||
_availableTags.value = tags.sortedByDescending { it.occurrences }
|
||||
},
|
||||
onFailure = {
|
||||
// Silently fail - tags are optional
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun onNewTagInputChanged(input: String) {
|
||||
_newTagInput.value = input
|
||||
updateTagSuggestions(input)
|
||||
}
|
||||
|
||||
private fun updateTagSuggestions(query: String) {
|
||||
if (query.isBlank()) {
|
||||
_tagSuggestions.value = emptyList()
|
||||
return
|
||||
}
|
||||
|
||||
val queryLower = query.lowercase()
|
||||
_tagSuggestions.value =
|
||||
_availableTags
|
||||
.value
|
||||
.filter {
|
||||
it.name.lowercase().contains(queryLower) &&
|
||||
it.name !in _selectedTags.value
|
||||
}
|
||||
.take(10)
|
||||
}
|
||||
|
||||
fun addTag(tag: String) {
|
||||
val cleanTag = tag.trim().lowercase()
|
||||
if (cleanTag.isNotBlank() && cleanTag !in _selectedTags.value) {
|
||||
_selectedTags.value = _selectedTags.value + cleanTag
|
||||
_newTagInput.value = ""
|
||||
_tagSuggestions.value = emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
fun addNewTag() {
|
||||
addTag(_newTagInput.value)
|
||||
}
|
||||
|
||||
fun removeTag(tag: String) {
|
||||
_selectedTags.value = _selectedTags.value - tag
|
||||
}
|
||||
|
||||
fun updateLink() {
|
||||
viewModelScope.launch {
|
||||
_uiState.value = EditLinkUiState.Saving
|
||||
|
||||
val currentUrl = url.value
|
||||
if (currentUrl.isBlank()) {
|
||||
_uiState.value = EditLinkUiState.Error("URL is required")
|
||||
return@launch
|
||||
}
|
||||
|
||||
linkRepository.updateLink(
|
||||
id = linkId,
|
||||
url = currentUrl,
|
||||
title = title.value.ifBlank { null },
|
||||
description = description.value.ifBlank { null },
|
||||
tags = _selectedTags.value.ifEmpty { null },
|
||||
isPrivate = isPrivate.value
|
||||
).fold(
|
||||
onSuccess = {
|
||||
_uiState.value = EditLinkUiState.Success
|
||||
},
|
||||
onFailure = { error ->
|
||||
_uiState.value = EditLinkUiState.Error(error.message ?: "Failed to update link")
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sealed class EditLinkUiState {
|
||||
object Loading : EditLinkUiState()
|
||||
object Loaded : EditLinkUiState()
|
||||
object Saving : EditLinkUiState()
|
||||
object Success : EditLinkUiState()
|
||||
data class Error(val message: String) : EditLinkUiState()
|
||||
}
|
||||
@@ -6,195 +6,297 @@ import androidx.compose.animation.*
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.material.ExperimentalMaterialApi
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material.pullrefresh.PullRefreshIndicator
|
||||
import androidx.compose.material.pullrefresh.pullRefresh
|
||||
import androidx.compose.material.pullrefresh.rememberPullRefreshState
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
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 androidx.paging.LoadState
|
||||
import androidx.paging.compose.collectAsLazyPagingItems
|
||||
import com.shaarit.domain.model.ShaarliLink
|
||||
import com.shaarit.ui.components.GlassCard
|
||||
import com.shaarit.domain.model.ViewStyle
|
||||
import com.shaarit.ui.components.PremiumTextField
|
||||
import com.shaarit.ui.components.TagChip
|
||||
import com.shaarit.ui.theme.*
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterialApi::class)
|
||||
@Composable
|
||||
fun FeedScreen(
|
||||
onNavigateToAdd: () -> Unit,
|
||||
onNavigateToTags: () -> Unit = {},
|
||||
initialTagFilter: String? = null,
|
||||
viewModel: FeedViewModel = hiltViewModel()
|
||||
onNavigateToAdd: () -> Unit,
|
||||
onNavigateToEdit: (Int) -> Unit = {},
|
||||
onNavigateToTags: () -> Unit = {},
|
||||
initialTagFilter: String? = null,
|
||||
viewModel: FeedViewModel = hiltViewModel()
|
||||
) {
|
||||
val pagingItems = viewModel.pagedLinks.collectAsLazyPagingItems()
|
||||
val searchQuery by viewModel.searchQuery.collectAsState()
|
||||
val searchTags by viewModel.searchTags.collectAsState()
|
||||
val viewStyle by viewModel.viewStyle.collectAsState()
|
||||
|
||||
val context = LocalContext.current
|
||||
var showViewStyleMenu by remember { mutableStateOf(false) }
|
||||
var selectedLink by remember { mutableStateOf<com.shaarit.domain.model.ShaarliLink?>(null) }
|
||||
|
||||
// Set initial tag filter
|
||||
LaunchedEffect(initialTagFilter) { viewModel.setInitialTagFilter(initialTagFilter) }
|
||||
|
||||
val pullRefreshState = rememberPullRefreshState(
|
||||
refreshing = pagingItems.loadState.refresh is LoadState.Loading,
|
||||
onRefresh = { pagingItems.refresh() }
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.fillMaxSize()
|
||||
.background(
|
||||
brush =
|
||||
Brush.verticalGradient(
|
||||
colors = listOf(DeepNavy, DarkNavy)
|
||||
)
|
||||
)
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
brush = Brush.verticalGradient(
|
||||
colors = listOf(DeepNavy, DarkNavy)
|
||||
)
|
||||
)
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
Column {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
"ShaarIt",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = TextPrimary
|
||||
)
|
||||
},
|
||||
actions = {
|
||||
// Tags button - using # symbol which represents tags
|
||||
TextButton(onClick = onNavigateToTags) {
|
||||
Text(
|
||||
text = "#",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = TealSecondary
|
||||
)
|
||||
}
|
||||
},
|
||||
colors =
|
||||
TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = DeepNavy.copy(alpha = 0.9f),
|
||||
titleContentColor = TextPrimary
|
||||
)
|
||||
)
|
||||
|
||||
// Search Bar or Tag Filter
|
||||
AnimatedContent(
|
||||
targetState = searchTags != null,
|
||||
transitionSpec = {
|
||||
fadeIn() + slideInVertically() togetherWith
|
||||
fadeOut() + slideOutVertically()
|
||||
}
|
||||
) { hasTagFilter ->
|
||||
if (hasTagFilter && searchTags != null) {
|
||||
// Tag filter chip
|
||||
Row(
|
||||
modifier =
|
||||
Modifier.fillMaxWidth()
|
||||
.background(DarkNavy)
|
||||
.padding(
|
||||
horizontal = 16.dp,
|
||||
vertical = 12.dp
|
||||
),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text(
|
||||
"Filtering by:",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = TextSecondary
|
||||
)
|
||||
TagChip(
|
||||
tag = searchTags!!,
|
||||
isSelected = true,
|
||||
onClick = { viewModel.clearTagFilter() }
|
||||
)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
IconButton(onClick = { viewModel.clearTagFilter() }) {
|
||||
Icon(
|
||||
Icons.Default.Close,
|
||||
contentDescription = "Clear filter",
|
||||
tint = TextMuted,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Search Bar
|
||||
PremiumTextField(
|
||||
value = searchQuery,
|
||||
onValueChange = viewModel::onSearchQueryChanged,
|
||||
modifier =
|
||||
Modifier.fillMaxWidth()
|
||||
.padding(
|
||||
horizontal = 16.dp,
|
||||
vertical = 8.dp
|
||||
),
|
||||
placeholder = "Search links...",
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
Icons.Default.Search,
|
||||
contentDescription = null,
|
||||
tint = TextMuted
|
||||
)
|
||||
},
|
||||
trailingIcon = {
|
||||
if (searchQuery.isNotEmpty()) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
viewModel.onSearchQueryChanged("")
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Close,
|
||||
contentDescription = "Clear",
|
||||
tint = TextMuted
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
topBar = {
|
||||
Column {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
"ShaarIt",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = TextPrimary
|
||||
)
|
||||
},
|
||||
actions = {
|
||||
// Refresh Button
|
||||
IconButton(onClick = { pagingItems.refresh() }) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Refresh,
|
||||
contentDescription = "Refresh",
|
||||
tint = CyanPrimary
|
||||
)
|
||||
}
|
||||
|
||||
// View Style Selector
|
||||
Box {
|
||||
IconButton(onClick = { showViewStyleMenu = true }) {
|
||||
Icon(
|
||||
imageVector = when (viewStyle) {
|
||||
ViewStyle.LIST -> Icons.Default.ViewStream
|
||||
ViewStyle.GRID -> Icons.Default.ViewModule
|
||||
ViewStyle.COMPACT -> Icons.Default.ViewList
|
||||
},
|
||||
contentDescription = "View style",
|
||||
tint = CyanPrimary
|
||||
)
|
||||
}
|
||||
|
||||
DropdownMenu(
|
||||
expanded = showViewStyleMenu,
|
||||
onDismissRequest = { showViewStyleMenu = false },
|
||||
modifier = Modifier.background(CardBackground)
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.ViewStream,
|
||||
contentDescription = null,
|
||||
tint = if (viewStyle == ViewStyle.LIST) CyanPrimary else TextSecondary
|
||||
)
|
||||
Text(
|
||||
"List View",
|
||||
color = if (viewStyle == ViewStyle.LIST) CyanPrimary else TextPrimary
|
||||
)
|
||||
}
|
||||
},
|
||||
onClick = {
|
||||
viewModel.setViewStyle(ViewStyle.LIST)
|
||||
showViewStyleMenu = false
|
||||
}
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.ViewModule,
|
||||
contentDescription = null,
|
||||
tint = if (viewStyle == ViewStyle.GRID) CyanPrimary else TextSecondary
|
||||
)
|
||||
Text(
|
||||
"Grid View",
|
||||
color = if (viewStyle == ViewStyle.GRID) CyanPrimary else TextPrimary
|
||||
)
|
||||
}
|
||||
},
|
||||
onClick = {
|
||||
viewModel.setViewStyle(ViewStyle.GRID)
|
||||
showViewStyleMenu = false
|
||||
}
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.ViewList,
|
||||
contentDescription = null,
|
||||
tint = if (viewStyle == ViewStyle.COMPACT) CyanPrimary else TextSecondary
|
||||
)
|
||||
Text(
|
||||
"Compact View",
|
||||
color = if (viewStyle == ViewStyle.COMPACT) CyanPrimary else TextPrimary
|
||||
)
|
||||
}
|
||||
},
|
||||
onClick = {
|
||||
viewModel.setViewStyle(ViewStyle.COMPACT)
|
||||
showViewStyleMenu = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Tags button
|
||||
TextButton(onClick = onNavigateToTags) {
|
||||
Text(
|
||||
text = "#",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = TealSecondary
|
||||
)
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = DeepNavy.copy(alpha = 0.9f),
|
||||
titleContentColor = TextPrimary
|
||||
)
|
||||
)
|
||||
|
||||
// Search Bar or Tag Filter
|
||||
AnimatedContent(
|
||||
targetState = searchTags != null,
|
||||
transitionSpec = {
|
||||
fadeIn() + slideInVertically() togetherWith
|
||||
fadeOut() + slideOutVertically()
|
||||
}
|
||||
) { hasTagFilter ->
|
||||
if (hasTagFilter && searchTags != null) {
|
||||
// Tag filter chip
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(DarkNavy)
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text(
|
||||
"Filtering by:",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = TextSecondary
|
||||
)
|
||||
TagChip(
|
||||
tag = searchTags!!,
|
||||
isSelected = true,
|
||||
onClick = { viewModel.clearTagFilter() }
|
||||
)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
IconButton(onClick = { viewModel.clearTagFilter() }) {
|
||||
Icon(
|
||||
Icons.Default.Close,
|
||||
contentDescription = "Clear filter",
|
||||
tint = TextMuted,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Search Bar
|
||||
PremiumTextField(
|
||||
value = searchQuery,
|
||||
onValueChange = viewModel::onSearchQueryChanged,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
placeholder = "Search links...",
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
Icons.Default.Search,
|
||||
contentDescription = null,
|
||||
tint = TextMuted
|
||||
)
|
||||
},
|
||||
trailingIcon = {
|
||||
if (searchQuery.isNotEmpty()) {
|
||||
IconButton(
|
||||
onClick = { viewModel.onSearchQueryChanged("") }
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Close,
|
||||
contentDescription = "Clear",
|
||||
tint = TextMuted
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
floatingActionButton = {
|
||||
FloatingActionButton(
|
||||
onClick = onNavigateToAdd,
|
||||
containerColor = CyanPrimary,
|
||||
contentColor = DeepNavy
|
||||
) { Icon(Icons.Default.Add, contentDescription = "Add Link") }
|
||||
},
|
||||
containerColor = androidx.compose.ui.graphics.Color.Transparent
|
||||
}
|
||||
},
|
||||
floatingActionButton = {
|
||||
FloatingActionButton(
|
||||
onClick = onNavigateToAdd,
|
||||
containerColor = CyanPrimary,
|
||||
contentColor = DeepNavy
|
||||
) { Icon(Icons.Default.Add, contentDescription = "Add Link") }
|
||||
},
|
||||
containerColor = Color.Transparent
|
||||
) { paddingValues ->
|
||||
Column(modifier = Modifier.padding(paddingValues)) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(paddingValues)
|
||||
.fillMaxSize()
|
||||
.pullRefresh(pullRefreshState)
|
||||
) {
|
||||
when {
|
||||
pagingItems.loadState.refresh is LoadState.Loading -> {
|
||||
pagingItems.loadState.refresh is LoadState.Loading && pagingItems.itemCount == 0 -> {
|
||||
// Initial loading
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) { CircularProgressIndicator(color = CyanPrimary) }
|
||||
}
|
||||
pagingItems.loadState.refresh is LoadState.Error -> {
|
||||
pagingItems.loadState.refresh is LoadState.Error && pagingItems.itemCount == 0 -> {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
"Failed to load links",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = ErrorRed
|
||||
"Failed to load links",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = ErrorRed
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
TextButton(onClick = { pagingItems.refresh() }) {
|
||||
@@ -205,194 +307,168 @@ fun FeedScreen(
|
||||
}
|
||||
pagingItems.itemCount == 0 -> {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
if (searchQuery.isNotBlank() || searchTags != null)
|
||||
"No links found"
|
||||
else "No links yet",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = TextSecondary
|
||||
if (searchQuery.isNotBlank() || searchTags != null)
|
||||
"No links found"
|
||||
else "No links yet",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = TextSecondary
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
if (searchQuery.isNotBlank() || searchTags != null)
|
||||
"Try a different search"
|
||||
else "Add your first link!",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = TextMuted
|
||||
if (searchQuery.isNotBlank() || searchTags != null)
|
||||
"Try a different search"
|
||||
else "Add your first link!",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = TextMuted
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
items(count = pagingItems.itemCount) { index ->
|
||||
val link = pagingItems[index]
|
||||
if (link != null) {
|
||||
LinkItem(
|
||||
link = link,
|
||||
onTagClick = viewModel::onTagClicked,
|
||||
onLinkClick = { url ->
|
||||
val intent =
|
||||
Intent(Intent.ACTION_VIEW, Uri.parse(url))
|
||||
context.startActivity(intent)
|
||||
},
|
||||
onDeleteClick = { viewModel.deleteLink(link.id) }
|
||||
)
|
||||
when (viewStyle) {
|
||||
ViewStyle.GRID -> {
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(2),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
items(count = pagingItems.itemCount) { index ->
|
||||
val link = pagingItems[index]
|
||||
if (link != null) {
|
||||
GridViewItem(
|
||||
link = link,
|
||||
onLinkClick = { url ->
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
|
||||
context.startActivity(intent)
|
||||
},
|
||||
onViewClick = { selectedLink = link },
|
||||
onEditClick = onNavigateToEdit,
|
||||
onDeleteClick = { viewModel.deleteLink(link.id) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (pagingItems.loadState.append is LoadState.Loading) {
|
||||
item {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
color = CyanPrimary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ViewStyle.COMPACT -> {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
items(count = pagingItems.itemCount) { index ->
|
||||
val link = pagingItems[index]
|
||||
if (link != null) {
|
||||
CompactViewItem(
|
||||
link = link,
|
||||
onLinkClick = { url ->
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
|
||||
context.startActivity(intent)
|
||||
},
|
||||
onViewClick = { selectedLink = link },
|
||||
onEditClick = onNavigateToEdit,
|
||||
onDeleteClick = { viewModel.deleteLink(link.id) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (pagingItems.loadState.append is LoadState.Loading) {
|
||||
item {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
color = CyanPrimary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ViewStyle.LIST -> {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
items(count = pagingItems.itemCount) { index ->
|
||||
val link = pagingItems[index]
|
||||
if (link != null) {
|
||||
ListViewItem(
|
||||
link = link,
|
||||
onTagClick = viewModel::onTagClicked,
|
||||
onLinkClick = { url ->
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
|
||||
context.startActivity(intent)
|
||||
},
|
||||
onViewClick = { selectedLink = link },
|
||||
onEditClick = onNavigateToEdit,
|
||||
onDeleteClick = { viewModel.deleteLink(link.id) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (pagingItems.loadState.append is LoadState.Loading) {
|
||||
item {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
color = CyanPrimary
|
||||
)
|
||||
if (pagingItems.loadState.append is LoadState.Loading) {
|
||||
item {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
color = CyanPrimary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pull refresh indicator
|
||||
PullRefreshIndicator(
|
||||
refreshing = pagingItems.loadState.refresh is LoadState.Loading,
|
||||
state = pullRefreshState,
|
||||
modifier = Modifier.align(Alignment.TopCenter),
|
||||
backgroundColor = DarkNavy,
|
||||
contentColor = CyanPrimary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun LinkItem(
|
||||
link: ShaarliLink,
|
||||
onTagClick: (String) -> Unit,
|
||||
onLinkClick: (String) -> Unit,
|
||||
onDeleteClick: () -> Unit
|
||||
) {
|
||||
var showDeleteDialog by remember { mutableStateOf(false) }
|
||||
|
||||
if (showDeleteDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showDeleteDialog = false },
|
||||
title = { Text("Delete Link", fontWeight = FontWeight.Bold, color = TextPrimary) },
|
||||
text = {
|
||||
Column {
|
||||
Text("Are you sure you want to delete this link?", color = TextSecondary)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
link.title,
|
||||
color = CyanPrimary,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
onDeleteClick()
|
||||
showDeleteDialog = false
|
||||
}
|
||||
) { Text("Delete", color = ErrorRed) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showDeleteDialog = false }) {
|
||||
Text("Cancel", color = TextMuted)
|
||||
}
|
||||
},
|
||||
containerColor = CardBackground,
|
||||
titleContentColor = TextPrimary,
|
||||
textContentColor = TextSecondary
|
||||
)
|
||||
}
|
||||
|
||||
GlassCard(modifier = Modifier.fillMaxWidth(), onClick = { onLinkClick(link.url) }) {
|
||||
Column {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.Top
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = link.title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = CyanPrimary,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = link.url,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = TealSecondary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
|
||||
IconButton(onClick = { showDeleteDialog = true }, modifier = Modifier.size(32.dp)) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Delete,
|
||||
contentDescription = "Delete",
|
||||
tint = ErrorRed.copy(alpha = 0.7f),
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (link.description.isNotBlank()) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = link.description,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = TextSecondary,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
|
||||
if (link.tags.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
items(link.tags) { tag ->
|
||||
TagChip(tag = tag, isSelected = false, onClick = { onTagClick(tag) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = link.date,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = TextMuted
|
||||
)
|
||||
|
||||
if (link.isPrivate) {
|
||||
Text(
|
||||
text = "🔒 Private",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = TextMuted
|
||||
)
|
||||
if (selectedLink != null) {
|
||||
LinkDetailsDialog(
|
||||
link = selectedLink!!,
|
||||
onDismiss = { selectedLink = null },
|
||||
onLinkClick = { url ->
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
|
||||
context.startActivity(intent)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope
|
||||
import androidx.paging.PagingData
|
||||
import androidx.paging.cachedIn
|
||||
import com.shaarit.domain.model.ShaarliLink
|
||||
import com.shaarit.domain.model.ViewStyle
|
||||
import com.shaarit.domain.repository.LinkRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import javax.inject.Inject
|
||||
@@ -27,6 +28,9 @@ class FeedViewModel @Inject constructor(private val linkRepository: LinkReposito
|
||||
private val _searchTags = MutableStateFlow<String?>(null)
|
||||
val searchTags = _searchTags.asStateFlow()
|
||||
|
||||
private val _viewStyle = MutableStateFlow(ViewStyle.LIST)
|
||||
val viewStyle = _viewStyle.asStateFlow()
|
||||
|
||||
private val _refreshTrigger = MutableStateFlow(0)
|
||||
|
||||
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
|
||||
@@ -48,13 +52,15 @@ class FeedViewModel @Inject constructor(private val linkRepository: LinkReposito
|
||||
}
|
||||
|
||||
fun onTagClicked(tag: String) {
|
||||
// Toggle or set? User said "n'afficher que les liens de ce tag"
|
||||
// If clicking same tag, maybe clear?
|
||||
if (_searchTags.value == tag) {
|
||||
_searchTags.value = null
|
||||
val currentTags = _searchTags.value?.split(" ")?.filter { it.isNotBlank() }?.toMutableSet() ?: mutableSetOf()
|
||||
|
||||
if (currentTags.contains(tag)) {
|
||||
currentTags.remove(tag)
|
||||
} else {
|
||||
_searchTags.value = tag
|
||||
currentTags.add(tag)
|
||||
}
|
||||
|
||||
_searchTags.value = if (currentTags.isEmpty()) null else currentTags.joinToString(" ")
|
||||
}
|
||||
|
||||
fun setInitialTagFilter(tag: String?) {
|
||||
@@ -78,4 +84,8 @@ class FeedViewModel @Inject constructor(private val linkRepository: LinkReposito
|
||||
fun refresh() {
|
||||
_refreshTrigger.value++
|
||||
}
|
||||
|
||||
fun setViewStyle(style: ViewStyle) {
|
||||
_viewStyle.value = style
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,617 @@
|
||||
package com.shaarit.presentation.feed
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
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.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.shaarit.domain.model.ShaarliLink
|
||||
import com.shaarit.ui.components.GlassCard
|
||||
import com.shaarit.ui.components.TagChip
|
||||
import com.shaarit.ui.theme.*
|
||||
import dev.jeziellago.compose.markdowntext.MarkdownText
|
||||
|
||||
/**
|
||||
* Full list view item - shows all details including markdown description
|
||||
*/
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun ListViewItem(
|
||||
link: ShaarliLink,
|
||||
onTagClick: (String) -> Unit,
|
||||
onLinkClick: (String) -> Unit,
|
||||
onViewClick: () -> Unit,
|
||||
onEditClick: (Int) -> Unit,
|
||||
onDeleteClick: () -> Unit
|
||||
) {
|
||||
var showDeleteDialog by remember { mutableStateOf(false) }
|
||||
|
||||
if (showDeleteDialog) {
|
||||
DeleteConfirmationDialog(
|
||||
linkTitle = link.title,
|
||||
onConfirm = {
|
||||
onDeleteClick()
|
||||
showDeleteDialog = false
|
||||
},
|
||||
onDismiss = { showDeleteDialog = false }
|
||||
)
|
||||
}
|
||||
|
||||
GlassCard(modifier = Modifier.fillMaxWidth(), onClick = { onLinkClick(link.url) }) {
|
||||
Column {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.Top
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = link.title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = CyanPrimary,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = link.url,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = TealSecondary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
|
||||
Row {
|
||||
IconButton(onClick = onViewClick, modifier = Modifier.size(32.dp)) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Visibility,
|
||||
contentDescription = "View Details",
|
||||
tint = CyanPrimary.copy(alpha = 0.7f),
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { onEditClick(link.id) }, modifier = Modifier.size(32.dp)) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Edit,
|
||||
contentDescription = "Edit",
|
||||
tint = TealSecondary.copy(alpha = 0.7f),
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { showDeleteDialog = true }, modifier = Modifier.size(32.dp)) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Delete,
|
||||
contentDescription = "Delete",
|
||||
tint = ErrorRed.copy(alpha = 0.7f),
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (link.description.isNotBlank()) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
MarkdownText(
|
||||
markdown = link.description,
|
||||
style = MaterialTheme.typography.bodyMedium.copy(color = TextSecondary),
|
||||
maxLines = 5,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
|
||||
if (link.tags.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
items(link.tags) { tag ->
|
||||
TagChip(tag = tag, isSelected = false, onClick = { onTagClick(tag) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = link.date,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = TextMuted
|
||||
)
|
||||
|
||||
if (link.isPrivate) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Lock,
|
||||
contentDescription = "Private",
|
||||
tint = TextMuted,
|
||||
modifier = Modifier.size(12.dp)
|
||||
)
|
||||
Text(
|
||||
text = "Private",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = TextMuted
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Grid view item - compact cards in a 2-column grid
|
||||
*/
|
||||
@Composable
|
||||
fun GridViewItem(
|
||||
link: ShaarliLink,
|
||||
onLinkClick: (String) -> Unit,
|
||||
onViewClick: () -> Unit,
|
||||
onEditClick: (Int) -> Unit,
|
||||
onDeleteClick: () -> Unit
|
||||
) {
|
||||
var showDeleteDialog by remember { mutableStateOf(false) }
|
||||
|
||||
if (showDeleteDialog) {
|
||||
DeleteConfirmationDialog(
|
||||
linkTitle = link.title,
|
||||
onConfirm = {
|
||||
onDeleteClick()
|
||||
showDeleteDialog = false
|
||||
},
|
||||
onDismiss = { showDeleteDialog = false }
|
||||
)
|
||||
}
|
||||
|
||||
GlassCard(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(200.dp),
|
||||
onClick = { onLinkClick(link.url) }
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Column {
|
||||
// Title
|
||||
Text(
|
||||
text = link.title,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = CyanPrimary,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
// Description with Markdown
|
||||
if (link.description.isNotBlank()) {
|
||||
MarkdownText(
|
||||
markdown = link.description,
|
||||
style = MaterialTheme.typography.bodySmall.copy(color = TextSecondary),
|
||||
maxLines = 3,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
// Tags (show only first 2)
|
||||
if (link.tags.isNotEmpty()) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = Modifier.padding(bottom = 8.dp)
|
||||
) {
|
||||
link.tags.take(2).forEach { tag ->
|
||||
Text(
|
||||
text = "#$tag",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = TealSecondary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
if (link.tags.size > 2) {
|
||||
Text(
|
||||
text = "+${link.tags.size - 2}",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = TextMuted
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Actions row
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
if (link.isPrivate) {
|
||||
Icon(
|
||||
Icons.Default.Lock,
|
||||
contentDescription = "Private",
|
||||
tint = TextMuted,
|
||||
modifier = Modifier.size(12.dp)
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = link.date.take(10),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = TextMuted
|
||||
)
|
||||
}
|
||||
|
||||
Row {
|
||||
IconButton(
|
||||
onClick = onViewClick,
|
||||
modifier = Modifier.size(24.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Visibility,
|
||||
contentDescription = "View Details",
|
||||
tint = CyanPrimary.copy(alpha = 0.7f),
|
||||
modifier = Modifier.size(14.dp)
|
||||
)
|
||||
}
|
||||
IconButton(
|
||||
onClick = { onEditClick(link.id) },
|
||||
modifier = Modifier.size(24.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Edit,
|
||||
contentDescription = "Edit",
|
||||
tint = TealSecondary.copy(alpha = 0.7f),
|
||||
modifier = Modifier.size(14.dp)
|
||||
)
|
||||
}
|
||||
IconButton(
|
||||
onClick = { showDeleteDialog = true },
|
||||
modifier = Modifier.size(24.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Delete,
|
||||
contentDescription = "Delete",
|
||||
tint = ErrorRed.copy(alpha = 0.7f),
|
||||
modifier = Modifier.size(14.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact view item - minimal info for dense lists
|
||||
*/
|
||||
@Composable
|
||||
fun CompactViewItem(
|
||||
link: ShaarliLink,
|
||||
onLinkClick: (String) -> Unit,
|
||||
onViewClick: () -> Unit,
|
||||
onEditClick: (Int) -> Unit,
|
||||
onDeleteClick: () -> Unit
|
||||
) {
|
||||
var showDeleteDialog by remember { mutableStateOf(false) }
|
||||
|
||||
if (showDeleteDialog) {
|
||||
DeleteConfirmationDialog(
|
||||
linkTitle = link.title,
|
||||
onConfirm = {
|
||||
onDeleteClick()
|
||||
showDeleteDialog = false
|
||||
},
|
||||
onDismiss = { showDeleteDialog = false }
|
||||
)
|
||||
}
|
||||
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.clickable { onLinkClick(link.url) },
|
||||
color = CardBackground.copy(alpha = 0.7f)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
if (link.isPrivate) {
|
||||
Icon(
|
||||
Icons.Default.Lock,
|
||||
contentDescription = "Private",
|
||||
tint = TextMuted,
|
||||
modifier = Modifier.size(14.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = link.title,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = CyanPrimary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = link.date.take(10),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = TextMuted
|
||||
)
|
||||
|
||||
if (link.tags.isNotEmpty()) {
|
||||
Text(
|
||||
text = link.tags.take(2).joinToString { "#$it" },
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = TealSecondary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row {
|
||||
IconButton(onClick = onViewClick, modifier = Modifier.size(28.dp)) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Visibility,
|
||||
contentDescription = "View Details",
|
||||
tint = CyanPrimary.copy(alpha = 0.7f),
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { onEditClick(link.id) }, modifier = Modifier.size(28.dp)) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Edit,
|
||||
contentDescription = "Edit",
|
||||
tint = TealSecondary.copy(alpha = 0.7f),
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { showDeleteDialog = true }, modifier = Modifier.size(28.dp)) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Delete,
|
||||
contentDescription = "Delete",
|
||||
tint = ErrorRed.copy(alpha = 0.7f),
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable delete confirmation dialog
|
||||
*/
|
||||
@Composable
|
||||
fun DeleteConfirmationDialog(
|
||||
linkTitle: String,
|
||||
onConfirm: () -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Delete Link", fontWeight = FontWeight.Bold, color = TextPrimary) },
|
||||
text = {
|
||||
Column {
|
||||
Text("Are you sure you want to delete this link?", color = TextSecondary)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
linkTitle,
|
||||
color = CyanPrimary,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onConfirm) {
|
||||
Text("Delete", color = ErrorRed)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Cancel", color = TextMuted)
|
||||
}
|
||||
},
|
||||
containerColor = CardBackground,
|
||||
titleContentColor = TextPrimary,
|
||||
textContentColor = TextSecondary
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Dialog to show full link details
|
||||
*/
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun LinkDetailsDialog(
|
||||
link: ShaarliLink,
|
||||
onDismiss: () -> Unit,
|
||||
onLinkClick: (String) -> Unit
|
||||
) {
|
||||
androidx.compose.ui.window.Dialog(
|
||||
onDismissRequest = onDismiss,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false)
|
||||
) {
|
||||
GlassCard(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(24.dp)
|
||||
.fillMaxSize()
|
||||
) {
|
||||
// Header
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.Top
|
||||
) {
|
||||
Text(
|
||||
text = link.title,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = CyanPrimary,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
IconButton(
|
||||
onClick = onDismiss,
|
||||
modifier = Modifier
|
||||
.size(32.dp)
|
||||
.offset(x = 8.dp, y = (-8).dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = "Close",
|
||||
tint = TextSecondary
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Scrollable Content
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.verticalScroll(androidx.compose.foundation.rememberScrollState())
|
||||
) {
|
||||
// URL
|
||||
Text(
|
||||
text = link.url,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = TealSecondary,
|
||||
modifier = Modifier
|
||||
.clickable { onLinkClick(link.url) }
|
||||
.padding(vertical = 4.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Tags
|
||||
if (link.tags.isNotEmpty()) {
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
link.tags.forEach { tag ->
|
||||
TagChip(
|
||||
tag = tag,
|
||||
isSelected = false,
|
||||
onClick = { /* No action in dialog */ }
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
}
|
||||
|
||||
// Metadata
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = link.date,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = TextMuted
|
||||
)
|
||||
if (link.isPrivate) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Lock,
|
||||
contentDescription = "Private",
|
||||
tint = TextMuted,
|
||||
modifier = Modifier.size(14.dp)
|
||||
)
|
||||
Text(
|
||||
text = "Private",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = TextMuted
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
Divider(color = TextMuted.copy(alpha = 0.2f))
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
// Description
|
||||
if (link.description.isNotBlank()) {
|
||||
MarkdownText(
|
||||
markdown = link.description,
|
||||
style = MaterialTheme.typography.bodyMedium.copy(color = TextPrimary),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = "No description",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = TextMuted,
|
||||
fontStyle = androidx.compose.ui.text.font.FontStyle.Italic
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,100 +18,115 @@ sealed class Screen(val route: String) {
|
||||
}
|
||||
}
|
||||
object Add : Screen("add?url={url}&title={title}&isShare={isShare}")
|
||||
object Edit : Screen("edit/{linkId}") {
|
||||
fun createRoute(linkId: Int): String = "edit/$linkId"
|
||||
}
|
||||
object Tags : Screen("tags")
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AppNavGraph(
|
||||
startDestination: String = Screen.Login.route,
|
||||
shareUrl: String? = null,
|
||||
shareTitle: String? = null
|
||||
startDestination: String = Screen.Login.route,
|
||||
shareUrl: String? = null,
|
||||
shareTitle: String? = null
|
||||
) {
|
||||
val navController = rememberNavController()
|
||||
val context = LocalContext.current
|
||||
val isShareIntent = shareUrl != null
|
||||
|
||||
NavHost(navController = navController, startDestination = startDestination) {
|
||||
composable(Screen.Login.route) {
|
||||
com.shaarit.presentation.auth.LoginScreen(
|
||||
onLoginSuccess = {
|
||||
if (shareUrl != null) {
|
||||
// Use proper URL encoding that handles spaces correctly
|
||||
val encodedUrl = URLEncoder.encode(shareUrl, "UTF-8")
|
||||
val encodedTitle =
|
||||
if (shareTitle != null) {
|
||||
URLEncoder.encode(shareTitle, "UTF-8")
|
||||
} else ""
|
||||
navController.navigate("add?url=$encodedUrl&title=$encodedTitle&isShare=true") {
|
||||
popUpTo(Screen.Login.route) { inclusive = true }
|
||||
}
|
||||
} else {
|
||||
navController.navigate(Screen.Feed.createRoute()) {
|
||||
popUpTo(Screen.Login.route) { inclusive = true }
|
||||
}
|
||||
onLoginSuccess = {
|
||||
if (shareUrl != null) {
|
||||
// Use proper URL encoding that handles spaces correctly
|
||||
val encodedUrl = URLEncoder.encode(shareUrl, "UTF-8")
|
||||
val encodedTitle =
|
||||
if (shareTitle != null) {
|
||||
URLEncoder.encode(shareTitle, "UTF-8")
|
||||
} else ""
|
||||
navController.navigate("add?url=$encodedUrl&title=$encodedTitle&isShare=true") {
|
||||
popUpTo(Screen.Login.route) { inclusive = true }
|
||||
}
|
||||
} else {
|
||||
navController.navigate(Screen.Feed.createRoute()) {
|
||||
popUpTo(Screen.Login.route) { inclusive = true }
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
composable(
|
||||
route = "feed?tag={tag}",
|
||||
arguments =
|
||||
listOf(
|
||||
navArgument("tag") {
|
||||
type = NavType.StringType
|
||||
nullable = true
|
||||
defaultValue = null
|
||||
}
|
||||
)
|
||||
route = "feed?tag={tag}",
|
||||
arguments = listOf(
|
||||
navArgument("tag") {
|
||||
type = NavType.StringType
|
||||
nullable = true
|
||||
defaultValue = null
|
||||
}
|
||||
)
|
||||
) { backStackEntry ->
|
||||
val tag = backStackEntry.arguments?.getString("tag")
|
||||
com.shaarit.presentation.feed.FeedScreen(
|
||||
onNavigateToAdd = { navController.navigate("add?url=&title=&isShare=false") },
|
||||
onNavigateToTags = { navController.navigate(Screen.Tags.route) },
|
||||
initialTagFilter = tag
|
||||
onNavigateToAdd = { navController.navigate("add?url=&title=&isShare=false") },
|
||||
onNavigateToEdit = { linkId ->
|
||||
navController.navigate(Screen.Edit.createRoute(linkId))
|
||||
},
|
||||
onNavigateToTags = { navController.navigate(Screen.Tags.route) },
|
||||
initialTagFilter = tag
|
||||
)
|
||||
}
|
||||
|
||||
composable(
|
||||
route = "add?url={url}&title={title}&isShare={isShare}",
|
||||
arguments =
|
||||
listOf(
|
||||
navArgument("url") {
|
||||
type = NavType.StringType
|
||||
defaultValue = ""
|
||||
nullable = true
|
||||
},
|
||||
navArgument("title") {
|
||||
type = NavType.StringType
|
||||
defaultValue = ""
|
||||
nullable = true
|
||||
},
|
||||
navArgument("isShare") {
|
||||
type = NavType.BoolType
|
||||
defaultValue = false
|
||||
}
|
||||
)
|
||||
route = "add?url={url}&title={title}&isShare={isShare}",
|
||||
arguments = listOf(
|
||||
navArgument("url") {
|
||||
type = NavType.StringType
|
||||
defaultValue = ""
|
||||
nullable = true
|
||||
},
|
||||
navArgument("title") {
|
||||
type = NavType.StringType
|
||||
defaultValue = ""
|
||||
nullable = true
|
||||
},
|
||||
navArgument("isShare") {
|
||||
type = NavType.BoolType
|
||||
defaultValue = false
|
||||
}
|
||||
)
|
||||
) { backStackEntry ->
|
||||
val isShare = backStackEntry.arguments?.getBoolean("isShare") ?: false
|
||||
com.shaarit.presentation.add.AddLinkScreen(
|
||||
onNavigateBack = { navController.popBackStack() },
|
||||
onShareSuccess = if (isShare) {
|
||||
{ (context as? Activity)?.finish() }
|
||||
} else null
|
||||
onNavigateBack = { navController.popBackStack() },
|
||||
onShareSuccess = if (isShare) {
|
||||
{ (context as? Activity)?.finish() }
|
||||
} else null
|
||||
)
|
||||
}
|
||||
|
||||
composable(
|
||||
route = "edit/{linkId}",
|
||||
arguments = listOf(
|
||||
navArgument("linkId") {
|
||||
type = NavType.IntType
|
||||
}
|
||||
)
|
||||
) {
|
||||
com.shaarit.presentation.edit.EditLinkScreen(
|
||||
onNavigateBack = { navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
|
||||
composable(Screen.Tags.route) {
|
||||
com.shaarit.presentation.tags.TagsScreen(
|
||||
onNavigateBack = { navController.popBackStack() },
|
||||
onNavigateToFeedWithTag = { tag ->
|
||||
navController.navigate(Screen.Feed.createRoute(tag)) {
|
||||
popUpTo(Screen.Tags.route) { inclusive = true }
|
||||
}
|
||||
onNavigateBack = { navController.popBackStack() },
|
||||
onNavigateToFeedWithTag = { tag ->
|
||||
navController.navigate(Screen.Feed.createRoute(tag)) {
|
||||
popUpTo(Screen.Tags.route) { inclusive = true }
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -40,21 +40,16 @@ fun TagsScreen(
|
||||
viewModel: TagsViewModel = hiltViewModel()
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
val selectedTag by viewModel.selectedTag.collectAsState()
|
||||
val tagLinks by viewModel.tagLinks.collectAsState()
|
||||
val isLoadingLinks by viewModel.isLoadingLinks.collectAsState()
|
||||
val searchQuery by viewModel.searchQuery.collectAsState()
|
||||
|
||||
val context = LocalContext.current
|
||||
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.fillMaxSize()
|
||||
.background(
|
||||
brush =
|
||||
Brush.verticalGradient(
|
||||
colors = listOf(DeepNavy, DarkNavy)
|
||||
)
|
||||
Brush.verticalGradient(
|
||||
colors = listOf(DeepNavy, DarkNavy)
|
||||
)
|
||||
)
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
@@ -130,24 +125,10 @@ fun TagsScreen(
|
||||
}
|
||||
is TagsUiState.Success -> {
|
||||
val filteredTags = viewModel.getFilteredTags()
|
||||
|
||||
if (selectedTag != null) {
|
||||
// Show links for selected tag
|
||||
TagLinksView(
|
||||
tag = selectedTag!!,
|
||||
links = tagLinks,
|
||||
isLoading = isLoadingLinks,
|
||||
onBack = { viewModel.clearTagSelection() },
|
||||
onLinkClick = { url ->
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
|
||||
context.startActivity(intent)
|
||||
},
|
||||
onViewInFeed = { onNavigateToFeedWithTag(selectedTag!!.name) }
|
||||
)
|
||||
} else {
|
||||
// Show tags grid
|
||||
TagsGridView(tags = filteredTags, onTagClick = viewModel::onTagSelected)
|
||||
}
|
||||
TagsGridView(
|
||||
tags = filteredTags,
|
||||
onTagClick = { tag -> onNavigateToFeedWithTag(tag.name) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -191,124 +172,3 @@ private fun TagGridItem(tag: ShaarliTag, onClick: () -> Unit) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun TagLinksView(
|
||||
tag: ShaarliTag,
|
||||
links: List<ShaarliLink>,
|
||||
isLoading: Boolean,
|
||||
onBack: () -> Unit,
|
||||
onLinkClick: (String) -> Unit,
|
||||
onViewInFeed: () -> Unit
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
// Tag header
|
||||
GlassCard(modifier = Modifier.fillMaxWidth().padding(16.dp), glowColor = CyanPrimary) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
Icons.Default.ArrowBack,
|
||||
contentDescription = "Back",
|
||||
tint = TextSecondary
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = "#${tag.name}",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = CyanPrimary
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = "${tag.occurrences} links",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = TextSecondary,
|
||||
modifier = Modifier.padding(start = 48.dp)
|
||||
)
|
||||
}
|
||||
|
||||
TextButton(onClick = onViewInFeed) { Text("View in Feed", color = TealSecondary) }
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth().weight(1f),
|
||||
contentAlignment = Alignment.Center
|
||||
) { CircularProgressIndicator(color = CyanPrimary) }
|
||||
} else if (links.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth().weight(1f),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
"No links found for this tag",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = TextSecondary
|
||||
)
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.weight(1f),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
items(links) { link ->
|
||||
TagLinkItem(link = link, onClick = { onLinkClick(link.url) })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TagLinkItem(link: ShaarliLink, onClick: () -> Unit) {
|
||||
GlassCard(modifier = Modifier.fillMaxWidth(), onClick = onClick) {
|
||||
Column {
|
||||
Text(
|
||||
text = link.title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = TextPrimary,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = link.url,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = TealSecondary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
if (link.description.isNotBlank()) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = link.description,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = TextSecondary,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
if (link.tags.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
items(link.tags) { tag -> TagChip(tag = tag, isSelected = false, onClick = {}) }
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(text = link.date, style = MaterialTheme.typography.labelSmall, color = TextMuted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,7 +91,6 @@ fun ShaarItTheme(
|
||||
val colorScheme =
|
||||
when {
|
||||
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||
val context = LocalContext.current
|
||||
if (darkTheme) DarkColorScheme // Use custom dark even with dynamic
|
||||
else lightColorScheme()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user