Tutorial: llmPrompt Compose
Cover Page
DUE Wed, 09/03, 2 pm
This tutorial introduces you to the Android app development environment and basic development tools on the backend. You’ll learn some Kotlin syntax and language features for the front end and, to a lesser extent, get acquainted with the backend language and web stack of your choice. You will use Compose to build reactive UI declaratively on the front end. Let’s get started!
Expected behavior
Posting a prompt and receiving and displaying streamed response:
DISCLAIMER: the video demo shows you one aspect of the app’s behavior. It is not a substitute for the spec. If there are any discrepancies between the demo and the spec, please follow the spec. The spec is the single source of truth. If the spec is ambiguous, please consult the teaching staff for clarification.
Be patient, the Chatter app on your device or emulator will be very slow because we’re running on debug mode, not as stand-alone app in release mode. Depending on the resources on your development platform and device, it could take several seconds after launch for the app’s first screen to show up.
Preliminaries
Before we start, you’ll need to prepare a GitHub repo to submit your tutorials and for us to communicate your tutorial grades back to you. Please follow the instructions in Preparing GitHub for Reactive Tutorials and Projects and then return here to continue.
If you don’t have an environment set up for Android development, please read our notes on Getting Started with Android Development first.
Creating an Android Studio project
In the following, replace <YOUR UNIQNAME>
with your uniqname. Google will complain if your Package name
is not globally unique. Using your uniqname is one way to generate a unique Package name
.
Depending on your version of Android Studio, the screenshots in this and subsequent specs may not look exactly the same as what you see on screen.
- Click
New Project
in “Welcome to Android Studio” screen (screenshot) - On
Phone and Tablet
tab, selectEmpty Activity
(NOTNo Activity
and NOTEmpty Views Activity
) and clickNext
(screenshot) - Enter
Name
: composeChatter (screenshot, showing all fields below) -
Package name
: edu.umich.<YOUR UNIQNAME>
.composeChatter 👈👈👈replace
<YOUR UNIQNAME>
with yours, remove the angle brackets,< >
Android Studio may automatically change all upper case letters in
Name
to lower case inPackage name
. If you prefer to use upper case, just edit thePackage name
directly. -
Save location
: put yourcomposeChatter
folder in 👉👉👉YOUR*TUTORIALS/llmprompt/composeChatter/
, whereYOUR*TUTORIALS
is the name you give to your assignment GitHub repo clone in Preparing GitHub for Reactive above. -
Minimum SDK
: API 36 (Android 16.0) -
Build configuration language
: Kotlin DSL (build.gradle.kts) - Click
Finish
Subsequently in this and other tutorials, we will use the tag YOUR_PACKAGENAME
to refer to your package name. Whenever you see the tag, please replace it with your chosen package name.
Checking GitHub
Open GitHub Desktop and
- Click on
Current Repository
on the top left of the interface - Click on the assignment GitHub repo you cloned above
- Add Summary to your changes and click
Commit to main
at the bottom of the left pane - If you have a team mate and they have pushed changes to GitHub, you’ll have to click
Pull Origin
and resolve any conflicts, re-commit to main, and - Finally click on
Push Origin
to push changes to GitHub
If you are proficient with git, you don’t have to use GitHub Desktop. However, we can only help with GitHub Desktop, so if you use anything else, you’ll be on your own.
Go to the GitHub website to confirm that your folders follow this structure outline:
reactive
|-- llmprompt
|-- composeChatter
|-- app
|-- gradle
If the folders in your GitHub repo does not have the above structure, we will not be able to grade your tutorials and you will get a ZERO.
Android Studio project structure
Set the left or Project
pane of your Android Studio window to show your project structure in Android
view (screenshot) such that your project structure looks like this:
-
/app/manifests/AndroidManifest.xml
: general app settings and activity list -
/app/kotlin+java/
-
YOUR_PACKAGENAME
: source code- MainActivity.kt: we will be modifying this source file later.
While we are not required to call the first activity of the app the
MainActivity
(it can be changed inAndroidManifest.xml
), it is a convention to do so and Android Studio automatically does this when it sets up a new project. - Color highlight on filenames indicates that the files have modifications not yet committed and pushed to Git repo. They will automatically turn off once changes are committed and pushed (including on GitHub Desktop).
- MainActivity.kt: we will be modifying this source file later.
-
YOUR_PACKAGENAME (androidTest)
: testing code to be run on device -
YOUR_PACKAGENAME (test)
: testing code to be run on development machine -
YOUR_PACKAGENAME.ui.theme
:-
Color.kt
: ARGB definition of color -
Theme.kt
: Material Design 3 Theme with dark and light modes -
Type.kt
: definition of type faces, weights, and sizes used
-
-
Theming and UI
One can easily spend a whole weekend (or longer) getting the theme “just right.” It’s best to just leave this folder alone, most of the time.
We won’t be grading you on how beautiful your UI looks. You’re free to design your UI differently, so long as all indicated UI elements are fully visible on the screen, non overlapping, and functioning as specified.
-
/app/res/
: resource files-
drawable
: vector image and icon assets -
mipmap
: bitmap assets at different resolutions -
values
: constants for colors, strings, themes (must not delete) -
xml
: rules and configurations, such as security configuration
-
-
/Gradle Scripts
: build scripts (see next section)
loading . . .
If your project pane doesn’t look like the above, wait for Android Studio to finish syncing and building and configuring, which usually take a long time on first load of after any project changes, your project should then be structured per the above.
Gradle build setup
Gradle scipt is the build script for your Android project. Staying in your Project
pane, open the file /Gradle Scripts/libs.version.toml
. Under the [versions]
block, update the following two entries:
# . . .
kotlin = "2.2.10"
# . . .
compose-bom = "2025.08.00"
# . . .
“BoM” stands for “Bill of Materials.” It lists the latest versions of libraries as of the listed date that are compatible with each other.
At the bottom of the file, under the [plugins]
section, add the line:
kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref= "kotlin" }
Next, open the /Gradle Scripts/build.gradle.kts (Module:app)
file.
this is the Module gradle file, listed second in
/Gradle Scripts/
, not the first listed Project gradle file /Gradle Scripts/build.gradle.kts (Project: composeChatter)
. Subsequently, we will refer to /Gradle Scripts/build.gradle.kts (Module:app)
as the “app build file”.
Inside the plugins
block, add the line:
alias(libs.plugins.kotlinSerialization)
After and outside the android
block add:
kotlin {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_11)
optIn.addAll("androidx.compose.material3.ExperimentalMaterial3Api",
"kotlinx.serialization.ExperimentalSerializationApi")
}
}
Scroll down until you see the dependencies
block near the bottom of the file and
add the following lines inside the block, below the other implementation
statements:
implementation("androidx.compose.material3:material3:1.4.0-beta02")
implementation("androidx.compose.material:material-icons-core:1.7.8")
implementation("androidx.navigation:navigation-compose:2.9.3")
implementation("com.squareup.okhttp3:okhttp:5.1.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0")
OkHttp
is a library from Square Inc. used for network transfers.
Kotlin serialization
helps with converting Kotlin data structure to/from JSON.
compileSDK, minSDK, targetSDK
In the android
block of your app build file, set compileSDK
to the latest Android API level, e.g., 36. This will give you access to the latest Android libraries and compiler warnings of deprecated features or APIs. The compileSDK
only affects the compilation step; the SDK is not bundled with your app. Set the minSDK
to the Android version running on your device or whose API your code depends on (sometimes APIs change signatures between Android versions). You can have compileSDK = 36
(Android 16), but minSDK = 31
(Android 11), for example.
We will set minSDK = 36
also. Finally, there’s targetSDK
, which is the Android version your app has been tested on. On a device running an Android version higher than your targetSDK
, OS behavior (e.g., themes) released after your targetSDK
will not be applied to your app. The targetSDK
can be set to between minSDK
and compileSDK
, usually it will be set to compileSDK
. Google PlayStore also has a minimum supported version (API Level 35 starting August 31st, 2025). Both minSDK
and targetSDK
(if different) will be bundled with your app. Starting with Android 11 (API Level 11 (R)), when new APIs are added to a certain API level, it may also be made available as SDK Extensions
to earlier API levels (see also references).
Highlighted dependencies
Android Studio will highlight your newly added implementation
dependencies. If you hover over the highlighted depency, for example over “androidx.compose.material3:material3:1.4.0-alpha18)”, a dialog box pops up suggesting, “Replace with new library catalog definition for androidx-material” You can ignore the recommendation or accept by clicking on it. If you accept, the line will be replaced with:
implementation(libs.compose.material3)
and if you look into /Gradle Scripts/libs.versions.toml
, you’ll see under [versions]
a new entry has been added:
# . . .
material3 = "1.4.0-alpha18"
and under [libraries]
the following line has been added:
compose-material3 = { module = "androidx.compose.material3:material3", version.ref = "material3" }
You can accept (or not) all the recommendations to convert each of the remaining dependencies with the “new library catalog definitions”.
Should you accept the recommendations, click Sync Now
on the right corner of the messsage bar that says “Gradle files have changed since last project sync. . . .” After a successful Gradle sync, it should then recognize the new libraries and all the new implementation
lines should not be highlighted anymore.
Permission, colors, and strings
First we need user’s permission to use the network.
In AndroidManifest.xml
, before the <application
block, add:
<uses-permission android:name="android.permission.INTERNET"/>
We also want to allow dismissing of the soft keyboard when changing focus.
In the <activity
block, under android:exported="true"
, add:
android:windowSoftInputMode="adjustResize"
Next we define some additional colors. Open the file /app/java/YOUR_PACKAGENAME.ui.theme/Color.kt
and add the following colors–some of these we will use in latter tutorials:
val WhiteSmoke = Color(0xFFEFEFEF)
val HeavenWhite = Color(0xFFFEFEFE)
val Gray88 = Color(0xFFE0E0E0)
val Chartreuse = Color(0xFFDFFF00)
val Moss = Color(0xFF526822)
val DarkGreen = Color(0xFF006400)
val Navy = Color(0, 39, 76)
val NavyLight = Color(0x8800274C)
val Maize = Color(255, 203, 5)
val MaizeLight = Color(0x88FFC84C)
val Canary = Color(0xFFFFC107)
val Firebrick = Color(0xFFB22222)
Finally, let’s define some strings. Open the file /app/res/values/strings.xml
. Inside the resources
block, below the line listing your app_name
, add:
<string name="chatter">llmPrompt</string>
<string name="send">Send</string>
<string name="model">tinyllama</string>
<string name="username">tinyllama</string> <!-- instead of uniqname -->
<string name="message">howdy?</string>
<string name="instruction">Type a message…</string>
Chatter app
Chatt
In all our tutorials, we will use a structure called Chatt
to hold exchanges with the backend to be displayed on screen. We store the definition of this structure in a file called
Chatt.kt
. Create a new Kotlin File (not Class):
- Right click on
/app/kotlin+java/PACKAGE_NAME
folder on the left/project pane - Select
New > Kotlin Class/File
-
Enter
Chatt
in theName
text field on top of the dialog box that pops up and double click onFile
(again notClass
(screenshot))When you select
New > Kotlin Class/File
, Android Studio defaults to creating a Kotlin Class, which automatically adds a blank class definition for you, whereas we want an empty file here. So be sure to choose “File” not “Class”.Please remember this distinction between creating a Kotlin File vs. Class. You will need to make this distinction in all subsequent tutorials.
- A
chatt
holds at the minimum the following fields. When theChatter
app is used to interact with Ollama, theusername
field may be used to hold the LLM model instead of the actual user’s name. Similarly, themessage
field will be used to hold user’s prompt to Ollama in such use case. Place the following class definition forChatt
in the newly created file:class Chatt(var username: String? = null, var message: MutableState<String>? = null, var id: UUID? = randomUUID(), var timestamp: String? = null)
Compose can use the
id
to uniquely identify each instance of aChatt
in a list. When identifiable items in a list moved up or down the list but otherwise not modified, Compose can skip recomposing them.
With auto import enabled, as you enter code, Android Studio automatically detects and determines which library to import.
In case of multiple potential matches, Android Studio would have you choose. Most of the time the choice would be rather obvious (pick the one with the word compose
since we’re using Jetpack Compose, for example). We always provide a full list of imports as an Appendix to each tutorial spec. Compare your import list against the list in the Appendix when in doubt. Or you could cut and paste all of the imports into your source files before any code. If you choose to cut and paste the imports, be sure that you do NOT check Optimize imports on the fly
in Android Studio’s Preferences/Settings, otherwise Android Studio will automatically remove them all for being unused and not needed.
ChattStore
Create another Kotlin File (not Class), call it `ChattStore.kt.
While the frontend sends messages to the backend in the form of Chatt
messages, Ollama can either response with OllamaError
or OllamaReply
. Put the following
in your ChattStore.kt
:
@Serializable
data class OllamaError(val error: String)
@Serializable
@JsonIgnoreUnknownKeys
data class OllamaReply(val model: String, val created_at: String, val response: String)
The @Serializable
annotation generates code to convert JSON strings
received from the network into these Kotlin classes.
Then add the following ChattStore
singleton:
object ChattStore {
var chatts = mutableStateListOf<Chatt>()
private set
private const val serverUrl = "https://YOUR_SERVER_IP"
}
Once you have implemented your own back-end server, you will replace
mada.eecs.umich.edu
with your server’s IP address.
Declaring ChattStore
an object
makes it a singleton, meaning there will ever be only one instance of this class when the app runs. We will keep user’s interaction with Ollama in the chatts
array. Since we want only a single copy of the chatt
s data, we make this a singleton object.
The array chatts
is initialized with a mutableStateListOf
, which is of type SnapshotStateList
, an observable version of List
. When a Jetpack Compose function (a composable) reads the value of an observable variable (the subject), the composable is automatically subcribed to the subject, i.e., it will be notified and the composable will automatically recompose when the value changes. If the subject is updated but the new value is the same as the old value, recomposition will not be triggered (duplicates are removed). The chatts
array will be used to hold user exchanges with Ollama. While we want chatts
to be readable outside the class, we don’t want it publicly modifiable, and so we have set its “setter” to private
.
Since chatt
s are retrieved from and posted to the chatterd
back-end
server, we will keep all network functions to communicate with the server as
methods of this class. We use the OkHttp library for network communication.
This library does not have a built-in suspending coroutine for initiating network
request, though we can easily write one. Add the following code to your
ChattStore
class:
private val client = OkHttpClient.Builder()
.connectTimeout(0, TimeUnit.MILLISECONDS)
.readTimeout(0, TimeUnit.MILLISECONDS)
.writeTimeout(0, TimeUnit.MILLISECONDS)
.build()
private suspend fun Call.await() = suspendCoroutine { cont ->
enqueue(object : Callback {
override fun onResponse(call: Call, response: Response) {
cont.resume(response)
}
override fun onFailure(call: Call, e: IOException) {
cont.resumeWithException(e)
}
})
}
Aside from adding a suspending extension function, await()
, to OkHttp’s Call
interface, we also instantiated an OkHttpClient
which we’ll reuse for all
of our network calls in the app. For this tutorial, we will have only one
network function, llmPrompt()
.
To send a prompt to the backend, the user calls the suspending function llmPrompt()
, which starts by appending the user’s prompt to the chatts
array. Add the following function definition to your ChattStore
class:
suspend fun llmPrompt(chatt: Chatt, errMsg: MutableState<String>) {
chatts.add(chatt)
// prepare prompt
}
The errMsg
parameter of llmPrompt()
is of type MutableState<String>
, which means that updating its value
property will notify observers of the
variable. We’ll see later that updating errMsg
will cause an alert dialog box to
pop up, to warn the user.
For this tutorial, we interact with Ollama using its generate
API. Ollama’s generate
API expects incoming prompts to be JSON objects with the following fields:
{
"model": "string",
"prompt": "string",
"stream": boolean
}
We use the data passed in through chatt
to create such a JSON object for Ollama.
Add the following code to your llmPrompt()
, replacing // prepare prompt
:
val jsonObj = mapOf(
"model" to chatt.username,
"prompt" to chatt.message?.value,
"stream" to true,
)
val requestBody = JSONObject(jsonObj).toString()
.toRequestBody("application/json; charset=utf-8".toMediaType())
// prepare request
We first assemble a Kotlin map comprising the key-value pairs of data we want to post to the server. We can’t just post the Kotlin map as is though. The server may not, and actually is not, written in Kotlin, and in any case could have a different memory layout for various data structures. Presented with a chunk of binary data, the server will not know that the data represents a map, nor how to reconstruct the map in its own map layout. To post the Kotlin map, therefore, we first call JSONObject()
to encode it into a serialized JSON object that the server will know how to parse. Then we put it a stringify version in a requestBody
.
Below we use the requestBody
to build a POST Request
, with the appropriate
URL. Add the following code to the function, replacing // prepare request
:
val apiUrl = "${serverUrl}/llmprompt"
val request = Request.Builder()
.url(apiUrl)
.addHeader("Accept", "application/*")
.post(requestBody)
.build()
// connect to chatterd and Ollama
We initiate a connection to our chatterd
backend and send the request.
Our backend simply forwards the request to Ollama. Check that the connection
has been made successfully. If we fail to connect to our backend (the catch
block) or Ollama returned any HTTP error, we simply report it to the user,
and end session. Replace // connect to chatterd and Ollama
with:
try {
val response = client.newCall(request).await()
if (!response.isSuccessful) {
errMsg.value = parseErr(response.code.toString(),
apiUrl, response.body.string())
return
}
// prepare placeholder
} catch (e: Throwable) {
errMsg.value = "llmPrompt: ${e.localizedMessage ?: "failed"}"
}
If the connection has been made successfully, we create a placeholder chatt
for the incoming response and append it to the chatts
array. The response is
streamed and we want each arriving element to be displayed right away, hence
the need for a placeholder chatt
. Put the following code at the end of your
do
block, replacing // prepare placeholder
:
val resChatt = Chatt(
username = "assistant (${chatt.username ?: "ollama"})",
message = mutableStateOf(""),
timestamp = Instant.now().toString()
// import java.time.Instant, not kotlin.time.Instant
)
chatts.add(resChatt)
// receive Ollama response
Finally, we receive each newline-delimited JSON (NDJSON) response and, if
the line is not empty, we deserialize it into OllamaReply
. The deserialization
is done using Kotlin’s serialization
package. Upon successful deserialization,
the response
property in OllamaReply
is appended to the message
property
of our placeholder resChatt
which will trigger a reactive update of the
display. Put the following code at the end of your do
block, replace
// receive Ollama response
:
val stream = response.body.source()
while (!stream.exhausted()) {
val line = stream.readUtf8Line() ?: continue
try {
val ollamaResponse = Json.decodeFromString<OllamaReply>(line)
resChatt.message?.value += ollamaResponse.response
} catch (e: IllegalArgumentException) {
errMsg.value += parseErr(e.localizedMessage, apiUrl, line)
resChatt.message?.value += "\nllmPrompt Error: ${errMsg.value}\n\n"
}
}
Here’s the parseErr()
helper function, put it inside your ChattStore
class, outside the llmPrompt()
function:
private fun parseErr(code: String?, apiUrl: String, line: String): String {
try {
val errJson = Json.decodeFromString<OllamaError>(line)
return errJson.error
} catch (e: IllegalArgumentException) {
return "$code\n${apiUrl}\n${line}"
}
}
ChattViewModel
We will have several variables accessed by multiple composables. Instead of
passing these variables back and forth, we put them in a viewmodel. A composable
that requires access to these variables can easily reach for the viewmodel in
Android’s built-in ViewModelStore
. Put the following class in your
MainActivity.kt
file, outside the MainActivity
class:
class.
class ChattViewModel(app: Application): AndroidViewModel(app) {
val model = app.getString(R.string.model)
val username = app.getString(R.string.model)
val instruction = app.getString(R.string.instruction)
val message = TextFieldState(app.getString(R.string.message))
val errMsg = mutableStateOf("")
}
We set the username
property to be the model
requested of the LLM to help
with the display of user prompt vs. LLM response. We declare the properties
message
and errMsg
to be of type MutableState
so that, when changed,
they can trigger a reactive update of the composable(s) observing them.
Delete the composable functions Greeting()
and GreetingPreview()
and replace your MainActivity
class definition with the following:
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
MainView()
}
}
}
As a general rule, a @Composable
function can only be called by another @Composable
function. The only exception is setContent()
, which binds the given composable to the Activity as the root view of the Activity. It is the only non-composable allowed to call a composable.
@Preview and LiveEdit
When Android Studio created MainActivity.kt
it also put in it a @Preview
block. As the name implies, the @Preview
code allows you to preview your composables. The preview only renders your composable, it is not an emulator, it won’t populate your composable with data. I found the preview of to be of limited use and would just delete or comment out the whole @Preview
block, which automatically disables the preview and closes the Design (or Split) pane. The video, Compose Design Tools, shows you what is possible with @Preview
. Also check out the @Preview section of the References below.
Android Studio Giraffe and higher supports LiveEdit
which “update composables in emulators and physical devices in real time.” While @Preview
allows you to see your UI design in different themes, locales, and UI element settings but does not actually run the rest of your app, LiveEdit
updates your actual running app. “Live Edit is focused on UI- and UX-related code changes. It doesn’t support changes such as method signature updates, adding new methods, or class hierarchy changes.”
ChattScrollView
We want to display user exchanges with Ollama in a timeline view. First we define what
each row of the timeline contains. Create a new Kotlin File, ChattScrollView.kt
,
and put the following composable in it:
@Composable
fun ChattView(chatt: Chatt, isSender: Boolean) {
Column(
horizontalAlignment = if (isSender) Alignment.End else Alignment.Start,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp)
) {
// chatt displayed here
}
}
For each chatt
, we check whether we’re displaying the user’s message or a response
from Ollama. In the former case, we display the row flush right, else flush left.
The modifier fillMaxWidth()
forces the Column
to use the full width of the screen.
Below we check if the message is empty. If it’s not empty, we first display the
sender’s name if it is not from the user. Then we display the message in a “message
bubble”, followed by the timestamp on the message. We put these three elements inside a Column
which arranges its elements in a vertical column. Add the
following lines inside your Column{}
block, replacing // chatt displayed here
:
chatt.message?.let { msg ->
if (msg.value.isNotEmpty()) {
Text(
text = if (isSender) "" else chatt.username ?: "",
style = MaterialTheme.typography.labelLarge,
color = PurpleGrey40,
modifier = Modifier
.padding(start = 4.dp)
)
Text(
text = msg.value,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier
.shadow(2.dp, shape = RoundedCornerShape(20.dp))
.background(if (isSender) Chartreuse else HeavenWhite)
.padding(12.dp)
.widthIn(min = 30.dp, max = 300.dp)
)
Text(
text = chatt.timestamp ?: "",
color = Color.Gray,
style = MaterialTheme.typography.labelSmall,
modifier = Modifier
.padding(top = 4.dp, start = 8.dp)
)
}
}
Hover over the red elements and import any missing classes. Usually the first choice with compose
in its package name is the right one.
"dp", "px", "sp"
Aside from different screen sizes, different Android devices also have different screen densities, i.e., number of pixels per inch. To ensure UI elements have more-or-less uniform sizes on screens with different densities, Google recommends that sizes be expressed in terms of device-independent pixels (dp) which is then displayed using more or less pixels (px) depending on screen density (see also Cracking Android Screen Sizes and Designing for Multiple Screen Densisites on Android).
Text sizes are measured in sp (scale-independent-pixel) unit, which specifies font sizes relative to user’s font-size preference—to assist visually-impaired users.
If your locale has a language that reads left to right, start
is the same as left
; for languages that read right to left (RTL), start
is the same as right
(conversely and similarly end
). Most of the time you would use start
and end
to refer to the two ends of a UI element, reserving left
and right
to use with the physical world, e.g., when giving direction.
You can hover over a composable (e.g., Column
, Text
, or
Scaffold`) to bring up a menu of possible actions on it.
DSL
Notice how type inference and the use of trailing lamba makes Row
, Column
, Text
, etc. look and act like keywords of a programming language used to describe the UI, separate from Kotlin. Hence Compose is also considered a “domain-specific language (DSL)”, the “domain” in this case being UI description.
Now that we have a description of each row, we can put the rows in a list. Put the
the following View in your ChattScrollView.kt
file, outside ChattView
:
@Composable
fun ChattScrollView(modifier: Modifier, listScroll: LazyListState) {
val vm: ChattViewModel = viewModel()
LazyColumn(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(12.dp),
state = listScroll,
) {
items(items = chatts, key = { it.id as Any }) {
ChattView(it, it.username == vm.username)
}
}
}
The variable listScroll
is used to scroll the list programmatically. Here we’re
simply assigni the provided LazyListState
to the LazyColumn
. We will use it
in SubmitButton
later to scroll the list programmatically.
For each element in the chatts
array in ChattStore
, ChattView
constructs and
returns a composable, which LazyColumn
then displays. LazyColumn
only loads array
elements that are visible on screen. The function items()
recomposes an item iff the key
of that item has changed—for example, if a row’s chatt.id
has not changed, the row will not be recomposed even if it gets moved around the array.
Without id
, each row will be recomposed if it moves up or down in the array even
if the content of the row hasn’t changed.
Recall that the chatts
array in
ChattStore
is a MutableStateList
. When a composable accesses the chatts
array, SwiftUI automatically subscribes the composable to it so that the
copmosable can be automatically recomposed chatts
is modified.
ChattScrollView
helps ChattView
determine whether a chatt
belongs to the
user by comparing the sender’s username
against the username
stored in the
viewmodel obtained from Android’s built-in ViewModelStore
, retrieved using
the built-in composable function viewModel()
.
SubmitButton
While ChattView
displays each chatt
and ChattScrollView
puts the ChattView
s
in a scrollable list, SubmitButton
actually sends each user’s prompt to the backend
and receives Ollama’s response and put both in the chatts
array for
ChattScrollView
to display.
Create another new Kotlin File, name it MainView.kt
, and put the code for
SubmitButton
in it:
@Composable
fun SubmitButton(listScroll: LazyListState) {
val vm: ChattViewModel = viewModel()
var isSending by rememberSaveable { mutableStateOf(false) }
// `by` property delegation needs to import `setValue`
// and `getValue` separately, so do import twice
IconButton(
onClick = {
isSending = true
vm.viewModelScope.launch(Dispatchers.Default) {
llmPrompt(Chatt(username = vm.model,
message = mutableStateOf(vm.message.text.toString()),
timestamp = Instant.now().toString()
// import java.time.Instant, not kotlin.time.Instant
), vm.errMsg)
// completion code
}
},
// modifiers
) {
// icons
}
}
remember and rememberSaveable
As previously discusssed, to publish a subject, we make it a MutableState
using mutableStateOf()
or mutableStateListOf()
. A composable that uses a published variable automatically subscribes to it. You don’t need to use remember()
to subscribe to a published variable. Use remember()
only if you want to retain states declared in composables across recompositions. This is normally used for view-logic states.
Other than recomposition, states in a composable can also be destroyed by events impacting Activity
lifecycle, such as device-orientation change. To save composable states across changes in orientation, use rememberSaveable()
. States in a singleton object
and in ViewModel
s are not destroyed across recompositions nor Activity lifecycles.
There are three ways we can use remember()
:
- Directly assign a remembered mutable state to a variable:
val message = remember { mutableStateOf("A message") } // type: MutableState<String>
the variable will then be of type
MutableState<T>
and to modify the value stored in the variable, we modify it’svalue
property, e.g.,message.value = "Another message"
. This method is required to specifyMutableState<T>
as a function or (data)class parameter. - Use property delegation (
by
):val message by remember { mutableStateOf("A message") } // type: String
we implicitly cast the state object as object of type
T
in Compose, which makes working with the variable more convenient, e.g., we can directly assign a literal value of typeT
to the variable.T
beingString
in this case. This is the most convenient method where it works. - Or destructure the remembered state into its getter and setter:
val (message, setMessage) = remember { mutableStateOf("A message") }
The getter (
message
) is then used to read the state and its setter (setMessage
) to set the state. When used with legacy version ofTextField()
, itsvalue
parameter will be assigned the getter,message
, and itsonValueChange
parameter assigned the setter,setMessage
. Note that restructuredmessage
is not updated (it’s aval
) whensetMessage
is called. If the assignment is inside a composable functionmessage
is re-initialized during recomposition so it “looks like” it is updated.
We launch llmPrompt()
using vm.viewModelScope
so that our network operations
survives the composable lifecycles, which got terminated when you change the orientation
of your device, for example.
Upon returning from llmPrompt()
, we reset vm.message
and isSending
. Then
we scroll the display to the bottom of displayed chatts
.
Since scroll the display modifies the UI, we need to
conduct this on the AndroidUiDispatcher.Main
thread. Add the following code
inside the launch {}
block, replacing the comment // completion code
:
vm.message.clearText()
isSending = false
withContext(AndroidUiDispatcher.Main) {
listScroll.animateScrollToItem(chatts.size)
}
To disable the button if isSending
is true
or if there’s no message to send,
add the following modifiers to IconButton
by replacing the comment // modifiers
:
modifier = Modifier
.size(55.dp)
.background(if (vm.message.text.isEmpty()) NavyLight else Navy,
shape = CircleShape),
enabled = !(isSending || vm.message.text.isEmpty()),
For the icon itself, we provide two options: one to show a “loading” view if
we’re still waiting for Ollama’s response (isSending
is true
) and one to show
a “paperplane” submit icon otherwise. Add the following code as IconButton
’s content
parameter, replacing the comment // icons
:
if (isSending) {
CircularProgressIndicator(
color = Gray88,
strokeWidth = 4.dp,
modifier = Modifier.size(24.dp)
)
} else {
Icon(
Icons.AutoMirrored.Filled.Send,
contentDescription = stringResource(R.string.send),
tint = if (vm.message.text.isEmpty()) MaizeLight else Maize,
modifier = Modifier.size(28.dp)
)
}
Again, hover over the red elements and import any missing classes. Usually the first choice with compose
in its package name is the right one. For Instant
, you need
to import java.time.Instant
instead of kotlin.time.Instant
. Property delegation
using by
requires import of androidx.compose.runtime.setValue
and androidx.compose.runtime.getValue
as two separate import action, so you’d need to hover
over by
and do the import twice.
MainView
We now have all the pieces we need to build our MainView
. Add to your
MainView.kt
, outside the SubmitButton
:
@Composable
fun MainView() {
val vm: ChattViewModel = viewModel()
val layoutDirection = LocalLayoutDirection.current
val listScroll = rememberLazyListState()
val focus = LocalFocusManager.current
Scaffold(
topBar = {
TopAppBar(
title = {
Text(
text = stringResource(R.string.chatter),
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center
)
},
colors = TopAppBarDefaults.topAppBarColors(WhiteSmoke),
)
},
// tap background to dismiss keyboard
) {
// describe the content
}
}
We initialize a LazyListState
that we pass to ChattScrollView
to control the
scrolling of the LazyColumn
it instantiate, which is done in SubmitButton
.
Scaffold
is a composable that implements the basic Material Design visual layout structure, providing slots for the most common top-level components such as topbar, floating action button, and others. By using Scaffold
, we ensure the proper positioning of these components and that they interoperate smoothly. Scaffold
, TopAppBar
are examples of layout composables that follow the slot-based layout, a.k.a. Slot API pattern of Compose. In our case, we add
a TopAppBar
that consists of only a textbox containing the string Chatter
.
Why topBar = { TopBar() }?
The topBar
parameter of Scaffold
take as argument a composable with zero parameter and Unit
return value. In Kotlin, a function is not a reference,
to assign the composable function TopAppBar()
to topBar
, we must wrap it
in a parameterless lambda returning Unit
value, which we did above.
When the user taps any where on the screen other than the TextField
below,
we want to dismiss the soft keyboard. Replace // tap background to dismiss keyboard
with:
modifier = Modifier
.pointerInput(Unit) {
detectTapGestures { focus.clearFocus() }
}
Scaffold
’s last parameter, content
, also takes a composable as argument.
Since it is the last argument, we have presented it as a trailing lambda in the above. Let’s show the chatt
timeline here. Replace // describe the content
with the following code:
Column(
modifier = Modifier
.fillMaxSize()
.imePadding()
.padding(
it.calculateStartPadding(layoutDirection),
it.calculateTopPadding(),
it.calculateEndPadding(layoutDirection),
)
.background(color = WhiteSmoke),
) {
ChattScrollView(modifier = Modifier.weight(1f), listScroll)
// prompt input and submit
}
The modifier imePadding()
causes the Column
to not extend behind the soft
keyboard when it is shown. The padding()
modifier sets padding according to
the safe area of the screen, but let the bottom padding extends to the edge of
the screen. Then MainView
puts the ChattScrollView
at the top of its
column.
Below ChattScrollView
, we now put a text box, where user can enter their Ollama
prompt, and the SubmitButton
. We put these text box and button inside a Row
. Elements in a Row
are displayed side by side horizontally. Replace // prompt input and submit
with:
Row(verticalAlignment = Alignment.Bottom,
modifier = Modifier
.padding(top = 12.dp, start = 20.dp, end = 20.dp, bottom = 40.dp)
) {
OutlinedTextField(
state = vm.message,
placeholder = {
Text(text = vm.instruction, color = Color.Gray)
},
shape = RoundedCornerShape(40.dp),
modifier = Modifier
.weight(1f)
.padding(horizontal = 10.dp),
textStyle = LocalTextStyle.current.copy(fontSize = 18.sp),
colors = TextFieldDefaults.colors(
unfocusedContainerColor = HeavenWhite,
focusedContainerColor = HeavenWhite,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent
),
lineLimits = TextFieldLineLimits.MultiLine(1, 6),
)
SubmitButton(listScroll)
}
// show error
OutlinedTextField
can modify vm.message
as if it were passed
by reference. We also give vm.instruction
to TextField()
, which will be
shown as a “background” text that automatically goes away when the user starts
typing. We also pass listScroll
to SubmitButton
so that it can
programmatically scroll the screen to the last item it added to the chatts
array.
Before we leave MainView
, we check whether the value in vm.errMsg
. If
it’s not empty, we show an alert dialog with the error message in vm.errMsg
.
Replace // show error
with:
if (vm.errMsg.value.isNotEmpty()) {
AlertDialog(
modifier = Modifier
.shadow(0.dp, shape = RoundedCornerShape(20.dp))
.padding(12.dp)
.widthIn(min = 30.dp, max = 300.dp),
onDismissRequest = {
vm.errMsg.value = ""
},
confirmButton = {
TextButton(onClick = {
vm.errMsg.value = ""
}) {
Text(
"OK",
fontSize = 28.sp,
fontWeight = FontWeight.Bold,
)
}
},
title = {
Text(
"LLM Error",
fontWeight = FontWeight.Bold
)
},
text = {
Text(
vm.errMsg.value,
fontSize = 20.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp
)
}
)
}
Again, hover over the red elements and import any missing classes. Usually the first choice with compose
in its package name is the right one.
Congratulations! You’re done with the front end! (Don’t forget to work on the backend!)
Run and test to verify and debug
You should now be able to run your front end against the provided back end on mada.eecs.umich.edu
. Change serverUrl
in ChattStore
from YOUR_SERVER_IP
to mada.eecs.umich.edu
.
If you’re not familiar with how to run and test your code, please review the instructions in the Getting Started with Android Development.
Completing the back end
Once you’re satisfied that your front end is working correctly, follow the back-end spec to build your own back end:
With your back end completed, return here to prepare your front end to connect to your back end via HTTP/2 with HTTPS.
Installing your self-signed certificate
Download a copy of chatterd.crt
to YOUR*TUTORIALS
on your laptop. Enter the following commands:
laptop$ cd YOUR*TUTORIALS
laptop$ scp -i reactive.pem ubuntu@YOUR_SERVER_IP:reactive/chatterd.crt chatterd.crt
Install chatterd.crt
onto your Android: download chatterd.crt
from your laptop onto your emulator or device:
-
If you don’t see your device mirrored on Android Studio, connect it to Android Studio for USB debugging, then turn on device mirroring by selecting
File/Android Studio > Settings > Tools > Device Mirroring
and checkingEnable mirroring of physical Android device
. To view the mirrored device, selectView > Tool Windows > Running Devices
. -
With your emulator or mirrored device visible in your Android Studio, drag
chatterd.crt
on your laptop and drop it on the home screen of the emulator or mirrored device.Alternatively, you could also email
chatterd.crt
to yourself, then on the device/emulator, view your email and tap the attachedchatterd.crt
.
You can verify that your certificate is installed in Settings > Security & privacy > More security settings > Encryption & credentials > User credentials
.
To test the installion, launch a web browser on the emulator or device and access your server at
https://YOUR_SERVER_IP/llmprompt
. Since /llmprompt
does not have a GET
method, the browser
may say, Cannot GET /llmprompt
. As long as you’re not getting a security-related error message,
it indicates that your self-signed certificate is installed correctly.
Preparing Chatter
Next, we need to tell Chatter
to trust the self-signed certificate.
On the left pane in Android Studio, right click the xml
folder in /app/res/
, choose New > File
in the drop-down menu. Name the new XML file network_security_config.xml
and put the following content in the file:
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<domain-config>
<!-- 👇👇👇👇👇👇👇👇👇 -->
<domain includeSubdomains="true">YOUR_SERVER_IP</domain>
<trust-anchors>
<certificates src="user"/>
</trust-anchors>
</domain-config>
</network-security-config>
WARNING: be sure to limit the use of the self-signed certificate to your back-end server IP as shown above. In particular do not use the <base-config>
tag because it will cause your app to try and fail to apply the self-signed certificate with other services, such as Google Maps.
Add the following line that accounts for the new file to your AndroidManifest.xml
, above the existing android:theme
line:
<application
<!-- ... other items -->
android:networkSecurityConfig="@xml/network_security_config"
android:theme="@style/Theme.composeChatter">
Finally, change the serverUrl
property of your ChattStore
class from mada.eecs.umich.edu
to YOUR_SERVER_IP
.
Build and run your app and you should now be able to connect your mobile front end to your back end via HTTPS.
Your frontend must work with both mada.eecs.umich.edu and your backend.
You will not get full credit if your submitted front end is not set up to work with your backend!
Front-end submission guidelines
We will only grade files committed to the main
branch. If you’ve created multiple branches, please merge them all to the main branch for submission.
Push your front-end code to the same GitHub repo you’ve submitted your back-end code:
- Open GitHub Desktop and click on
Current Repository
on the top left of the interface - Click on the GitHub repo you created at the start of this tutorial
- Add Summary to your changes and click
Commit to main
at the bottom of the left pane - Since you have pushed your back end code, you’ll have to click
Pull Origin
to synch up the repo on your laptop - Finally click
Push Origin
to push all changes to GitHub
Go to the GitHub website to confirm that your front-end files have been uploaded to your GitHub repo
under the folder
llmprompt
. Confirm that your repo has a folder structure outline similar to the following. If
your folder structure is not as outlined, our script will not pick up your submission, you will get ZERO point, and
you will further have problems getting started on latter tutorials. There could be other files or folders in your
local folder not listed below, don’t delete them. As long as you have installed the course .gitignore
as per the
instructions in Preparing GitHub for Reactive, only
files needed for grading will be pushed to GitHub.
reactive
|-- chatterd
|-- chatterd.crt
|-- llmprompt
|-- composeChatter
|-- app
|-- gradle
Verify that your Git repo is set up correctly: on your laptop, grab a new clone of your repo and build and run your submission to make sure that it works. You will get ZERO point if your tutorial doesn’t open, build, or run.
IMPORTANT: If you work in a team, put your team mate’s name and uniqname in your repo’s README.md
(click the pencil icon at the upper right corner of the README.md
box on your git repo) so that we’d know. Otherwise, we could mistakenly think that you were cheating and accidentally report you to the Honor Council, which would be a hassle to undo. You don’t need a README.md
if you work by yourself.
Invite eecsreactive@umich.edu
to your GitHub repo. Enter your uniqname (and that of your team mate’s) and the link to your GitHub repo on the Tutorial and Project Links sheet. The request for teaming information is redundant by design.
References
Dev
- Android Studio
- Gradle: build tool and dependency manager for Android
-
Picking your compileSdk
Version, minSdkVersion, and targetSdkVersion - Google Play developer account
- GooglePlay’s Testing Tracks
- Publishing app to Play Store
OkHttp3
- okhttp3
- How does OkHttp get Json string?
- Android Http Requests in Kotlin with OkHttp
- OkHttp3 Response
- OkHttp3 ResponseBody
- OkHttp3 executeAsync [nonJvm]
NDJSON
Jetpack Compose Concepts
- Understanding Jetpack Compose
- Thinking in Compose
- Architecting your Compose UI
- Layouts in Compose
- Slot API
- Lists
- State and Jetpack Compose
- Observer vs Pub-Sub pattern
- Navigating with Compose
- Get started with Jetpack Compose
- Compose tooling
- Material Components and layout
Tutorial Pathways
Documentations
- Use Android Studio with Jetpack Compose
- androidx.compose
- Scaffold
- Column
- LazyColumn
- Button
- MutableState
- mutableStateOf()
- mutableStateListOf()
- remember
- rememberSaveable
3rd-party articles on Jetpack Compose
Depending on date of publication, Compose APIs used in 3rd-party articles may have been deprecated or their signatures may have changed. Always consult the authoritative official documentation and change logs for the most up to date version.
Screen sizes and densities
- How to Find Device Metrics for Any Screen
- Designing for multiple screen densities on Android
- DP vs SP vs DPI vs PX in Android
- Screen sizes and densities market distribution
- Screen compatibility overview
Layout and Components
ViewModel
ConstraintLayout and Compose
LiveEdit and @Preview
- Deep dive into Live Edit for Jetpack Compose UI
- Iterative code development
- Composable Preview
- Jetpack Compose and Composable Preview
- Jetpack Compose Preview like a pro
- The power of @Preview
Themes and Styles
- Setting up Themes
- How to create a truly custom theme in Jetpack Compose
- Surfaces
- Sample Code with Surface
- Material Design Color System scroll all the way down until you get to the “2014 Material Design color palettes”
- Access default icon in SDK
- Material Design Icons
- Material Design Icons Guide
- Add multi-density vector graphics
Appendix: imports
Prepared by Alex Wu, Tiberiu Vilcu, Nowrin Mohamed, Chenglin Li, Xin Jie ‘Joyce’ Liu, and Sugih Jamin | Last updated: August 27th, 2025 |