3434package no.nordicsemi.android.common.scanner.view
3535
3636import androidx.annotation.DrawableRes
37+ import androidx.compose.foundation.background
3738import androidx.compose.foundation.clickable
3839import androidx.compose.foundation.layout.Arrangement
3940import androidx.compose.foundation.layout.Box
@@ -44,7 +45,7 @@ import androidx.compose.foundation.layout.WindowInsetsSides
4445import androidx.compose.foundation.layout.asPaddingValues
4546import androidx.compose.foundation.layout.displayCutout
4647import androidx.compose.foundation.layout.fillMaxSize
47- import androidx.compose.foundation.layout.fillMaxWidth
48+ import androidx.compose.foundation.layout.heightIn
4849import androidx.compose.foundation.layout.only
4950import androidx.compose.foundation.layout.padding
5051import androidx.compose.foundation.layout.union
@@ -54,6 +55,7 @@ import androidx.compose.foundation.lazy.items
5455import androidx.compose.foundation.shape.RoundedCornerShape
5556import androidx.compose.material3.ExperimentalMaterial3Api
5657import androidx.compose.material3.MaterialTheme
58+ import androidx.compose.material3.OutlinedCard
5759import androidx.compose.material3.Text
5860import androidx.compose.material3.pulltorefresh.PullToRefreshBox
5961import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
@@ -70,6 +72,7 @@ import androidx.compose.ui.graphics.painter.Painter
7072import androidx.compose.ui.res.painterResource
7173import androidx.compose.ui.res.stringResource
7274import androidx.compose.ui.text.style.TextOverflow
75+ import androidx.compose.ui.tooling.preview.Preview
7376import androidx.compose.ui.unit.dp
7477import androidx.hilt.navigation.compose.hiltViewModel
7578import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -86,19 +89,25 @@ import no.nordicsemi.android.common.scanner.viewmodel.ScanningState
8689import no.nordicsemi.android.common.scanner.viewmodel.UiState
8790import no.nordicsemi.android.common.ui.view.CircularIcon
8891import no.nordicsemi.android.common.ui.view.RssiIcon
92+ import no.nordicsemi.kotlin.ble.client.android.AdvertisingData
8993import no.nordicsemi.kotlin.ble.client.android.ScanResult
90- import kotlin.time.Duration.Companion.seconds
94+ import no.nordicsemi.kotlin.ble.client.android.preview.PreviewPeripheral
95+ import no.nordicsemi.kotlin.ble.core.Phy
96+ import no.nordicsemi.kotlin.ble.core.PrimaryPhy
97+ import kotlin.time.Duration
9198import kotlin.uuid.ExperimentalUuidApi
9299
93100@OptIn(ExperimentalMaterial3Api ::class )
94101@Composable
95102fun ScannerView (
96103 onScanResultSelected : (ScanResult ) -> Unit ,
97104 state : ScanFilterState = rememberFilterState(),
105+ timeout : Duration = Duration .INFINITE ,
98106 onScanningStateChanged : (Boolean ) -> Unit = {},
107+ verticalArrangement : Arrangement .Vertical = Arrangement .spacedBy(8.dp),
99108 deviceItem : @Composable (ScanResult ) -> Unit = { scanResult ->
100109 DeviceListItem (scanResult)
101- }
110+ },
102111) {
103112 val viewModel = hiltViewModel<ScannerViewModel >()
104113 viewModel.setFilterState(state)
@@ -117,7 +126,7 @@ fun ScannerView(
117126 // This would start scanning on each orientation change,
118127 // but there is a flag set in the ViewModel to prevent that.
119128 // User needs to pull to refresh to start scanning again.
120- viewModel.initiateScanning(timeout = 4 .seconds )
129+ viewModel.initiateScanning(timeout = timeout )
121130 }
122131
123132 val pullToRefreshState = rememberPullToRefreshState()
@@ -132,6 +141,7 @@ fun ScannerView(
132141 }
133142 },
134143 state = pullToRefreshState,
144+ modifier = Modifier .background(MaterialTheme .colorScheme.background)
135145 ) {
136146 val uiState by viewModel.uiState.collectAsStateWithLifecycle()
137147 DisposableEffect (uiState.isScanning) {
@@ -144,7 +154,8 @@ fun ScannerView(
144154 isLocationRequiredAndDisabled = isLocationRequiredAndDisabled,
145155 uiState = uiState,
146156 onClick = onScanResultSelected,
147- deviceItem = deviceItem
157+ verticalArrangement = verticalArrangement,
158+ deviceItem = deviceItem,
148159 )
149160 }
150161 }
@@ -156,6 +167,7 @@ internal fun ScannerContent(
156167 isLocationRequiredAndDisabled : Boolean ,
157168 uiState : UiState ,
158169 onClick : (ScanResult ) -> Unit ,
170+ verticalArrangement : Arrangement .Vertical = Arrangement .spacedBy(8.dp),
159171 deviceItem : @Composable (ScanResult ) -> Unit ,
160172) {
161173 when (uiState.scanningState) {
@@ -172,8 +184,9 @@ internal fun ScannerContent(
172184 modifier = Modifier .fillMaxSize(),
173185 contentPadding = WindowInsets .displayCutout
174186 .only(WindowInsetsSides .Horizontal )
175- .union(WindowInsets (left = 8 .dp, right = 8 .dp, top = 8 .dp, bottom = 8 .dp))
176- .asPaddingValues()
187+ .union(WindowInsets (left = 16 .dp, right = 16 .dp, top = 16 .dp, bottom = 16 .dp))
188+ .asPaddingValues(),
189+ verticalArrangement = verticalArrangement,
177190 ) {
178191 DeviceListItems (
179192 devices = uiState.scanningState.result,
@@ -197,7 +210,7 @@ internal fun LazyListScope.DeviceListItems(
197210 items(devices) { device ->
198211 Box (
199212 modifier = Modifier
200- .clip(RoundedCornerShape (16 .dp))
213+ .clip(RoundedCornerShape (4 .dp))
201214 .clickable { onScanResultSelected(device.latestScanResult) }
202215 ) {
203216 deviceItem(device.latestScanResult)
@@ -233,35 +246,129 @@ fun DeviceListItem(
233246 subtitle : String ,
234247 trailingContent : @Composable () -> Unit = { },
235248) {
236- Row (
237- modifier = Modifier
238- .fillMaxWidth()
239- .padding(16 .dp),
240- verticalAlignment = Alignment .CenterVertically ,
241- horizontalArrangement = Arrangement .spacedBy(16 .dp)
242- ) {
243- peripheralIcon?.let {
244- CircularIcon (
245- painter = it,
246- iconSize = 30 .dp
247- )
248- }
249- Column (
250- verticalArrangement = Arrangement .spacedBy(4 .dp),
251- modifier = Modifier .weight(1.0f )
249+ OutlinedCard {
250+ Row (
251+ modifier = Modifier
252+ .heightIn(min = 80 .dp)
253+ .padding(all = 16 .dp),
254+ verticalAlignment = Alignment .CenterVertically ,
255+ horizontalArrangement = Arrangement .spacedBy(16 .dp)
252256 ) {
253- Text (
254- text = title,
255- color = MaterialTheme .colorScheme.onSurface,
256- maxLines = 2 ,
257- overflow = TextOverflow .Ellipsis ,
258- style = MaterialTheme .typography.titleMedium,
259- )
260- Text (
261- text = subtitle,
262- style = MaterialTheme .typography.bodyMedium,
263- )
257+ peripheralIcon?.let {
258+ CircularIcon (
259+ painter = it,
260+ )
261+ }
262+ Column (
263+ verticalArrangement = Arrangement .spacedBy(4 .dp),
264+ modifier = Modifier .weight(1.0f )
265+ ) {
266+ Text (
267+ text = title,
268+ color = MaterialTheme .colorScheme.onSurface,
269+ maxLines = 2 ,
270+ overflow = TextOverflow .Ellipsis ,
271+ style = MaterialTheme .typography.titleMedium,
272+ )
273+ Text (
274+ text = subtitle,
275+ style = MaterialTheme .typography.bodyMedium,
276+ maxLines = 1 ,
277+ )
278+ }
279+ trailingContent()
264280 }
265- trailingContent()
266281 }
267282}
283+
284+ @Preview
285+ @Composable
286+ private fun DeviceListItemPreview () {
287+ DeviceListItem (
288+ peripheralIcon = painterResource(R .drawable.outline_bluetooth_24),
289+ title = " Nordic HRM" ,
290+ subtitle = " 12:34:56:78:9A:BC" ,
291+ trailingContent = {
292+ RssiIcon (rssi = - 60 )
293+ }
294+ )
295+ }
296+
297+ @Preview(showBackground = true )
298+ @Composable
299+ private fun ScannerContentPreview_empty () {
300+ ScannerContent (
301+ isLocationRequiredAndDisabled = false ,
302+ uiState = UiState (
303+ isScanning = true ,
304+ scanningState = ScanningState .Loading ,
305+ ),
306+ onClick = {},
307+ deviceItem = { DeviceListItem (it) }
308+ )
309+ }
310+
311+ @Preview(showBackground = true )
312+ @Composable
313+ private fun ScannerContentPreview_error () {
314+ ScannerContent (
315+ isLocationRequiredAndDisabled = false ,
316+ uiState = UiState (
317+ isScanning = true ,
318+ scanningState = ScanningState .Error (" Internal error" ),
319+ ),
320+ onClick = {},
321+ deviceItem = { DeviceListItem (it) }
322+ )
323+ }
324+
325+ @Preview(showBackground = true )
326+ @Composable
327+ private fun ScannerContentPreview () {
328+ val scope = rememberCoroutineScope()
329+
330+ ScannerContent (
331+ isLocationRequiredAndDisabled = false ,
332+ uiState = UiState (
333+ isScanning = true ,
334+ scanningState = ScanningState .DevicesDiscovered (
335+ result = listOf (
336+ ScannedPeripheral (
337+ scanResult = ScanResult (
338+ peripheral = PreviewPeripheral (
339+ scope = scope,
340+ name = " Nordic HRM" ,
341+ address = " 12:34:56:78:9A:BC" ,
342+ ),
343+ isConnectable = true ,
344+ advertisingData = AdvertisingData (raw = byteArrayOf(0x02 , 0x01 , 0x06 )),
345+ rssi = - 60 ,
346+ txPowerLevel = null ,
347+ primaryPhy = PrimaryPhy .PHY_LE_1M ,
348+ secondaryPhy = Phy .PHY_LE_1M ,
349+ timestamp = System .currentTimeMillis(),
350+ ),
351+ ),
352+ ScannedPeripheral (
353+ scanResult = ScanResult (
354+ peripheral = PreviewPeripheral (
355+ scope = scope,
356+ name = " A device with a very long name" ,
357+ address = " 00:11:22:33:44:55" ,
358+ ),
359+ isConnectable = true ,
360+ advertisingData = AdvertisingData (raw = byteArrayOf(0x02 , 0x01 , 0x06 )),
361+ rssi = - 60 ,
362+ txPowerLevel = null ,
363+ primaryPhy = PrimaryPhy .PHY_LE_1M ,
364+ secondaryPhy = Phy .PHY_LE_1M ,
365+ timestamp = System .currentTimeMillis(),
366+ ),
367+ )
368+ )
369+ ),
370+ ),
371+ onClick = {},
372+ deviceItem = { DeviceListItem (it) }
373+ )
374+ }
0 commit comments