Skip to content

Instantly share code, notes, and snippets.

@johnhiott
Last active July 19, 2023 16:16
Show Gist options
  • Save johnhiott/0908dfaa74192c47df8db4f073e22784 to your computer and use it in GitHub Desktop.
Save johnhiott/0908dfaa74192c47df8db4f073e22784 to your computer and use it in GitHub Desktop.
package com.atsplod.tidely.ui.home
import android.Manifest.permission.ACCESS_FINE_LOCATION
import android.annotation.SuppressLint
import android.app.Activity
import android.util.Log
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.BottomSheetScaffold
import androidx.compose.material.BottomSheetScaffoldState
import androidx.compose.material.BottomSheetValue
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.SnackbarResult
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Clear
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.rounded.MyLocation
import androidx.compose.material.icons.rounded.TrendingDown
import androidx.compose.material.icons.rounded.TrendingUp
import androidx.compose.material.rememberBottomSheetScaffoldState
import androidx.compose.material.rememberBottomSheetState
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
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.platform.LocalFocusManager
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.atsplod.tidely.R
import com.atsplod.tidely.data.location.AutocompleteResult
import com.atsplod.tidely.data.tide.CompleteTideData
import com.atsplod.tidely.data.tide.TideType
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import com.google.android.gms.maps.CameraUpdateFactory
import com.google.android.gms.maps.model.CameraPosition
import com.google.android.gms.maps.model.LatLng
import com.google.maps.android.compose.CameraPositionState
import com.google.maps.android.compose.rememberCameraPositionState
import kotlinx.coroutines.launch
import java.time.format.DateTimeFormatter
@SuppressLint("MissingPermission")
@OptIn(ExperimentalMaterialApi::class, ExperimentalPermissionsApi::class)
@Composable
fun HomeScreen(
viewModel: HomeScreenViewModel = hiltViewModel(),
onExplainPermission: () -> Unit,
) {
val mapState by viewModel.mapState.collectAsState()
when (val state = mapState) {
is HomeScreenViewModel.MapState.Loading -> {
Box(
modifier = Modifier
.fillMaxSize()
) {
CircularProgressIndicator(
modifier = Modifier.align(Alignment.Center)
)
}
}
is HomeScreenViewModel.MapState.Ready -> {
ReadyScreen(
initialLocation = state.initialLocation,
onExplainPermission = onExplainPermission,
viewModel = viewModel
)
}
}
}
@OptIn(ExperimentalPermissionsApi::class, ExperimentalMaterialApi::class)
@Composable
fun ReadyScreen(
viewModel: HomeScreenViewModel,
onExplainPermission: () -> Unit,
initialLocation: LatLng
) {
val focusManager = LocalFocusManager.current
val scaffoldState = rememberBottomSheetScaffoldState(
bottomSheetState = rememberBottomSheetState(
initialValue = BottomSheetValue.Collapsed
)
)
val coroutineScope = rememberCoroutineScope()
val activity = LocalContext.current as Activity
var queryText by rememberSaveable {
mutableStateOf("")
}
val uiState by viewModel.uiState.collectAsState()
val cameraPositionState = rememberCameraPositionState {
position = CameraPosition.fromLatLngZoom(
initialLocation,
10f
)
}
val locationPermissionState = rememberPermissionState(permission = ACCESS_FINE_LOCATION)
LaunchedEffect(uiState.cameraLocation) {
uiState.cameraLocation?.let {
cameraPositionState.animate(CameraUpdateFactory.newLatLngZoom(it, 10f))
}
}
LaunchedEffect(cameraPositionState.isMoving) {
if (!cameraPositionState.isMoving) {
viewModel.updateCameraLocation(cameraPositionState.position.target)
}
}
LaunchedEffect(uiState.stations) {
if (uiState.stations.isNotEmpty()) {
scaffoldState.bottomSheetState.expand()
}
}
HomeScreenContent(
scaffoldState = scaffoldState,
cameraPositionState = cameraPositionState,
isLocationAvailable = locationPermissionState.status.isGranted,
isLoading = uiState.isLoading,
autoCompleteResults = uiState.autoCompleteResults,
selectedStationId = uiState.selectedStationId,
stations = uiState.stations,
selectedLocation = uiState.selectedLocation,
onSelectStation = {
viewModel.selectStation(it)
},
queryText = queryText,
onMapClick = {
coroutineScope.launch {
viewModel.updateCameraLocation(it)
viewModel.findStations(it)
}
},
onAutoCompleteResultSelected = {
focusManager.clearFocus()
queryText = it.address
viewModel.selectAutoCompleteResult(it)
},
onQueryTextChanged = {
viewModel.searchPlaces(it)
queryText = it
},
onFabClick = {
when {
!viewModel.isLocationEnabled() -> {
coroutineScope.launch {
val snackbarResult = scaffoldState.snackbarHostState.showSnackbar(
message = "Location is disabled.",
actionLabel = "Enable Location"
)
when (snackbarResult) {
SnackbarResult.Dismissed -> {}
SnackbarResult.ActionPerformed -> {
viewModel.updateSettings(activity)
}
}
}
}
locationPermissionState.status.isGranted -> viewModel.getCurrentLocation()
else -> onExplainPermission()
}
},
onStationDetailsClicked = {
onExplainPermission()
}
)
}
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun HomeScreenContent(
scaffoldState: BottomSheetScaffoldState,
cameraPositionState: CameraPositionState,
isLocationAvailable: Boolean,
selectedStationId: Int?,
isLoading: Boolean,
autoCompleteResults: List<AutocompleteResult>?,
queryText: String,
stations: List<CompleteTideData>,
onSelectStation: (Int) -> Unit,
onMapClick: (LatLng) -> Unit,
onQueryTextChanged: (String) -> Unit,
onFabClick: () -> Unit,
selectedLocation: LatLng?,
onAutoCompleteResultSelected: (AutocompleteResult) -> Unit,
onStationDetailsClicked: () -> Unit
) {
BottomSheetScaffold(
scaffoldState = scaffoldState,
sheetPeekHeight = 0.dp,
sheetBackgroundColor = Color.Transparent,
sheetElevation = 0.dp,
floatingActionButton = {
FloatingActionButton(
onClick = onFabClick
) {
Icon(
imageVector = Icons.Rounded.MyLocation,
contentDescription = ""
)
}
},
sheetContent = {
Box(
modifier = Modifier
.background(color = Color.Transparent)
.fillMaxWidth()
) {
StationsPager(
selectedPage = selectedStationId,
tideData = stations,
onPageChanged = onSelectStation,
)
}
}
) { padding ->
Log.d("BLAH", padding.toString())
Box(
modifier = Modifier.fillMaxSize()
) {
MapView(
onMapClick = onMapClick,
onMarkerClicked = onSelectStation,
isLocationEnabled = isLocationAvailable,
stations = stations,
selectedStationId = selectedStationId,
cameraPositionState = cameraPositionState,
selectedLocation = selectedLocation
)
AnimatedVisibility(
visible = isLoading,
enter = fadeIn(),
exit = fadeOut()
) {
Box(
modifier = Modifier
.fillMaxSize()
.clickable { /*DO NOTHING*/ },
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator()
}
}
PlacesAutoComplete(
onTextChanged = onQueryTextChanged,
textValue = queryText,
results = autoCompleteResults,
onRowClick = onAutoCompleteResultSelected
)
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun StationsPager(
tideData: List<CompleteTideData>,
onPageChanged: (Int) -> Unit,
selectedPage: Int?,
) {
val pagerState = rememberPagerState()
LaunchedEffect(key1 = selectedPage) {
selectedPage?.let {
pagerState.animateScrollToPage(selectedPage)
}
}
LaunchedEffect(key1 = pagerState.settledPage) {
onPageChanged(pagerState.settledPage)
}
HorizontalPager(
pageCount = tideData.size,
contentPadding = PaddingValues(32.dp),
state = pagerState,
) { num ->
StationTideCard(
tideData = tideData[num]
)
}
}
@Composable
fun StationTideCard(
tideData: CompleteTideData
) {
val nextTidePrediction = tideData.nextTide
val stationName = tideData.station.displayName
Card(
modifier = Modifier
.fillMaxWidth()
.padding(4.dp)
) {
Column(modifier = Modifier.padding(16.dp)) {
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
when (nextTidePrediction?.tideType) {
TideType.HIGH ->
Icon(imageVector = Icons.Rounded.TrendingUp, contentDescription = "")
else ->
Icon(imageVector = Icons.Rounded.TrendingDown, contentDescription = "")
}
Column {
Text(
text = stationName,
style = MaterialTheme.typography.headlineSmall,
overflow = TextOverflow.Ellipsis,
maxLines = 1
)
Text(
text = stringResource(
id = when (nextTidePrediction?.tideType) {
TideType.HIGH -> R.string.tide_coming_in
else -> R.string.tide_going_out
},
nextTidePrediction?.time?.format(DateTimeFormatter.ofPattern("h:mm a"))
?: ""
)
)
Column {
tideData.getTides().forEach {
Row(
modifier = Modifier.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Icon(
modifier = Modifier.size(22.dp),
painter = painterResource(
when (it.tideType) {
TideType.HIGH -> R.drawable.high_tide
else -> R.drawable.low_tide
}
),
contentDescription = ""
)
Text(
text = it.time.format(DateTimeFormatter.ofPattern("h:mm a")),
fontWeight = if (it.time == nextTidePrediction?.time) FontWeight.Bold else null
)
Text(
text = it.tideType.name,
fontWeight = if (it.time == nextTidePrediction?.time) FontWeight.Bold else null
)
}
}
}
}
}
}
}
}
@Composable
fun PlacesAutoComplete(
onTextChanged: (String) -> Unit,
textValue: String,
results: List<AutocompleteResult>?,
onRowClick: (AutocompleteResult) -> Unit
) {
Box(
modifier = Modifier
.padding(16.dp)
.wrapContentHeight()
) {
val focusManager = LocalFocusManager.current
Column(
modifier = Modifier
.wrapContentHeight()
) {
OutlinedTextField(
value = textValue,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
keyboardActions = KeyboardActions(onSearch = {
focusManager.clearFocus()
}),
onValueChange = onTextChanged,
modifier = Modifier
.fillMaxWidth()
.focusable(false),
trailingIcon = {
IconButton(
onClick = {
onTextChanged("")
}
) {
Icon(
imageVector = when (textValue.isEmpty()) {
true -> Icons.Default.Search
else -> Icons.Default.Clear
},
contentDescription = "Clear",
)
}
},
colors = TextFieldDefaults.colors()
)
AnimatedVisibility(visible = results?.isNotEmpty() == true) {
LazyColumn(
modifier = Modifier
.background(MaterialTheme.colorScheme.background)
.wrapContentHeight()
.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
results?.forEachIndexed { index, result ->
item {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
onRowClick(results[index])
}
) {
Text(
modifier = Modifier.padding(8.dp),
text = result.address
)
}
}
}
}
}
}
}
}
package com.atsplod.tidely.ui.home
import android.Manifest
import android.annotation.SuppressLint
import android.app.Activity
import android.content.Context
import android.content.pm.PackageManager
import android.util.Log
import androidx.core.app.ActivityCompat
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.atsplod.tidely.data.TideRepository
import com.atsplod.tidely.data.location.AutocompleteResult
import com.atsplod.tidely.data.location.LocationClient
import com.atsplod.tidely.data.location.PlacesClient
import com.atsplod.tidely.data.tide.CompleteTideData
import com.google.android.gms.maps.model.LatLng
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
@SuppressLint("StaticFieldLeak")
class HomeScreenViewModel @Inject constructor(
@ApplicationContext private val appContext: Context,
private val tideRepository: TideRepository,
private val placesClient: PlacesClient,
private val locationClient: LocationClient
): ViewModel() {
private var autoCompleteJob: Job? = null
data class UiState(
val cameraLocation: LatLng?,
val selectedLocation: LatLng?,
val autoCompleteResults: List<AutocompleteResult>?,
val stations: List<CompleteTideData>,
val selectedStationId: Int?,
val hasError: Boolean,
val isLoading: Boolean,
)
private val _uiState: MutableStateFlow<UiState> = MutableStateFlow(
UiState(
cameraLocation = null,
stations = listOf(),
autoCompleteResults = null,
selectedLocation = null,
selectedStationId = null,
hasError = false,
isLoading = false,
)
)
val uiState = _uiState.asStateFlow()
sealed class MapState {
object Loading: MapState()
data class Ready(val initialLocation: LatLng): MapState()
}
private val _mapState: MutableStateFlow<MapState> = MutableStateFlow(
MapState.Loading
)
val mapState = _mapState.asStateFlow()
init {
viewModelScope.launch {
val hasPermission = ActivityCompat.checkSelfPermission(
appContext,
Manifest.permission.ACCESS_FINE_LOCATION
) == PackageManager.PERMISSION_GRANTED
val currentLocation = if (locationClient.isLocationEnabled() && hasPermission) {
locationClient.getCurrentLocation()
} else LatLng(32.0, -81.241)
_mapState.value = MapState.Ready(
initialLocation = currentLocation
)
findStations(currentLocation)
}
}
fun selectStation(index: Int) {
_uiState.update {
it.copy(
selectedStationId = index
)
}
}
fun updateSettings(activity: Activity) {
viewModelScope.launch {
locationClient.enableLocation(activity)
}
}
fun isLocationEnabled() = locationClient.isLocationEnabled()
fun getCurrentLocation() {
viewModelScope.launch {
val latLng = locationClient.getCurrentLocation()
_uiState.update {
it.copy(
cameraLocation = latLng
)
}
findStations(latLng)
_uiState.update {
it.copy(
cameraLocation = latLng
)
}
}
}
fun searchPlaces(query: String) {
autoCompleteJob?.cancel()
autoCompleteJob = viewModelScope.launch {
try {
val results = placesClient.getPlaces(query)
_uiState.update {
it.copy(
autoCompleteResults = results
)
}
} catch (exception: Exception) {
_uiState.update {
it.copy(
hasError = true
)
}
}
}
}
fun updateCameraLocation(latLng: LatLng) {
_uiState.update {
it.copy(
cameraLocation = latLng
)
}
}
fun selectAutoCompleteResult(result: AutocompleteResult) {
viewModelScope.launch {
try {
val latLng = placesClient.getPlaceLatLng(result.placeId)
_uiState.update {
it.copy(
cameraLocation = latLng,
autoCompleteResults = null
)
}
findStations(latLng)
} catch (exception: Exception) {
_uiState.update {
it.copy(
hasError = true
)
}
}
}
}
fun findStations(latLng: LatLng) {
viewModelScope.launch {
try {
_uiState.update {
it.copy(
isLoading = true,
selectedLocation = latLng
)
}
val data = tideRepository.getStationsForLocation(
latitude = latLng.latitude,
longitude = latLng.longitude,
maxNumberOfStations = 10
)
_uiState.update {
it.copy(
selectedStationId = 0,
isLoading = false,
stations = data,
selectedLocation = latLng,
cameraLocation = latLng
)
}
} catch (exception: Exception) {
_uiState.update {
it.copy(
isLoading = false,
hasError = true
)
}
}
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment