Skip to content
12 min read Guides Android Kotlin

Android Feedback Board Tutorial: Kotlin SDK 1.0

This tutorial covers Kotlin SDK 1.0. If you are starting today, use the current Android tutorial, which covers the latest release. What 1.0 contains is summed up in Introducing the FeedbackKit Kotlin SDK.

A note on 1.0.0 and 1.0.1. Both are on Maven Central, and both went out on 9 February. 1.0.0's default production address has no FeedbackKit server behind it, and 1.0.1 corrects it. Install 1.0.1. The code in this tutorial is written against it.

A note on what 1.0 can do. Reading the board, filtering it, opening a request and filing one work. Voting, comments and the form's email field have known issues in 1.0, described in the sections they affect and gathered in section 9.

By the end of this guide your Android app has a screen where people read the requests others have filed, filter them, open one, vote, and file their own, without leaving the app. The screens are Jetpack Compose composables that come with the library. Your part is one Gradle line, one configure call with a user id, and the navigation between the screens.

The short version

  • Add com.getfeedbackkit:feedbackkit:1.0.1 from Maven Central. It needs minSdk 24 and Java 17.
  • Call FeedbackKit.configure(this) { ... } in your Application class, with your API key and a user id.
  • Wrap the screens in FeedbackKitProvider and wire FeedbackList, FeedbackDetailView and SubmitFeedbackView into your navigation.
  • Filter with rememberFeedbackListState, and theme with FeedbackKitTheme.

What you need

An Android app built with Jetpack Compose and Material 3, a minSdk of 24 or higher, and Java 17 for compiling. The library is built against API 34 and brings OkHttp, kotlinx.serialization, kotlinx.coroutines and DataStore with it. You also need a FeedbackKit project and its API key, which you copy from the project's settings in FeedbackKit. Keys start with sf_.

The examples use Trailmate, a fictional hiking app whose users keep asking for offline maps.

1. Add the dependency

Make sure mavenCentral() is among the repositories in settings.gradle.kts, then add one line to your app module's build.gradle.kts:

dependencies {
    implementation("com.getfeedbackkit:feedbackkit:1.0.1")
}

The Maven group is com.getfeedbackkit, while the Kotlin packages you import are under com.swiftlydeveloped.feedbackkit. The library declares the internet permission in its own manifest, and the manifest merger adds it to your app.

2. Configure the SDK with a user id

Configure once, in your Application class, before any FeedbackKit screen appears. Two values matter: the API key, and a user id.

The user id is the part that is easy to miss. Every request and every vote belongs to one, and the server refuses a new request or a vote without it. Kotlin SDK 1.0 does not create one for you. If your app has accounts, use your own account id. Otherwise make an id per install and keep it:

import android.app.Application
import com.swiftlydeveloped.feedbackkit.FeedbackKit
import com.swiftlydeveloped.feedbackkit.configure
import java.util.UUID

class TrailmateApp : Application() {
    override fun onCreate() {
        super.onCreate()

        val prefs = getSharedPreferences("trailmate", MODE_PRIVATE)
        val feedbackUserId = prefs.getString("feedback_user_id", null)
            ?: UUID.randomUUID().toString().also { id ->
                prefs.edit().putString("feedback_user_id", id).apply()
            }

        FeedbackKit.configure(this) {
            apiKey = BuildConfig.FEEDBACKKIT_API_KEY
            userId = feedbackUserId
        }
    }
}

Register the class in your manifest with android:name=".TrailmateApp" on the <application> element. The configure block is an extension function, so it needs its own import, com.swiftlydeveloped.feedbackkit.configure, next to the one for FeedbackKit.

The block's environment starts at Environment.PRODUCTION, the FeedbackKit server your projects live on, so you can leave it out. Environment.LOCAL and Environment.LOCAL_DEVICE are for running a FeedbackKit server yourself. configure keeps the instance it creates, and a second call returns that same instance, which is why it belongs in Application and not in an Activity.

Keep the key out of your repository

The key travels as an X-API-Key header. It is a client key for one project, made to ship inside an app, and anyone who unpacks your APK can read it. To keep it out of git, put feedbackkitApiKey=sf_your_api_key in ~/.gradle/gradle.properties and expose it as a build config field:

android {
    buildFeatures {
        buildConfig = true
    }
    defaultConfig {
        buildConfigField(
            "String",
            "FEEDBACKKIT_API_KEY",
            "\"${project.findProperty("feedbackkitApiKey") ?: ""}\""
        )
    }
}

When the user signs in

When someone signs in after launch, switch to their account id, so their requests and votes follow them to another device:

FeedbackKit.shared.userId = account.id

Votes cast under the earlier id stay with that id.

3. Show the board with FeedbackList

The three screens do not navigate on their own: each takes callbacks, and you connect them. This example uses Navigation Compose, so add androidx.navigation:navigation-compose to your app. The list's state is created once, outside the routes, so it survives a trip to the detail screen and back.

import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import com.swiftlydeveloped.feedbackkit.models.Feedback
import com.swiftlydeveloped.feedbackkit.state.FeedbackKitProvider
import com.swiftlydeveloped.feedbackkit.state.rememberFeedbackListState
import com.swiftlydeveloped.feedbackkit.theme.FeedbackKitTheme
import com.swiftlydeveloped.feedbackkit.ui.FeedbackDetailView
import com.swiftlydeveloped.feedbackkit.ui.FeedbackList
import com.swiftlydeveloped.feedbackkit.ui.SubmitFeedbackView

@Composable
fun IdeasBoard() {
    FeedbackKitProvider(theme = FeedbackKitTheme.Light) {
        val navController = rememberNavController()
        val listState = rememberFeedbackListState()
        var selected by remember { mutableStateOf<Feedback?>(null) }

        NavHost(navController = navController, startDestination = "list") {
            composable("list") {
                Scaffold(
                    floatingActionButton = {
                        FloatingActionButton(onClick = { navController.navigate("submit") }) {
                            Icon(Icons.Default.Add, contentDescription = "Suggest a feature")
                        }
                    }
                ) { padding ->
                    FeedbackList(
                        modifier = Modifier.fillMaxSize().padding(padding),
                        state = listState,
                        onFeedbackClick = { feedback ->
                            selected = feedback
                            navController.navigate("detail")
                        }
                    )
                }
            }
            composable("detail") {
                selected?.let { feedback ->
                    FeedbackDetailView(
                        feedback = feedback,
                        onBack = { navController.popBackStack() }
                    )
                }
            }
            composable("submit") {
                SubmitFeedbackView(
                    onBack = { navController.popBackStack() },
                    onSubmitSuccess = { created ->
                        listState.addFeedback(created)
                        navController.popBackStack()
                    }
                )
            }
        }
    }
}

Feedback is not parcelable, so the example keeps the tapped request in state and reads it in the detail route, the same way the library's own example app does.

That one list gives you:

  • A card per request with a vote button, status and category badges, the title, two lines of the description and the comment count.
  • The most-voted requests at the top, as the server sorts them.
  • A spinner while it loads, a "No feedback yet" message for an empty project, and an error screen with a Retry button.

The list has no plus button of its own, which is why the example adds a floating action button. It has no pull-to-refresh gesture either: call listState.refresh() when you want fresh data, for example when the screen comes back into view. The screens are in English in this version.

Your own empty, error and loading states

FeedbackList takes emptyContent, errorContent and loadingContent composables. Here Trailmate replaces the empty state:

FeedbackList(
    state = listState,
    onFeedbackClick = onOpen,
    emptyContent = {
        Text("No ideas yet. What should Trailmate do next?")
    }
)

4. The submit form

SubmitFeedbackView has a title, a category picker (Feature Request, Bug Report, Improvement or Other, starting at Feature Request), a description and an optional email field. Submit stays disabled until the title and description are filled in, and a failure shows its message above the button. After a successful submit, onSubmitSuccess receives the new Feedback. In the example above, it goes to the top of the list and the form closes.

You can also open the form by itself, for example from a "Report a problem" row in Settings. Pass showAppBar = false when your own scaffold already draws a top bar.

The email field in 1.0. The form shows the field and sends what is typed, but under a name the server does not read, so the address never reaches FeedbackKit and that person is not emailed when the status changes. In 1.0 the field cannot be hidden, so do not promise status emails next to it.

5. The detail screen

FeedbackDetailView shows the vote button, the status and category badges, the title, the full description and a comments section headed with the comment count. It brings a top app bar titled "Feedback Details", with a back arrow when you pass onBack.

Comments in 1.0. A request with no comments shows "No comments yet". Once a request has a comment, the section shows "Failed to load comments", because the library expects a field the server's reply does not have. The detail screen has no field for writing a comment. comments.create() exists on the client, and the comment it sends is saved, but the call then throws for the same reason, so it is not worth building on in 1.0.

6. Voting

The vote button sits on every card and on the detail screen. A tap updates the count at once and sends the vote. The rules behind it:

  • A new request starts with its submitter's vote, so it shows a count of 1.
  • Voting stops at Completed and Rejected. FeedbackStatus.canVote is false for those two, the button is disabled, and the server refuses new votes.
  • Each user id votes once per request. A repeat vote is a FeedbackKitError.ConflictError.
  • Without a user id, votes.vote() throws a ValidationError before sending anything.

Voting in 1.0. The vote is saved, but the library cannot read the server's reply, so the button rolls back to its old count and onVoteChange is not called. The vote appears once the list reloads, which is another reason to call listState.refresh() when the board comes back into view. Removing a vote fails in 1.0, so a vote stays once cast.

