feat: add tags toggle in Settings, move newsletter to bottom tab

- Add TagsPreferences with per-account toggle (enabled by default)
- Tags toggle in Settings → Features section with "Manage Tags" button
- When tags disabled: hide tag filter chips, tag section in Composer,
  tag click handlers become no-ops in Feed
- New Newsletter bottom tab (Home, Newsletter, Stats, Settings)
- NewsletterScreen shows enable toggle, subscriber count, newsletters list
- Remove newsletter section from Settings (moved to dedicated tab)
This commit is contained in:
Paweł Orzech 2026-03-20 09:18:30 +01:00
parent da8a90470d
commit 0718a9e744
No known key found for this signature in database
8 changed files with 416 additions and 112 deletions

View file

@ -0,0 +1,32 @@
package com.swoosh.microblog.data
import android.content.Context
import android.content.SharedPreferences
class TagsPreferences private constructor(
private val prefs: SharedPreferences,
private val accountIdProvider: () -> String
) {
constructor(context: Context) : this(
prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE),
accountIdProvider = { AccountManager(context).getActiveAccount()?.id ?: "" }
)
constructor(prefs: SharedPreferences, accountId: String) : this(
prefs = prefs,
accountIdProvider = { accountId }
)
private fun activeAccountId(): String = accountIdProvider()
fun isTagsEnabled(): Boolean =
prefs.getBoolean("tags_enabled_${activeAccountId()}", true)
fun setTagsEnabled(enabled: Boolean) =
prefs.edit().putBoolean("tags_enabled_${activeAccountId()}", enabled).apply()
companion object {
const val PREFS_NAME = "tags_prefs"
}
}

View file

@ -432,16 +432,18 @@ fun ComposerScreen(
}
)
// Tags section: input + suggestions + chips
Spacer(modifier = Modifier.height(12.dp))
TagsSection(
tagInput = state.tagInput,
onTagInputChange = viewModel::updateTagInput,
tagSuggestions = state.tagSuggestions,
extractedTags = state.extractedTags,
onAddTag = viewModel::addTag,
onRemoveTag = viewModel::removeTag
)
// Tags section: input + suggestions + chips (only when tags enabled)
if (viewModel.isTagsEnabled()) {
Spacer(modifier = Modifier.height(12.dp))
TagsSection(
tagInput = state.tagInput,
onTagInputChange = viewModel::updateTagInput,
tagSuggestions = state.tagSuggestions,
extractedTags = state.extractedTags,
onAddTag = viewModel::addTag,
onRemoveTag = viewModel::removeTag
)
}
Spacer(modifier = Modifier.height(12.dp))

View file

