Last active
July 19, 2023 16:16
-
-
Save johnhiott/0908dfaa74192c47df8db4f073e22784 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | |
) | |
} | |
} | |
} | |
} | |
} | |
} | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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