Skip to content

Commit

Permalink
Merge remote-tracking branch 'origin/main' into feature/alternative-r…
Browse files Browse the repository at this point in the history
…elease-workflow
  • Loading branch information
yuriisurzhykov committed May 31, 2024
2 parents a098701 + 60f1e58 commit 9dbad48
Show file tree
Hide file tree
Showing 7 changed files with 103 additions and 96 deletions.
59 changes: 16 additions & 43 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
![Super linter](https://github.com/yuriisurzhykov/Purs-Android/actions/workflows/android_lint_checker.yaml/badge.svg)
![Unit tests](https://github.com/yuriisurzhykov/Purs-Android/actions/workflows/android_tests_run.yaml/badge.svg)
[![Android Lint Checker](https://github.com/yuriisurzhykov/Purs-Android/actions/workflows/android_lint_checker.yaml/badge.svg)](https://github.com/yuriisurzhykov/Purs-Android/actions/workflows/android_lint_checker.yaml)
[![Tests check](https://github.com/yuriisurzhykov/Purs-Android/actions/workflows/android_tests_run.yaml/badge.svg)](https://github.com/yuriisurzhykov/Purs-Android/actions/workflows/android_tests_run.yaml)

# About

Expand Down Expand Up @@ -188,6 +188,7 @@ the `end_local_date` is 24:00. The "Open 24 hours" has to be displayed.
"end_local_time": "00:00:00"
}
```
Or no schema for the day, so that list of time slots is empty.

</details>

Expand All @@ -212,14 +213,7 @@ better to use an image as an asset croped for different screen sizes.

### Location selection

In the example JSON structure the only one location is available, but to make things more flexible
and scalable it would be better if we would open selection screen in case of multiple location
available. So the logic should be the following:

- If there is only one location in the structure, then a screen with details by working hours
immediately opens.
- If there is multiple locations the selection screen should be displayed.
- If no location received the dialog should appear to notify user about the failure
In the example JSON structure the only one location is available and the structure of JSON at the moment does not imply that more than one location will be sent, which means at the moment, we can limit ourselve to only one location at a time. In the future if need more than one location be available for user, the location details screen remains unchanged with only few changes: the use case to fetch location details needs to be modified and the location ID has to be passed to the use case.

### Location screen

Expand All @@ -244,39 +238,6 @@ Components:
right under the first time occurence.
- It's better to animate dropdown effect to make the UI smooth

## User flow

1. App Launch:
The app starts, and the user sees a loading screen or the main screen.
2. Location Selection Screen:
After loading, the user is presented with a screen to select a location from a list of available
locations.
3. Location Selection:
The user selects a location from the list.
Upon selection, the app navigates to the detailed working hours screen for the chosen location.
4. Working Hours Screen:
On this screen, the user sees the location name and its working hours.
The user can navigate back to the location selection screen to choose another location.

### Visualization of User Flow

<img src="https://github.com/yuriisurzhykov/Purs-Android/assets/44873047/0359dacb-0c88-4239-b2d3-f2b75f3355ed" alt="drawing" width="350"/>

### Location selection screen

#### UI Elements

- Navigation Bar/App Bar with the title "Select Location".
- List of locations (List in SwiftUI, LazyColumn in Jetpack Compose).
- Loading indicator (ProgressView in SwiftUI, CircularProgressIndicator in Jetpack Compose) while
data is being loaded.
- Each list item should be styled as a card (CardView) with the location name and an arrow
indicating navigation to the detail screen.

#### Actions

When a list item is tapped, the app navigates to the detailed working hours screen for the selected
location.
</details>

<details>
Expand Down Expand Up @@ -407,6 +368,18 @@ keyPassword=Purs2024
**NOTE:** You can leave all variables empty if you just want to make a debug build
</details>

<details>
<summary>TODO Next</summary>

## What TO DO next
Several enhancements are needed to finish the task:
- [ ] Update the current location status periodically to keep information up to date for the user [#44](https://github.com/yuriisurzhykov/Purs-Android/issues/44)
- [ ] Fix expand arrow animation which turns wrong angle if user clicks too fast [#45](https://github.com/yuriisurzhykov/Purs-Android/issues/45)
- [ ] Blur the background when user expand the location hours section [#46](https://github.com/yuriisurzhykov/Purs-Android/issues/46)
- [ ] Edge-to-edge stoped working properly, need to fix it [#47](https://github.com/yuriisurzhykov/Purs-Android/issues/47)
- [ ] Fix working hours alignment on expandable section [#48](https://github.com/yuriisurzhykov/Purs-Android/issues/48)
</details>

# Contacts

Email: yuriisurzhykov@gmail.com
7 changes: 4 additions & 3 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,16 @@ junit = "4.13.2"
mockk = "1.13.11"
junitVersion = "1.1.5"
espressoCore = "3.5.1"
androidx-lifecycle = "2.8.0"
androidx-lifecycle = "2.8.1"
activityCompose = "1.9.0"
composeBom = "2024.05.00"
androidx-compose-runtime = "1.6.7"
androidx-compose-runtime = "1.7.0-beta02"
androidx-compose-kotlin-compiler-ext = "1.5.14"
retrofit = "2.9.0"
kotlinx-coroutines = "1.8.0"
retrofitAdaptersResult = "1.0.9"
retrofitConverterKotlinxSerialization = "1.0.0"
appcompat = "1.6.1"
appcompat = "1.7.0"
material = "1.12.0"
room = "2.6.1"
ksp = "1.9.24-1.0.20"
Expand Down Expand Up @@ -50,6 +50,7 @@ androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", versi
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "androidx-lifecycle" }
androidx-lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "androidx-lifecycle" }
androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" }
androidx-lifecycle-runtime-compose-android = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose-android", version.ref = "androidx-lifecycle" }

androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ interface StringToDayOfWeekMapper : Mapper<String, DayOfWeek> {
class Base @Inject constructor() : StringToDayOfWeekMapper {
override fun map(source: String): DayOfWeek {
val weekNames = listOf("MON", "TUE", "WED", "THU", "FRI", "SAT", "SUN")
return DayOfWeek.of(weekNames.indexOf(source))
return DayOfWeek.of(weekNames.indexOf(source) + 1)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -70,37 +70,51 @@ interface BuildCurrentLocationStatusUseCase {
if (timeSlot.endTime < timeSlot.startTime) {
timeSlot.startTime.isBefore(currentTime)
} else {
timeSlot.endTime.isAfter(currentTime) && timeSlot.startTime.isBefore(currentTime)
(timeSlot.startTime == LocalTime.MIDNIGHT && timeSlot.endTime == LocalTime.MIDNIGHT) ||
(timeSlot.startTime.isBefore(currentTime) && timeSlot.endTime.isAfter(currentTime))
}
}
return if (currentOpenSchedule != null) {
val timeDifference =
if (currentOpenSchedule.endTime < currentOpenSchedule.startTime) {
currentTime.until(LocalTime.MAX, ChronoUnit.MINUTES) +
LocalTime.MIDNIGHT.until(currentOpenSchedule.endTime, ChronoUnit.MINUTES)
} else {
currentTime.until(currentOpenSchedule.endTime, ChronoUnit.MINUTES)
}
// If the location closes within 24 hours, return the location status
// that it closing soon. Otherwise, return the location status that it opens
return if (timeDifference <= 60) {
if (nextWorkingDay == null) {
return LocationStatus.Closing(currentOpenSchedule.endTime)
}
// If the current schedule is open 24h for today, return the location status that
// it open
if (currentOpenSchedule.startTime == LocalTime.MIDNIGHT &&
currentOpenSchedule.endTime == LocalTime.MIDNIGHT) {
LocationStatus.Open(currentOpenSchedule.endTime)
} else {
// Calculate time difference between current time and the end time of the location
val timeDifference =
if (currentOpenSchedule.endTime < currentOpenSchedule.startTime) {
currentTime.until(LocalTime.MAX, ChronoUnit.MINUTES) +
LocalTime.MIDNIGHT.until(
currentOpenSchedule.endTime,
ChronoUnit.MINUTES
)
} else {
currentTime.until(currentOpenSchedule.endTime, ChronoUnit.MINUTES)
}
// If the location closes within 24 hours, return the location status
// that it closing soon. Otherwise, return the location status that it opens
return if (timeDifference <= 60) {
if (nextWorkingDay == null) {
return LocationStatus.Closing(currentOpenSchedule.endTime)
}

val nextOpenTime = nextWorkingDay.second.startTime
val reopenTimeDifference = currentTime.until(nextOpenTime, ChronoUnit.HOURS)
if (reopenTimeDifference > 24) {
LocationStatus.ClosingSoonLongReopen(
currentOpenSchedule.endTime,
nextWorkingDay.first,
nextOpenTime
)
// Calculate time difference between current time and the start time of the
// next schedule and returns location status based on it
val nextOpenTime = nextWorkingDay.second.startTime
val reopenTimeDifference = currentTime.until(nextOpenTime, ChronoUnit.HOURS)
if (reopenTimeDifference > 24) {
LocationStatus.ClosingSoonLongReopen(
currentOpenSchedule.endTime,
nextWorkingDay.first,
nextOpenTime
)
} else {
LocationStatus.ClosingSoon(currentOpenSchedule.endTime, nextOpenTime)
}
} else {
LocationStatus.ClosingSoon(currentOpenSchedule.endTime, nextOpenTime)
LocationStatus.Open(currentOpenSchedule.endTime)
}
} else {
LocationStatus.Open(currentOpenSchedule.endTime)
}
} else null
}
Expand Down
1 change: 1 addition & 0 deletions ui-features/location-details/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ dependencies {
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.graphics)
implementation(libs.androidx.material3)
implementation(libs.androidx.lifecycle.runtime.compose.android)
debugImplementation(libs.androidx.compose.ui.tooling.preview)
debugImplementation(libs.androidx.compose.ui.tooling)
debugImplementation(libs.androidx.compose.ui.test.manifest)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextAlign
import com.github.yuriisurzhykov.purs.domain.model.TimeSlot
import java.time.LocalTime
import java.time.format.DateTimeFormatter
Expand All @@ -15,15 +16,17 @@ import java.util.Locale
fun LocalTimeTextView(
time: TimeSlot,
modifier: Modifier = Modifier,
style: TextStyle = MaterialTheme.typography.bodyLarge
style: TextStyle = MaterialTheme.typography.bodyLarge,
textAlign: TextAlign? = null
) {
Text(
text = stringResource(id = R.string.format_patter_time).format(
time.startTime.toFormattedString(),
time.endTime.toFormattedString()
),
modifier = modifier,
style = style
style = style,
textAlign = textAlign
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,13 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
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.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
Expand All @@ -53,6 +53,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage
import com.github.yuriisurzhykov.purs.domain.model.Location
Expand All @@ -76,18 +77,35 @@ internal fun LocationDetails(
viewModel: LocationDetailsViewModel,
modifier: Modifier = Modifier
) {
val state = viewModel.detailsResponse.collectAsState().value
BackgroundImage(modifier = modifier.fillMaxSize())
val state: State by viewModel.detailsResponse.collectAsStateWithLifecycle(lifecycleOwner = androidx.compose.ui.platform.LocalLifecycleOwner.current)
var blurContent by remember { mutableStateOf(false) }
BackgroundImage(blurContent = blurContent, modifier = Modifier.fillMaxSize())
if (state != State.None) {
Content(state = state, modifier = modifier.fillMaxSize())
Content(state = state, modifier = modifier.fillMaxSize()) { expanded ->
blurContent = expanded
}
}
}

@Composable
internal fun Content(state: State, modifier: Modifier = Modifier) {
fun BackgroundImage(blurContent: Boolean, modifier: Modifier = Modifier) {
AsyncImage(
model = R.drawable.screen_background,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = if (blurContent) modifier.blur(radiusX = 15.dp, radiusY = 15.dp) else modifier
)
}

@Composable
internal fun Content(
state: State,
modifier: Modifier = Modifier,
onExpand: (Boolean) -> Unit = {}
) {
Column(modifier = modifier) {
if (state is State.Success && state.location != null) {
LocationDetailsMain(state.location)
LocationDetailsMain(state.location, onExpand = onExpand)
}
if (state is State.Loading) {
CircularProgressIndicator(
Expand All @@ -96,12 +114,12 @@ internal fun Content(state: State, modifier: Modifier = Modifier) {
.padding(DefaultPadding)
)
if (state.location != null) {
LocationDetailsMain(location = state.location)
LocationDetailsMain(location = state.location, onExpand = onExpand)
}
}
if (state is State.Error) {
if (state.location != null) {
LocationDetailsMain(location = state.location)
LocationDetailsMain(location = state.location, onExpand = onExpand)
}
state.error?.let {
Toast.makeText(LocalContext.current, it.message.orEmpty(), Toast.LENGTH_SHORT)
Expand All @@ -112,7 +130,7 @@ internal fun Content(state: State, modifier: Modifier = Modifier) {
}

@Composable
internal fun LocationDetailsMain(location: Location) {
internal fun LocationDetailsMain(location: Location, onExpand: (Boolean) -> Unit = {}) {
Text(
text = location.locationName,
style = TextStyle(
Expand All @@ -126,7 +144,7 @@ internal fun LocationDetailsMain(location: Location) {
)

Box(modifier = Modifier.fillMaxSize()) {
OperatingHoursBox(location = location)
OperatingHoursBox(location = location, onExpand = onExpand)
Box(
modifier = Modifier
.padding(bottom = 16.dp)
Expand All @@ -138,7 +156,11 @@ internal fun LocationDetailsMain(location: Location) {
}

@Composable
internal fun OperatingHoursBox(location: Location, modifier: Modifier = Modifier) {
internal fun OperatingHoursBox(
location: Location,
modifier: Modifier = Modifier,
onExpand: (Boolean) -> Unit = {}
) {
var expanded by remember { mutableStateOf(false) }
val expandIconAngle = remember { Animatable(0f) }

Expand Down Expand Up @@ -179,6 +201,7 @@ internal fun OperatingHoursBox(location: Location, modifier: Modifier = Modifier
),
onClick = {
expanded = !expanded
onExpand.invoke(expanded)
})
.padding(DefaultPadding),
contentAlignment = Alignment.CenterStart
Expand Down Expand Up @@ -341,16 +364,6 @@ fun LocationStatusTextView(text: String, modifier: Modifier = Modifier) {
)
}

@Composable
fun BackgroundImage(modifier: Modifier = Modifier) {
AsyncImage(
model = R.drawable.screen_background,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = modifier
)
}

@Composable
fun WorkingHourView(workingDay: WorkingDay) {
val textWeight = if (workingDay.weekDay == LocalDate.now().dayOfWeek) {
Expand Down Expand Up @@ -385,7 +398,9 @@ fun WorkingHourView(workingDay: WorkingDay) {
workingDay.scheduleList.forEach { timeSlot ->
LocalTimeTextView(
timeSlot,
style = MaterialTheme.typography.bodyLarge.copy(fontWeight = textWeight)
style = MaterialTheme.typography.bodyLarge.copy(fontWeight = textWeight),
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.End
)
}
}
Expand Down

0 comments on commit 9dbad48

Please sign in to comment.