Skip to content
Open
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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
# Changelog

## Unreleased

The drop-in Compose screens are localized, and ship in English and Italian.

- Every end-user string of the board, the feedback form, My requests and the conversation screens is now a string resource of the library: `values/strings.xml` (English, the default) and a complete `values-it/strings.xml`. The screens follow the host app's locale, per-app language included, and fall back to English for any other language. Every name is prefixed `feedbackthread_`, so it can't collide with the host app's own resources. Adding a language is one new `values-xx/strings.xml`; a unit test fails on a string left untranslated, or on a translation whose placeholders drift from the English.
- **No public API change.** `FeedbackThreadFeedbackKind.title` still returns its English `String`; the screens use an internal localized accessor beside it. Exception messages stay English and unchanged, for the integrators who read and log them: the copy the SDK itself writes for end users (service unreachable, unreadable response, the HTTP-status fallback) is tagged internally, and the drop-in screens show it localized. `FeedbackThreadConversationState.error` keeps its English values; `FeedbackThreadConversationHost` shows them localized.
- Shown as written, on purpose: request titles and descriptions, error messages written by the API, statuses the SDK doesn't recognize, and `FeedbackThreadException.InvalidConfiguration` messages, which address the integrator.
- Wire values are deliberately untouched: the request statuses matched from the API (`"Submitted"`, `"In review"`, `"Ready to release"`, …), serialized names, URL paths, headers and the persisted anonymous voter-identity key stay exactly as they were, even where a label reads the same.
- Two English changes. The board's empty filter states are one whole sentence per filter instead of a lowercased status interpolated into `"No … requests"`, which no other language can inflect: "No requests in review" and "No requests in progress" replace "No in review requests" and "No in progress requests" (the other filters read as before). And the vote button's screen-reader description is a proper plural: "Vote, 1 vote" instead of "Vote, 1 votes".

## 0.5.0 — 2026-09-15

- Add secure guest conversations and Compose reply/comment screens with foreground live updates, pagination, read markers, follow/mute and removal.
Expand Down
18 changes: 16 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,20 @@ The client exposes all three calls directly if you're building custom UI: `myReq

The Compose screens are optional. `FeedbackThreadClient` exposes `submit(...)`, `requests(...)`, and `setVote(...)` for custom UI, with configurable connect/read timeouts and base-URL validation.

## Localization

The drop-in screens ship in **English and Italian** and follow your app's language automatically — the device's, or the per-app language your users pick — with nothing to configure. A language the SDK doesn't carry falls back to English.

All SDK-authored user-facing text lives in the library's string resources, `feedbackthread/src/main/res/values/strings.xml` (English) and `values-it/strings.xml`. Adding a language is a pull request with one new `values-xx/strings.xml` and nothing else; a unit test fails if any string is left untranslated or if a translation's placeholders don't match the English ones. Every name is prefixed `feedbackthread_`, so the SDK's strings never collide with your app's.

Some text stays as it is on purpose:

- **Server-supplied text** — request titles and descriptions, error messages from the API, and any request status the SDK doesn't recognize are shown exactly as received.
- **Configuration errors** — `FeedbackThreadException.InvalidConfiguration` messages are addressed to you while you wire the SDK up, not to your users.
- **Exception messages** — the messages the SDK writes on `FeedbackThreadException` stay English for your logs; the drop-in screens show their own localized copy of them.

`FeedbackThreadFeedbackKind.title` and `FeedbackThreadConversationState.error` remain plain English `String`s, unchanged, for custom UI that already renders them.

## Development

