Skip to content

Commit 949ab7e

Browse files
authored
UX cleanup (#8)
* Show errors if failed to get postman collection * Removed collapsing effect (was causing sizing issues) * Fixed issue with deselection * Added enabled count for visibility * Fixed issues with delay * Loading state moved * Added input to update api key if it is no longer valid * Sample can now easily show all available mocked URLs * Fixed issue where check for "contains" was causing incorrect hits on enabled mocks
1 parent 1e16693 commit 949ab7e

File tree

6 files changed

+335
-194
lines changed

6 files changed

+335
-194
lines changed

app/build.gradle.kts

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,18 +10,13 @@ android {
1010
compileSdk = 34
1111

1212
// Postman mocking setup pulled from local.properties
13-
val localProps = gradleLocalProperties(rootDir)
14-
val exampleDefaultUrl: String = localProps.getProperty("exampleDefaultUrl", "\"\"")
15-
1613
defaultConfig {
1714
applicationId = "com.steamclock.steamock"
1815
minSdk = 24
1916
targetSdk = 33
2017
versionCode = 1
2118
versionName = "1.0"
2219
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
23-
24-
buildConfigField("String", "exampleDefaultUrl", exampleDefaultUrl)
2520
}
2621

2722
buildTypes {

app/src/main/java/com/steamclock/steamock/ExampleApiRepo.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ class ExampleApiRepo(
1111
) {
1212
private val mutableApiResponse = MutableStateFlow("")
1313
val apiResponse = mutableApiResponse.asStateFlow()
14+
fun clearLastResponse() { mutableApiResponse.value = "" }
1415

1516
suspend fun makeRequest(fullUrl: String) {
1617
mutableApiResponse.emit("Loading...")

app/src/main/java/com/steamclock/steamock/MainActivity.kt

Lines changed: 175 additions & 126 deletions
Original file line numberDiff line numberDiff line change
@@ -3,42 +3,48 @@ package com.steamclock.steamock
33
import android.os.Bundle
44
import androidx.activity.ComponentActivity
55
import androidx.activity.compose.setContent
6-
import androidx.compose.animation.AnimatedVisibility
7-
import androidx.compose.animation.core.tween
8-
import androidx.compose.animation.fadeIn
9-
import androidx.compose.animation.fadeOut
10-
import androidx.compose.foundation.clickable
6+
import androidx.compose.foundation.background
7+
import androidx.compose.foundation.border
8+
import androidx.compose.foundation.layout.Box
119
import androidx.compose.foundation.layout.Column
1210
import androidx.compose.foundation.layout.Row
13-
import androidx.compose.foundation.layout.Spacer
11+
import androidx.compose.foundation.layout.fillMaxHeight
1412
import androidx.compose.foundation.layout.fillMaxSize
1513
import androidx.compose.foundation.layout.fillMaxWidth
16-
import androidx.compose.foundation.layout.height
1714
import androidx.compose.foundation.layout.padding
15+
import androidx.compose.foundation.layout.wrapContentHeight
1816
import androidx.compose.foundation.layout.wrapContentSize
19-
import androidx.compose.foundation.lazy.LazyColumn
20-
import androidx.compose.material.*
21-
import androidx.compose.material.icons.Icons
22-
import androidx.compose.material.icons.filled.KeyboardArrowDown
23-
import androidx.compose.material.icons.filled.KeyboardArrowUp
17+
import androidx.compose.foundation.rememberScrollState
18+
import androidx.compose.foundation.verticalScroll
19+
import androidx.compose.material.AlertDialog
20+
import androidx.compose.material.Button
21+
import androidx.compose.material.DropdownMenuItem
22+
import androidx.compose.material.ExperimentalMaterialApi
23+
import androidx.compose.material.ExposedDropdownMenuBox
24+
import androidx.compose.material.ExposedDropdownMenuDefaults
25+
import androidx.compose.material.MaterialTheme
26+
import androidx.compose.material.OutlinedTextField
27+
import androidx.compose.material.Surface
28+
import androidx.compose.material.Text
29+
import androidx.compose.material.TextButton
2430
import androidx.compose.runtime.Composable
2531
import androidx.compose.runtime.collectAsState
26-
import androidx.compose.ui.Modifier
27-
import androidx.compose.ui.unit.dp
28-
import androidx.lifecycle.lifecycleScope
29-
import com.steamclock.steamock.lib.PostmanMockConfig
30-
import com.steamclock.steamock.lib.repo.PostmanMockRepo
31-
import kotlinx.coroutines.launch
3232
import androidx.compose.runtime.getValue
3333
import androidx.compose.runtime.mutableStateOf
3434
import androidx.compose.runtime.remember
3535
import androidx.compose.runtime.rememberCoroutineScope
3636
import androidx.compose.runtime.setValue
3737
import androidx.compose.ui.Alignment
38-
import androidx.compose.ui.unit.sp
38+
import androidx.compose.ui.Modifier
39+
import androidx.compose.ui.graphics.Color
40+
import androidx.compose.ui.unit.dp
41+
import androidx.compose.ui.window.Dialog
42+
import com.steamclock.steamock.lib.PostmanMockConfig
3943
import com.steamclock.steamock.lib.repo.MockState
40-
import com.steamclock.steamock.lib.ui.ContentLoadViewState
44+
import com.steamclock.steamock.lib.repo.MockedAPI
45+
import com.steamclock.steamock.lib.repo.PostmanMockRepo
4146
import com.steamclock.steamock.lib.ui.AvailableMocks
47+
import kotlinx.coroutines.launch
4248

4349
class MainActivity : ComponentActivity() {
4450

@@ -80,95 +86,137 @@ class MainActivity : ComponentActivity() {
8086
super.onCreate(savedInstanceState)
8187

8288
setContent {
83-
val coroutineScope = rememberCoroutineScope()
84-
val mockCollectionState by postmanRepo.mockCollectionState.collectAsState()
8589
val exampleAPIResponse by exampleApiRepo.apiResponse.collectAsState()
86-
var exampleAPIUrl by remember { mutableStateOf(BuildConfig.exampleDefaultUrl) }
87-
90+
val mockedAPIs by postmanRepo.mockedAPIs.collectAsState()
91+
val coroutineScope = rememberCoroutineScope()
8892
var openAlertDialog by remember { mutableStateOf(true) }
89-
var showingStubs by remember { mutableStateOf(false) }
90-
var showingExample by remember { mutableStateOf(false) }
9193

92-
val stateText = when (val immutableState = mockCollectionState) {
93-
is ContentLoadViewState.Error -> immutableState.throwable.localizedMessage
94-
ContentLoadViewState.Loading -> "Fetching available Postman mocks..."
95-
else -> null
94+
if (openAlertDialog) {
95+
WelcomeDialog { openAlertDialog = false }
96+
}
97+
98+
if (exampleAPIResponse.isNotEmpty()) {
99+
ResponseDialog(
100+
text = exampleAPIResponse,
101+
onDismiss = { exampleApiRepo.clearLastResponse() }
102+
)
96103
}
97104

98105
// A surface container using the 'background' color from the theme
99106
Surface(
100107
modifier = Modifier.fillMaxSize(),
101108
color = MaterialTheme.colors.background
102109
) {
103-
LazyColumn {
104-
when (mockCollectionState) {
105-
is ContentLoadViewState.Error,
106-
is ContentLoadViewState.Loading -> {
107-
stateText?.let {
108-
item {
109-
Text(modifier = Modifier.padding(16.dp), text = stateText)
110-
Spacer(modifier = Modifier.height(16.dp))
111-
}
112-
}
113-
}
114-
is ContentLoadViewState.Success -> {
115-
// List all mocks
116-
item {
117-
CollapsableContent(
118-
title = "Available Postman Mocks",
119-
isExpanded = showingStubs,
120-
onRowClicked = { showingStubs = !showingStubs },
121-
content = { AvailableMocks(mockRepo = postmanRepo) }
122-
)
123-
}
124-
125-
// Setup intercept requests
126-
item {
127-
Spacer(modifier = Modifier.height(16.dp))
128-
129-
CollapsableContent(
130-
title = "Intercept Requests",
131-
isExpanded = showingExample,
132-
onRowClicked = { showingExample = !showingExample },
133-
content = {
134-
Column(
135-
modifier = Modifier.padding(16.dp)
136-
) {
137-
OutlinedTextField(
138-
value = exampleAPIUrl,
139-
onValueChange = { exampleAPIUrl = it }
140-
)
141-
142-
Button(
143-
onClick = {
144-
coroutineScope.launch {
145-
exampleApiRepo.makeRequest(exampleAPIUrl)
146-
}
147-
}
148-
) {
149-
Text(text = "Send Request")
150-
}
151-
152-
Text(
153-
text = exampleAPIResponse
154-
)
155-
}
156-
}
157-
)
158-
}
110+
Column {
111+
// Input to allow us to test the interception
112+
RequestSimulator(mockedAPIs) { url ->
113+
coroutineScope.launch {
114+
exampleApiRepo.makeRequest(url)
159115
}
160116
}
161117

162-
}
163-
164-
if (openAlertDialog) {
165-
WelcomeDialog { openAlertDialog = false }
118+
// Available mocks takes up the rest of the page
119+
Box(modifier = Modifier
120+
.fillMaxWidth()
121+
.weight(1f)
122+
.border(4.dp, MaterialTheme.colors.primary)) {
123+
AvailableMocks(
124+
mockRepo = postmanRepo
125+
)
126+
}
166127
}
167128
}
168129
}
130+
}
131+
}
132+
133+
/**
134+
* Shows a dropdown list of all the available API/endpoints in the Postman collection
135+
* that have at least one mock setup on them. Allows a user to select an API, and then
136+
* press a button to simulate making a request to that URL.
137+
*/
138+
@Composable
139+
private fun RequestSimulator(
140+
mockedAPIs: List<MockedAPI>,
141+
onSendRequest: (String) -> Unit
142+
) {
143+
var selectedAPIUrl by remember { mutableStateOf("") }
144+
145+
// Input to allow us to test the interception
146+
Column(
147+
modifier = Modifier
148+
.padding(16.dp)
149+
.wrapContentSize()
150+
) {
151+
Text(
152+
text = "Request Simulator",
153+
style = MaterialTheme.typography.h6
154+
)
155+
156+
MockedAPIDropdownMenu(mockedAPIs) {
157+
it.url?.let { url -> selectedAPIUrl = url }
158+
}
159+
160+
Text(
161+
modifier = Modifier.padding(vertical = 8.dp),
162+
text = selectedAPIUrl,
163+
style = MaterialTheme.typography.caption
164+
)
165+
166+
Button(
167+
onClick = { onSendRequest(selectedAPIUrl) }
168+
) {
169+
Text(text = "Send Request")
170+
}
171+
}
172+
}
173+
174+
@OptIn(ExperimentalMaterialApi::class) // ExposedDropdownMenuBox
175+
@Composable
176+
private fun MockedAPIDropdownMenu(
177+
options: List<MockedAPI>,
178+
onSelected: (MockedAPI) -> Unit
179+
) {
180+
var expanded by remember { mutableStateOf(false) }
181+
var selectedOption by remember { mutableStateOf("Select an endpoint") }
182+
183+
Column {
184+
ExposedDropdownMenuBox(
185+
expanded = expanded,
186+
onExpandedChange = {
187+
expanded = !expanded
188+
}
189+
) {
190+
OutlinedTextField(
191+
value = selectedOption,
192+
onValueChange = { },
193+
modifier = Modifier
194+
.fillMaxWidth()
195+
.exposedDropdownSize(true),
196+
label = { Text("Endpoint") },
197+
readOnly = true,
198+
trailingIcon = {
199+
ExposedDropdownMenuDefaults.TrailingIcon(
200+
expanded = expanded
201+
)
202+
},
203+
colors = ExposedDropdownMenuDefaults.textFieldColors()
204+
)
169205

170-
lifecycleScope.launch {
171-
postmanRepo.requestCollectionUpdate()
206+
ExposedDropdownMenu(
207+
expanded = expanded,
208+
onDismissRequest = { expanded = false }
209+
) {
210+
options.forEach { option ->
211+
DropdownMenuItem(onClick = {
212+
selectedOption = option.name
213+
onSelected(option)
214+
expanded = false
215+
}) {
216+
Text(text = option.name)
217+
}
218+
}
219+
}
172220
}
173221
}
174222
}
@@ -185,8 +233,8 @@ private fun WelcomeDialog(
185233
Text(
186234
text = "This app is meant to demonstrate how to use Postman to mock APIs in your Android app." +
187235
"To get started, you will need to update the local properties for the sample app with your mocking environment setup.\n\n" +
188-
"Once setup, you can view and enable all mocks in the \"Available Postman Mocks\" section below." +
189-
"The \"Intercept Requests\" section can be used to test how calls are intercepted and mocks are returned.",
236+
"Once setup, you can view and enable all mocks in the \"Mocks available for...\" section below." +
237+
"The \"Request Simulator\" section can be used to test how calls are intercepted and mocks are returned.",
190238
modifier = Modifier.padding(16.dp)
191239
)
192240
},
@@ -201,39 +249,40 @@ private fun WelcomeDialog(
201249
)
202250
}
203251

252+
/**
253+
* Shows the response from the API call in a dialog.
254+
*/
204255
@Composable
205-
private fun CollapsableContent(
206-
title: String,
207-
isExpanded : Boolean,
208-
onRowClicked: () -> Unit,
209-
content: @Composable () -> Unit
256+
private fun ResponseDialog(
257+
text: String,
258+
onDismiss: () -> Unit
210259
) {
211-
Divider()
212-
213-
Row(
214-
modifier = Modifier
215-
.fillMaxWidth()
216-
.clickable { onRowClicked() },
217-
verticalAlignment = Alignment.CenterVertically
218-
) {
219-
Text(
220-
modifier = Modifier.weight(1f).padding(8.dp),
221-
fontSize = 24.sp,
222-
text = title,
223-
)
224-
Icon(
225-
modifier = Modifier.wrapContentSize().padding(8.dp),
226-
imageVector = if (isExpanded) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown,
227-
contentDescription = null
228-
)
229-
}
230-
Divider()
260+
Dialog(
261+
onDismissRequest = { onDismiss() },
262+
content = {
263+
Column(
264+
modifier = Modifier.fillMaxHeight(0.7f).fillMaxWidth().background(Color.White)
265+
) {
266+
Box(
267+
modifier = Modifier.weight(1f).verticalScroll(rememberScrollState())
268+
) {
269+
Text(
270+
text = text,
271+
modifier = Modifier.padding(16.dp)
272+
)
273+
}
231274

232-
AnimatedVisibility(
233-
visible = isExpanded,
234-
enter = fadeIn(animationSpec = tween(200)),
235-
exit = fadeOut(animationSpec = tween(200))
236-
) {
237-
content()
238-
}
275+
Row(
276+
modifier = Modifier
277+
.wrapContentHeight()
278+
.align(Alignment.End)
279+
.padding(horizontal = 8.dp, vertical = 2.dp)
280+
) {
281+
TextButton(onClick = { onDismiss() }) {
282+
Text("Ok")
283+
}
284+
}
285+
}
286+
}
287+
)
239288
}

0 commit comments

Comments
 (0)