@ -7,6 +7,7 @@ import androidx.lifecycle.viewModelScope
import com.swoosh.microblog.data.HashtagParser
import com.swoosh.microblog.data.MobiledocBuilder
import com.swoosh.microblog.data.NewsletterPreferences
import com.swoosh.microblog.data.TagsPreferences
import com.swoosh.microblog.data.PreviewHtmlBuilder
import com.swoosh.microblog.data.db.Converters
import com.swoosh.microblog.data.model.*
@ -29,6 +30,7 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
private val repository = PostRepository(application)
private val tagRepository = TagRepository(application)
private val newsletterPreferences = NewsletterPreferences(application)
private val tagsPreferences = TagsPreferences(application)
private val appContext = application
private val _uiState = MutableStateFlow(ComposerUiState())
@ -41,10 +43,14 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
private var previewDebounceJob: Job? = null
init {
loadAvailableTags()
if (tagsPreferences.isTagsEnabled()) {
loadAvailableTags()
}
loadNewsletterData()
}
fun isTagsEnabled(): Boolean = tagsPreferences.isTagsEnabled()
private fun loadAvailableTags() {
viewModelScope.launch {
tagRepository.fetchTags().fold(

View file

@ -117,6 +117,7 @@ fun FeedScreen(
val accounts by viewModel.accounts.collectAsStateWithLifecycle()
val activeAccount by viewModel.activeAccount.collectAsStateWithLifecycle()
val popularTags by viewModel.popularTags.collectAsStateWithLifecycle()
val tagsEnabled by viewModel.tagsEnabled.collectAsStateWithLifecycle()
val listState = rememberLazyListState()
val context = LocalContext.current
val snackbarHostState = remember { SnackbarHostState() }
@ -331,7 +332,7 @@ fun FeedScreen(
// Tag filter chips
AnimatedVisibility(
visible = !isSearchActive && popularTags.isNotEmpty(),
visible = !isSearchActive && tagsEnabled && popularTags.isNotEmpty(),
enter = fadeIn(SwooshMotion.quick()) + expandVertically(),
exit = fadeOut(SwooshMotion.quick()) + shrinkVertically()
) {
@ -588,7 +589,7 @@ fun FeedScreen(
onEdit = { onEditPost(post) },
onDelete = { postPendingDelete = post },
onTogglePin = { viewModel.toggleFeatured(post) },
onTagClick = { tag -> viewModel.filterByTag(tag) },
onTagClick = { tag -> if (tagsEnabled) viewModel.filterByTag(tag) },
snackbarHostState = snackbarHostState
)
}
@ -614,7 +615,7 @@ fun FeedScreen(
onEdit = { onEditPost(post) },
onDelete = { postPendingDelete = post },
onTogglePin = { viewModel.toggleFeatured(post) },
onTagClick = { tag -> viewModel.filterByTag(tag) },
onTagClick = { tag -> if (tagsEnabled) viewModel.filterByTag(tag) },
snackbarHostState = snackbarHostState
)
}

View file

@ -9,6 +9,7 @@ import com.swoosh.microblog.data.AccountManager
import com.swoosh.microblog.data.CredentialsManager
import com.swoosh.microblog.data.FeedPreferences
import com.swoosh.microblog.data.HashtagParser
import com.swoosh.microblog.data.TagsPreferences
import com.swoosh.microblog.data.api.ApiClient
import com.swoosh.microblog.data.db.Converters
import com.swoosh.microblog.data.model.*
@ -42,6 +43,7 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
private var repository = PostRepository(application)
private var tagRepository = TagRepository(application)
private val feedPreferences = FeedPreferences(application)
private val tagsPreferences = TagsPreferences(application)
private val searchHistoryManager = SearchHistoryManager(application)
private val _uiState = MutableStateFlow(FeedUiState())
@ -77,6 +79,9 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
private val _popularTags = MutableStateFlow<List<GhostTagFull>>(emptyList())
val popularTags: StateFlow<List<GhostTagFull>> = _popularTags.asStateFlow()
private val _tagsEnabled = MutableStateFlow(true)
val tagsEnabled: StateFlow<Boolean> = _tagsEnabled.asStateFlow()
private val _accounts = MutableStateFlow<List<GhostAccount>>(emptyList())
val accounts: StateFlow<List<GhostAccount>> = _accounts.asStateFlow()
@ -303,16 +308,23 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
val sort = _sortOrder.value
val tagFilter = _uiState.value.activeTagFilter
// Fetch popular tags in parallel
launch {
tagRepository.fetchTags().fold(
onSuccess = { tags ->
_popularTags.value = tags
.sortedByDescending { it.count?.posts ?: 0 }
.take(10)
},
onFailure = { /* silently ignore tag fetch failures */ }
)
// Fetch popular tags in parallel (only when tags feature is enabled)
val tagsOn = tagsPreferences.isTagsEnabled()
_tagsEnabled.value = tagsOn
if (tagsOn) {
launch {
tagRepository.fetchTags().fold(
onSuccess = { tags ->
_popularTags.value = tags
.sortedByDescending { it.count?.posts ?: 0 }
.take(10)
},
onFailure = { /* silently ignore tag fetch failures */ }
)
}
} else {
_popularTags.value = emptyList()
_uiState.update { it.copy(activeTagFilter = null) }
}
repository.fetchPosts(page = 1, filter = filter, sortOrder = sort, tagFilter = tagFilter).fold(

View file

@ -11,6 +11,7 @@ import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.BarChart
import androidx.compose.material.icons.filled.Email
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.*
@ -34,6 +35,7 @@ import com.swoosh.microblog.ui.pages.PagesScreen
import com.swoosh.microblog.ui.feed.FeedViewModel
import com.swoosh.microblog.ui.members.MemberDetailScreen
import com.swoosh.microblog.ui.members.MembersScreen
import com.swoosh.microblog.ui.newsletter.NewsletterScreen
import com.swoosh.microblog.ui.preview.PreviewScreen
import com.swoosh.microblog.ui.settings.SettingsScreen
import com.swoosh.microblog.ui.setup.SetupScreen
@ -55,6 +57,7 @@ object Routes {
const val MEMBERS = "members"
const val MEMBER_DETAIL = "member_detail"
const val TAGS = "tags"
const val NEWSLETTER = "newsletter"
}
data class BottomNavItem(
@ -65,12 +68,13 @@ data class BottomNavItem(
val bottomNavItems = listOf(
BottomNavItem(Routes.FEED, "Home", Icons.Default.Home),
BottomNavItem(Routes.NEWSLETTER, "Newsletter", Icons.Default.Email),
BottomNavItem(Routes.STATS, "Stats", Icons.Default.BarChart),
BottomNavItem(Routes.SETTINGS, "Settings", Icons.Default.Settings)
)
/** Routes where the bottom navigation bar should be visible */
private val bottomBarRoutes = setOf(Routes.FEED, Routes.STATS, Routes.SETTINGS)
private val bottomBarRoutes = setOf(Routes.FEED, Routes.NEWSLETTER, Routes.STATS, Routes.SETTINGS)
@Composable
fun SwooshNavGraph(
@ -289,6 +293,16 @@ fun SwooshNavGraph(
)
}
composable(
Routes.NEWSLETTER,
enterTransition = { fadeIn(tween(200)) },
exitTransition = { fadeOut(tween(150)) },
popEnterTransition = { fadeIn(tween(200)) },
popExitTransition = { fadeOut(tween(150)) }
) {
NewsletterScreen()
}
composable(
Routes.TAGS,
enterTransition = { slideInHorizontally(initialOffsetX = { it }, animationSpec = tween(250)) + fadeIn(tween(200)) },

View file

@ -0,0 +1,296 @@
package com.swoosh.microblog.ui.newsletter
import androidx.compose.animation.*
import androidx.compose.animation.core.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Email
import androidx.compose.material.icons.filled.People
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.swoosh.microblog.data.NewsletterPreferences
import com.swoosh.microblog.data.repository.PostRepository
import com.swoosh.microblog.data.model.GhostNewsletter
import com.swoosh.microblog.ui.animation.SwooshMotion
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NewsletterScreen() {
val context = LocalContext.current
val newsletterPreferences = remember { NewsletterPreferences(context) }
var newsletterEnabled by remember { mutableStateOf(newsletterPreferences.isNewsletterEnabled()) }
var validationStatus by remember { mutableStateOf<String?>(null) }
var newsletters by remember { mutableStateOf<List<GhostNewsletter>>(emptyList()) }
var isLoading by remember { mutableStateOf(false) }
var subscriberCount by remember { mutableStateOf<Int?>(null) }
// Load newsletters on launch if enabled
LaunchedEffect(newsletterEnabled) {
if (newsletterEnabled) {
isLoading = true
try {
val repository = PostRepository(context)
val result = repository.fetchNewsletters()
result.fold(
onSuccess = {
newsletters = it
validationStatus = "${it.size} newsletter(s) found"
},
onFailure = {
validationStatus = "Could not load newsletters"
}
)
// Fetch subscriber count
val membersResult = repository.fetchSubscriberCount()
membersResult.fold(
onSuccess = { subscriberCount = it },
onFailure = { /* ignore */ }
)
} catch (_: Exception) {
validationStatus = "Could not load newsletters"
}
isLoading = false
} else {
newsletters = emptyList()
validationStatus = null
subscriberCount = null
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Newsletter") }
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(24.dp)
.verticalScroll(rememberScrollState())
) {
// Enable/Disable toggle
Card(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Enable newsletter features",
style = MaterialTheme.typography.bodyLarge
)
}
Switch(
checked = newsletterEnabled,
onCheckedChange = { enabled ->
newsletterEnabled = enabled
newsletterPreferences.setNewsletterEnabled(enabled)
}
)
}
Text(
text = "Show newsletter sending options when publishing posts",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
// Newsletter details (when enabled)
AnimatedVisibility(
visible = newsletterEnabled,
enter = fadeIn(SwooshMotion.quick()) + expandVertically(animationSpec = SwooshMotion.snappy()),
exit = fadeOut(SwooshMotion.quick()) + shrinkVertically(animationSpec = SwooshMotion.snappy())
) {
Column {
Spacer(modifier = Modifier.height(16.dp))
if (isLoading) {
Card(modifier = Modifier.fillMaxWidth()) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(32.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
} else {
// Subscriber count card
if (subscriberCount != null) {
Card(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Icon(
Icons.Default.People,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
Column {
Text(
text = "$subscriberCount",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold
)
Text(
text = "Subscribers",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
Spacer(modifier = Modifier.height(12.dp))
}
// Newsletters list
if (newsletters.isNotEmpty()) {
Text(
"Your Newsletters",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(bottom = 8.dp)
)
newsletters.forEach { newsletter ->
Card(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 8.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Icon(
Icons.Default.Email,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Column(modifier = Modifier.weight(1f)) {
Text(
text = newsletter.name,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium
)
if (!newsletter.description.isNullOrBlank()) {
Text(
text = newsletter.description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = "Status: ${newsletter.status}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = "Visibility: ${newsletter.visibility}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
}
} else if (validationStatus != null) {
Card(modifier = Modifier.fillMaxWidth()) {
Text(
text = validationStatus!!,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(16.dp)
)
}
}
}
Spacer(modifier = Modifier.height(16.dp))
// Info card
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(16.dp)
) {
Text(
text = "How it works",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "When enabled, the publish dialog offers options to send posts as newsletters or email-only content to your subscribers.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
// Disabled state info
AnimatedVisibility(
visible = !newsletterEnabled,
enter = fadeIn(SwooshMotion.quick()) + expandVertically(animationSpec = SwooshMotion.snappy()),
exit = fadeOut(SwooshMotion.quick()) + shrinkVertically(animationSpec = SwooshMotion.snappy())
) {
Column {
Spacer(modifier = Modifier.height(16.dp))
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
Icons.Default.Email,
contentDescription = null,
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = "Newsletter features are disabled",
style = MaterialTheme.typography.bodyLarge
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Enable to send posts as newsletters to your subscribers",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
}
}
}

View file

@ -13,7 +13,6 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.BrightnessAuto
import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.filled.DarkMode
import androidx.compose.material.icons.filled.LightMode
import androidx.compose.material.icons.automirrored.filled.OpenInNew
@ -33,11 +32,9 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.compose.AsyncImage
import com.swoosh.microblog.data.AccountManager
import com.swoosh.microblog.data.toDisplayUrl
import com.swoosh.microblog.data.NewsletterPreferences
import com.swoosh.microblog.data.TagsPreferences
import com.swoosh.microblog.data.SiteMetadataCache
import com.swoosh.microblog.data.api.ApiClient
import com.swoosh.microblog.data.repository.PostRepository
import kotlinx.coroutines.launch
import com.swoosh.microblog.data.model.GhostAccount
import com.swoosh.microblog.ui.animation.SwooshMotion
import com.swoosh.microblog.ui.components.ConfirmationDialog
@ -252,50 +249,11 @@ fun SettingsScreen(
Spacer(modifier = Modifier.height(24.dp))
}
// --- Content Management section ---
Text("Content", style = MaterialTheme.typography.titleMedium)
// --- Features section ---
Text("Features", style = MaterialTheme.typography.titleMedium)
Spacer(modifier = Modifier.height(12.dp))
OutlinedCard(
onClick = onNavigateToTags,
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Tag,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
"Tags",
style = MaterialTheme.typography.bodyLarge
)
}
Icon(
Icons.Default.ChevronRight,
contentDescription = "Navigate to tags",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp)
)
}
}
Spacer(modifier = Modifier.height(24.dp))
HorizontalDivider()
Spacer(modifier = Modifier.height(24.dp))
// --- Newsletter section ---
NewsletterSettingsSection()
TagsSettingsSection(onNavigateToTags = onNavigateToTags)
Spacer(modifier = Modifier.height(24.dp))
HorizontalDivider()
@ -450,15 +408,10 @@ fun SettingsScreen(
}
@Composable
fun NewsletterSettingsSection() {
fun TagsSettingsSection(onNavigateToTags: () -> Unit = {}) {
val context = LocalContext.current
val newsletterPreferences = remember { NewsletterPreferences(context) }
var newsletterEnabled by remember { mutableStateOf(newsletterPreferences.isNewsletterEnabled()) }
val coroutineScope = rememberCoroutineScope()
var validationStatus by remember { mutableStateOf<String?>(null) }
Text("Newsletter", style = MaterialTheme.typography.titleMedium)
Spacer(modifier = Modifier.height(12.dp))
val tagsPreferences = remember { TagsPreferences(context) }
var tagsEnabled by remember { mutableStateOf(tagsPreferences.isTagsEnabled()) }
Card(modifier = Modifier.fillMaxWidth()) {
Column(
@ -473,57 +426,45 @@ fun NewsletterSettingsSection() {
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Enable newsletter features",
text = "Enable tags",
style = MaterialTheme.typography.bodyLarge
)
}
Switch(
checked = newsletterEnabled,
checked = tagsEnabled,
onCheckedChange = { enabled ->
newsletterEnabled = enabled
newsletterPreferences.setNewsletterEnabled(enabled)
if (enabled) {
// Best effort: validate by fetching newsletters
validationStatus = "Checking..."
coroutineScope.launch {
try {
val repository = PostRepository(context)
val result = repository.fetchNewsletters()
validationStatus = if (result.isSuccess) {
val count = result.getOrNull()?.size ?: 0
"$count newsletter(s) found"
} else {
null
}
} catch (_: Exception) {
validationStatus = null
}
}
} else {
validationStatus = null
}
tagsEnabled = enabled
tagsPreferences.setTagsEnabled(enabled)
}
)
}
Text(
text = "Show newsletter sending options when publishing posts",
text = "Show tag management and tag filters in feed",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
// Validation status
// Manage tags navigation
AnimatedVisibility(
visible = validationStatus != null,
visible = tagsEnabled,
enter = fadeIn(SwooshMotion.quick()) + expandVertically(animationSpec = SwooshMotion.snappy()),
exit = fadeOut(SwooshMotion.quick()) + shrinkVertically(animationSpec = SwooshMotion.snappy())
) {
Text(
text = validationStatus ?: "",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(top = 4.dp)
)
OutlinedButton(
onClick = onNavigateToTags,
modifier = Modifier
.fillMaxWidth()
.padding(top = 12.dp)
) {
Icon(
Icons.Default.Tag,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text("Manage Tags")
}
}
}
}