Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,19 @@ interface SheafApiService {
@GET("/v1/tags")
suspend fun listTags(): List<TagRead>

@GET("/v1/members/{id}/tags")
suspend fun getMemberTags(@Path("id") id: String): List<TagRead>

/**
* Sets the member's complete tag set, not a delta: send every tag the
* member should end up with. Requires the tags:write scope.
*/
@PUT("/v1/members/{id}/tags")
suspend fun setMemberTags(
@Path("id") id: String,
@Body body: MemberTagUpdate,
): List<TagRead>

@POST("/v1/tags")
suspend fun createTag(@Body body: TagCreate): TagRead

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -601,6 +601,12 @@ data class GroupMemberUpdate(

// ── Tags ──────────────────────────────────────────────────────────────────────

/** Body for `PUT /v1/members/{id}/tags`: the member's complete tag set. */
@JsonClass(generateAdapter = true)
data class MemberTagUpdate(
@Json(name = "tag_ids") val tagIds: List<String>,
)

@JsonClass(generateAdapter = true)
data class TagRead(
val id: String,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
package systems.lupine.sheaf.ui.members

import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.AssistChip
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import systems.lupine.sheaf.ui.components.ColorSwatch
import systems.lupine.sheaf.ui.components.ErrorBanner

/**
* The tags on one member: a chip per tag in the system, filled when applied.
*
* Mirrors [systems.lupine.sheaf.ui.relationships.RelationshipsEditor]: editable
* on the member editor, read-only on the profile, and rendering nothing at all
* in read-only mode when the member has no tags, so it can be dropped in
* unconditionally.
*
* Changes apply on tap rather than waiting for the editor's Save. The tag set
* is its own endpoint, not part of the member body that Save flushes, and the
* relationships editor on the same screen already works this way.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun MemberTagsEditor(
memberId: String,
modifier: Modifier = Modifier,
readOnly: Boolean = false,
viewModel: MemberTagsEditorViewModel = hiltViewModel(),
) {
LaunchedEffect(memberId) { viewModel.load(memberId) }
val state by viewModel.state.collectAsState()

val applied = state.allTags.filter { it.id in state.selected }
if (readOnly && (state.isLoading || applied.isEmpty())) return
// Nothing to show and nothing to pick from: a system with no tags defined
// gets a pointer to where they're made rather than an empty card.
if (!readOnly && !state.isLoading && state.allTags.isEmpty()) {
Card(
modifier = modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
),
) {
Column(modifier = Modifier.padding(16.dp)) {
Text("Tags", style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary)
Text(
"No tags yet. Create them in Settings > System > Tags, then " +
"come back to apply them here.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
return
}

Card(
modifier = modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
),
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
"Tags",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
)
state.error?.let { ErrorBanner(it, modifier = Modifier.padding(top = 8.dp)) }

FlowRow(
modifier = Modifier.padding(top = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
if (readOnly) {
applied.forEach { tag ->
AssistChip(
onClick = {},
label = { Text(tag.name) },
leadingIcon = { ColorSwatch(tag.color ?: DEFAULT_TAG_COLOR, size = 14.dp) },
)
}
} else {
state.allTags.forEach { tag ->
val on = tag.id in state.selected
FilterChip(
selected = on,
// Chips stay tappable while a save is in flight:
// the change is optimistic and rolls back on
// failure, so blocking here would only add lag.
onClick = { viewModel.toggle(tag.id) },
label = { Text(tag.name) },
leadingIcon = if (on) ({
Icon(
Icons.Default.Check,
contentDescription = null,
modifier = Modifier.size(16.dp),
)
}) else ({
ColorSwatch(tag.color ?: DEFAULT_TAG_COLOR, size = 14.dp)
}),
)
}
}
}
}
}
}

// Matches the swatch the tags manager falls back to for a colourless tag.
private const val DEFAULT_TAG_COLOR = "#10B981"
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package systems.lupine.sheaf.ui.members

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import systems.lupine.sheaf.data.api.SheafApiService
import systems.lupine.sheaf.data.model.MemberTagUpdate
import systems.lupine.sheaf.data.model.TagRead
import systems.lupine.sheaf.util.toUserMessage
import javax.inject.Inject

data class MemberTagsUiState(
/** Every tag the system has, the pool to choose from. */
val allTags: List<TagRead> = emptyList(),
/** Ids currently on this member. */
val selected: Set<String> = emptySet(),
val isLoading: Boolean = true,
val isSaving: Boolean = false,
val error: String? = null,
)

@HiltViewModel
class MemberTagsEditorViewModel @Inject constructor(
private val api: SheafApiService,
) : ViewModel() {

private val _state = MutableStateFlow(MemberTagsUiState())
val state: StateFlow<MemberTagsUiState> = _state.asStateFlow()

private var memberId: String? = null

fun load(memberId: String) {
// Guard against the LaunchedEffect re-firing on recomposition: a reload
// mid-edit would stomp a toggle the user just made.
if (this.memberId == memberId && !_state.value.isLoading) return
this.memberId = memberId
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
// The tag vocabulary is best-effort: a viewer who can read this
// member's tags but not list all of them still gets to see what is
// set, just with nothing to add from.
val all = runCatching { api.listTags() }.getOrDefault(emptyList())
runCatching { api.getMemberTags(memberId) }
.onSuccess { mine ->
_state.update {
it.copy(
allTags = all,
selected = mine.mapTo(mutableSetOf()) { t -> t.id },
isLoading = false,
)
}
}
.onFailure { e ->
_state.update { it.copy(isLoading = false, error = e.toUserMessage()) }
}
}
}

/**
* Add or remove one tag. The endpoint takes the member's whole tag set
* rather than a delta, so send the result of the toggle.
*
* Applies optimistically and rolls back on failure: this is a chip the user
* taps, and leaving it un-filled until a round-trip completes makes the tap
* feel broken on a slow connection.
*/
fun toggle(tagId: String) {
val id = memberId ?: return
val before = _state.value.selected
val after = if (tagId in before) before - tagId else before + tagId
_state.update { it.copy(selected = after, isSaving = true, error = null) }
viewModelScope.launch {
runCatching { api.setMemberTags(id, MemberTagUpdate(tagIds = after.toList())) }
.onSuccess { saved ->
_state.update {
it.copy(
selected = saved.mapTo(mutableSetOf()) { t -> t.id },
isSaving = false,
)
}
}
.onFailure { e ->
_state.update {
it.copy(selected = before, isSaving = false, error = e.toUserMessage())
}
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -818,9 +818,12 @@ fun MemberDetailScreen(
}
}

// Relationships (existing members only; a new member has no id yet).
// Tags and relationships, for existing members only: both attach to a
// member id, and a member being created does not have one yet.
if (!viewModel.isNewMember) {
Spacer(Modifier.height(16.dp))
MemberTagsEditor(memberId = memberId)
Spacer(Modifier.height(12.dp))
RelationshipsEditor(scope = REL_SCOPE_MEMBER, nodeId = memberId)
}

Expand Down Expand Up @@ -1151,6 +1154,8 @@ fun MemberProfileScreen(
}

// Relationships (read-only; renders nothing when there are none).
MemberTagsEditor(memberId = member.id, readOnly = true)

RelationshipsEditor(
scope = REL_SCOPE_MEMBER,
nodeId = member.id,
Expand Down
Loading
Loading