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:
2026-01-28 11:38:49 -05:00
parent 1438003f94
commit a9475c16b1
24 changed files with 2168 additions and 621 deletions
+11 -5
View File
@@ -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)
+60 -6
View File
@@ -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()
}