Here is how voting looks in FeedbackKit, with sample data from Trailmate:

Votes from inside the app

Each request carries a vote count, and voting stops when a request reaches Completed or Rejected.

7. Filters and your own screens

The list state can start filtered, and change filter later:

val listState = rememberFeedbackListState(
    initialStatusFilter = FeedbackStatus.APPROVED
)

// Later, from your own chips or menu:
listState.filterByCategory(FeedbackCategory.BUG_REPORT)
listState.clearFilters()

The filters run on the server, which returns the matching requests with the most-voted at the top. There are six statuses: PENDING, APPROVED, IN_PROGRESS, TESTFLIGHT, COMPLETED and REJECTED, shown as Pending, Approved, In Progress, TestFlight, Completed and Rejected. Android users see the TestFlight name too, so if your team uses that status for beta builds on both platforms, a word in your app about where betas come from helps.

For screens of your own, call the client directly. Here Trailmate's home screen shows its most-wanted Approved request:

@Composable
fun MostWanted() {
    var top by remember { mutableStateOf<Feedback?>(null) }

    LaunchedEffect(Unit) {
        top = try {
            FeedbackKit.shared.feedback
                .list(ListFeedbackOptions(status = FeedbackStatus.APPROVED))
                .maxByOrNull { it.voteCount }
        } catch (e: FeedbackKitError) {
            null
        }
    }

    top?.let { feedback ->
        ListItem(
            headlineContent = { Text(feedback.title) },
            trailingContent = { Text("${feedback.voteCount}") }
        )
    }
}

The client's other calls are feedback.get(id) and feedback.create(title, description, category). Every call is a suspend function that throws a FeedbackKitError. Each case has a userMessage for the screen, and isRecoverable is true for network and server errors, the ones worth a retry.

8. Theming

FeedbackKitTheme is a data class with Light and Dark presets. Start from one and change what you need with copy:

val trailmateGreen = Color(0xFF2E7D32)

val base = if (isSystemInDarkTheme()) FeedbackKitTheme.Dark else FeedbackKitTheme.Light
val theme = base.copy(
    primaryColor = trailmateGreen,
    voteActiveColor = trailmateGreen,
    statusColors = StatusColors(testflight = Color(0xFF00897B))
)

FeedbackKitProvider(theme = theme) {
    IdeasBoard()
}

The provider does not follow the system's dark mode by itself, so choose the preset as above. Besides colours for text, cards, borders, errors and the vote button, the theme holds borderRadius, spacing and cardElevation. Status colours default to gray, blue, orange, cyan, green and red, and category colours to purple, red, blue and gray.

9. Testing tips and the known issues

Plan around the 1.0 issues. In one place:

  • A vote is saved but the button rolls back until the list reloads, and removing a vote fails.
  • Existing comments do not load, and there is no field for writing one.
  • The form's email does not reach FeedbackKit.
  • Without a user id, filing and voting fail.

Configure before the board appears. The screens read FeedbackKit.shared, which throws until configure has run. Configuring in Application.onCreate settles that. If the board shows "Something went wrong" at launch, check that your Application class is registered in the manifest.

A wrong key shows an error screen. When the server rejects the key, the list shows its error state with "Authentication failed. Please check your API key."

Keep test requests off the real board. Everything you file while testing lands in the project your key belongs to. Pro includes two projects, so you can give debug builds a key from a second one. Otherwise, delete test requests afterwards.

Turn debug logging off in release builds. debug = true in the configure block prints each request's address and each response body to standard output, and both carry user ids. Keep it to debug builds:

FeedbackKit.configure(this) {
    apiKey = BuildConfig.FEEDBACKKIT_API_KEY
    userId = feedbackUserId
    debug = BuildConfig.DEBUG
}

Use previews with sample data. FeedbackCard, StatusBadge and CategoryBadge render from a Feedback value, which has a public constructor, so they work in a @Preview without a server. Do not tap the vote button in a preview: it calls FeedbackKit.shared.

Where to go next

Once requests come in, the work moves to your side: triage them in the FeedbackKit admin app on iPhone, iPad or Mac, or in the web admin, merge duplicates, and move the ones you build through Approved, In Progress and TestFlight to Completed. Your team gets a push notification for each new request and comment. On Pro and up you can send requests to GitHub, Linear, Notion or another of the trackers FeedbackKit connects to. The reference for every composable and call is in the Kotlin SDK docs.

Every plan includes the Kotlin SDK. Start free, copy your project key, and the board can be in your next internal test build.

Keep reading

Collect feedback in your own app

FeedbackKit ships SDKs for Swift, Kotlin, React Native, Flutter and JavaScript, and syncs what your users ask for into the tracker your team already uses.