```sh
Expand Down Expand Up @@ -236,5 +250,5 @@ while retaining that old object. Never reuse its client after logout.

Public comments default off. Disabling them hides existing discussion without
deleting it. Private replies remain enabled. Image uploads are deferred and have
no SDK or server implementation in this change. New Compose conversation copy is
English, matching the current Android SDK; localization remains a release check.
no SDK or server implementation in this change. The conversation screens are
localized with the rest of the drop-in UI (see [Localization](#localization)).
8 changes: 8 additions & 0 deletions feedbackthread/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,14 @@ android {
buildFeatures {
compose = true
}

testOptions {
unitTests.all {
// The localization tests read src/main/res directly; without this
// input, an edited translation would leave the test task up to date.
it.inputs.dir("src/main/res")
}
}
}

kotlin {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package com.feedbackthread.sdk

import android.content.Context
import androidx.activity.compose.BackHandler
import androidx.annotation.StringRes
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
Expand Down Expand Up @@ -50,6 +51,8 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
Expand All @@ -64,15 +67,18 @@ import kotlinx.coroutines.launch
private sealed interface RequestLoadPhase {
data object Loading : RequestLoadPhase
data object Loaded : RequestLoadPhase
data class Failed(val message: String) : RequestLoadPhase
data class Failed(val message: FeedbackThreadText) : RequestLoadPhase
}

private enum class RequestFilter(val title: String) {
ALL("All"),
IN_REVIEW("In review"),
PLANNED("Planned"),
IN_PROGRESS("In progress"),
COMPLETED("Completed");
// internal (not private) so its copy is unit-testable. The stage filters
// share the status labels; each filter has its own whole empty-state
// sentence rather than a status name interpolated into one.
internal enum class RequestFilter(@StringRes val title: Int, @StringRes val emptyTitle: Int) {
ALL(R.string.feedbackthread_filter_all, R.string.feedbackthread_board_empty_filter_all),
IN_REVIEW(R.string.feedbackthread_status_in_review, R.string.feedbackthread_board_empty_filter_in_review),
PLANNED(R.string.feedbackthread_status_planned, R.string.feedbackthread_board_empty_filter_planned),
IN_PROGRESS(R.string.feedbackthread_status_in_progress, R.string.feedbackthread_board_empty_filter_in_progress),
COMPLETED(R.string.feedbackthread_status_completed, R.string.feedbackthread_board_empty_filter_completed);

fun includes(status: String): Boolean = when (this) {
// Unknown statuses are included under "All" rather than dropped, so a
Expand Down Expand Up @@ -130,7 +136,7 @@ public fun FeedbackThreadBoard(
} catch (error: CancellationException) {
throw error
} catch (error: Exception) {
phase = RequestLoadPhase.Failed(error.message ?: "Feature requests could not be loaded.")
phase = RequestLoadPhase.Failed(error.feedbackThreadDisplayText(R.string.feedbackthread_board_load_failed_fallback))
}
}
}
Expand Down Expand Up @@ -194,7 +200,7 @@ public fun FeedbackThreadBoard(
throw error
} catch (error: Exception) {
phase = RequestLoadPhase.Failed(
error.message ?: "Your vote could not be saved.",
error.feedbackThreadDisplayText(R.string.feedbackthread_board_vote_failed_fallback),
)
} finally {
votingIds = votingIds - request.id
Expand All @@ -207,12 +213,20 @@ public fun FeedbackThreadBoard(
modifier = modifier.fillMaxSize(),
topBar = {
TopAppBar(
title = { Text(if (selectedRequest == null) "Feature requests" else "Request") },
title = {
Text(
if (selectedRequest == null) {
stringResource(R.string.feedbackthread_board_title)
} else {
stringResource(R.string.feedbackthread_board_detail_title)
},
)
},
navigationIcon = {
if (selectedRequest == null) {
TextButton(onClick = onDismiss) { Text("Done") }
TextButton(onClick = onDismiss) { Text(stringResource(R.string.feedbackthread_done)) }
} else {
TextButton(onClick = { selectedRequestId = null }) { Text("Back") }
TextButton(onClick = { selectedRequestId = null }) { Text(stringResource(R.string.feedbackthread_back)) }
}
},
actions = {
Expand All @@ -225,7 +239,7 @@ public fun FeedbackThreadBoard(
}
},
) {
Icon(Icons.Default.Person, contentDescription = "My requests")
Icon(Icons.Default.Person, contentDescription = stringResource(R.string.feedbackthread_my_requests_title))
}
}
}
Expand All @@ -244,7 +258,7 @@ public fun FeedbackThreadBoard(
.navigationBarsPadding()
.padding(horizontal = 16.dp, vertical = 12.dp),
) {
Text("Suggest a feature")
Text(stringResource(R.string.feedbackthread_board_suggest_feature))
}
}
}
Expand All @@ -254,34 +268,34 @@ public fun FeedbackThreadBoard(
FeatureRequestDetail(
request = selectedRequest,
isVoting = selectedRequest.id in votingIds,
errorMessage = (phase as? RequestLoadPhase.Failed)?.message,
errorMessage = (phase as? RequestLoadPhase.Failed)?.message?.resolve(),
onVote = { toggleVote(selectedRequest) },
modifier = Modifier.padding(contentPadding),
)
} else when {
phase is RequestLoadPhase.Loading && requests.isEmpty() -> {
RequestMessage(
title = "Loading requests…",
title = stringResource(R.string.feedbackthread_board_loading),
showProgress = true,
modifier = Modifier.padding(contentPadding),
)
}

phase is RequestLoadPhase.Failed && requests.isEmpty() -> {
RequestMessage(
title = "Couldn’t load requests",
message = (phase as RequestLoadPhase.Failed).message,
actionTitle = "Try again",
title = stringResource(R.string.feedbackthread_board_load_failed_title),
message = (phase as RequestLoadPhase.Failed).message.resolve(),
actionTitle = stringResource(R.string.feedbackthread_try_again),
onAction = { scope.launch { loadRequests() } },
modifier = Modifier.padding(contentPadding),
)
}

visibleRequests.isEmpty() -> {
RequestMessage(
title = "No approved requests yet",
message = "Share an idea and it will appear after review.",
actionTitle = "Add request",
title = stringResource(R.string.feedbackthread_board_empty_title),
message = stringResource(R.string.feedbackthread_board_empty_message),
actionTitle = stringResource(R.string.feedbackthread_board_add_request),
onAction = onAddRequest,
modifier = Modifier.padding(contentPadding),
)
Expand All @@ -306,23 +320,29 @@ public fun FeedbackThreadBoard(
selected = selectedFilter == filter,
onClick = { selectedFilter = filter },
label = {
Text("${filter.title} (${visibleRequests.count { filter.includes(it.status) }})")
Text(
stringResource(
R.string.feedbackthread_filter_chip,
stringResource(filter.title),
visibleRequests.count { filter.includes(it.status) },
),
)
},
)
}
}
if (phase is RequestLoadPhase.Failed) {
Text(
text = (phase as RequestLoadPhase.Failed).message,
text = (phase as RequestLoadPhase.Failed).message.resolve(),
modifier = Modifier.padding(horizontal = 20.dp, vertical = 10.dp),
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
}
if (filteredRequests.isEmpty()) {
RequestMessage(
title = "No ${selectedFilter.title.lowercase()} requests",
message = "Choose another status to see more requests.",
title = stringResource(selectedFilter.emptyTitle),
message = stringResource(R.string.feedbackthread_board_empty_filter_message),
modifier = Modifier.weight(1f),
)
} else {
Expand Down Expand Up @@ -402,7 +422,7 @@ private fun FeatureRequestRow(
@Composable
private fun BugKindBadge() {
Text(
text = "Bug",
text = stringResource(FeedbackThreadFeedbackKind.BUG.localizedTitle),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.error,
modifier = Modifier
Expand Down Expand Up @@ -431,7 +451,7 @@ private fun FeatureRequestDetail(
val conversations = LocalFeedbackThreadConversations.current
val conversationState = conversations?.state?.collectAsState()?.value
if (conversationState?.publicCommentsEnabled == true) {
TextButton(onClick = { conversations.open(FeedbackThreadConversationRoute(request.id, "public")) }) { Text("Comments") }
TextButton(onClick = { conversations.open(FeedbackThreadConversationRoute(request.id, "public")) }) { Text(stringResource(R.string.feedbackthread_comments)) }
}
Text(
text = request.title,
Expand Down Expand Up @@ -494,18 +514,20 @@ private fun FeatureRequestVoteButton(
isVoting: Boolean,
onVote: () -> Unit,
) {
// Resolved here, in composition: the semantics block below runs outside it.
val voteDescription = if (isVoted) {
pluralStringResource(R.plurals.feedbackthread_remove_vote_description, votes, votes)
} else {
pluralStringResource(R.plurals.feedbackthread_vote_description, votes, votes)
}
TextButton(
onClick = onVote,
enabled = !isVoting,
modifier = Modifier
.widthIn(min = 64.dp)
.heightIn(min = 56.dp)
.semantics {
contentDescription = if (isVoted) {
"Remove vote, $votes votes"
} else {
"Vote, $votes votes"
}
contentDescription = voteDescription
},
) {
if (isVoting) {
Expand All @@ -528,7 +550,7 @@ private fun FeatureRequestStatusBadge(status: String) {
is FeedbackThreadRequestStage.Unknown -> MaterialTheme.colorScheme.onSurfaceVariant
}
Text(
text = status.feedbackThreadRequestLabel(),
text = status.feedbackThreadRequestLabel().resolve(),
modifier = Modifier
.background(statusColor.copy(alpha = 0.12f), RoundedCornerShape(999.dp))
.padding(horizontal = 8.dp, vertical = 3.dp),
Expand All @@ -541,7 +563,7 @@ private fun FeatureRequestStatusBadge(status: String) {
private fun ShippedInVersionBadge(version: String) {
val shippedColor = Color(0xFF2E7D32)
Text(
text = "✓ Shipped in $version",
text = stringResource(R.string.feedbackthread_shipped_in, version),
modifier = Modifier
.background(shippedColor.copy(alpha = 0.12f), RoundedCornerShape(999.dp))
.padding(horizontal = 8.dp, vertical = 3.dp),
Expand Down
Loading