diff --git a/README.md b/README.md index ddbea3351e0bb6f99aa20cf5d78bbfc2704dc555..d6214e3349f98bc4ed5be292eb0f1d7946daa97c 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ technologies as little as possible. -### 🔧 Features Overview +### Features Overview | Feature | Description | |----------------------------|--------------------------------------------------------------| @@ -54,6 +54,7 @@ technologies as little as possible. ```bash cp backend/app/src/main/resources/env.example backend/app/src/main/resources/.env ``` + Edit the `.env` file with your configuration. 4. Set up required credentials: - Add `google-credentials.json` for Google Calendar API @@ -75,6 +76,12 @@ technologies as little as possible. ./gradlew :backend:app:bootRun --args='--spring.profiles.active=local' ``` +#### Run Clients +1. Open the project in Android Studio or IntelliJ IDEA +2. Sync the Gradle project to download dependencies +3. Choose the appropriate run configuration in IDE +4. Run (Shift+F10 or Control+R) + For detailed installation instructions, including setting up credentials and running client applications, see our [Getting Started Guide](https://github.com/effective-dev-opensource/Effective-Office/wiki/Getting-Started-with-Effective-Office) in the wiki. ## Project Structure @@ -123,7 +130,7 @@ An application for tracking foosball match results. It allows users to log games - [Tatyana Terleeva](https://t.me/tatyana_terleeva) - [Stanislav Radchenko](https://github.com/Radch-enko) - [Vitaly Smirnov](https://github.com/KrugarValdes) -- [Victoria Maksimovna](https://t.me/the_koheskine) +- [Viktoriya Kokh](https://t.me/the_koheskine) ## License The code is available as open source under the terms of the [MIT LICENSE](LICENSE). diff --git a/backend/feature/booking/calendar/google/src/main/kotlin/band/effective/office/backend/feature/booking/calendar/google/GoogleCalendarProvider.kt b/backend/feature/booking/calendar/google/src/main/kotlin/band/effective/office/backend/feature/booking/calendar/google/GoogleCalendarProvider.kt index 39b38216327ae7412788f12d516841d57392be4c..6c2e4cd9485f90a38a8fe7ba248fb9f44bc7efda 100644 --- a/backend/feature/booking/calendar/google/src/main/kotlin/band/effective/office/backend/feature/booking/calendar/google/GoogleCalendarProvider.kt +++ b/backend/feature/booking/calendar/google/src/main/kotlin/band/effective/office/backend/feature/booking/calendar/google/GoogleCalendarProvider.kt @@ -33,6 +33,10 @@ class GoogleCalendarProvider( private val logger = LoggerFactory.getLogger(GoogleCalendarProvider::class.java) + companion object { + private const val RESPONSE_STATUS_DECLINED = "declined" + } + @Value("\${calendar.default-calendar}") private lateinit var defaultCalendar: String @@ -203,6 +207,42 @@ class GoogleCalendarProvider( } } + /** + * Deletes an event from Google Calendar. + * + * @param calendarId The ID of the calendar containing the event + * @param eventId The ID of the event to delete + * @return true if the event was successfully deleted, false otherwise + */ + private fun deleteEvent(calendarId: String, eventId: String): Boolean { + return try { + calendar.events().delete(calendarId, eventId).execute() + logger.info("Successfully deleted event $eventId from calendar $calendarId because all participants declined") + true + } catch (e: GoogleJsonResponseException) { + logger.error("Failed to delete event $eventId from calendar $calendarId: {}", e.details) + false + } catch (e: Exception) { + logger.error("Unexpected error when deleting event $eventId from calendar $calendarId", e) + false + } + } + + /** + * Retrieves a list of events from a Google Calendar within a specified time range. + * + * This method performs the following operations: + * 1. Fetches events from the specified calendar within the given time range + * 2. Filters out events where all human participants have declined (and optionally deletes them) + * 3. Filters out events where any resource (room/workspace) has declined + * + * @param calendarId The ID of the Google Calendar to query + * @param from The start time for the query (inclusive) + * @param to The end time for the query (exclusive), or null for no end time limit + * @param q Optional search term to filter events by + * @param returnInstances Whether to expand recurring events into individual instances + * @return A filtered list of Google Calendar events + */ private fun listEvents( calendarId: String, from: Instant, @@ -210,31 +250,111 @@ class GoogleCalendarProvider( q: String? = null, returnInstances: Boolean = true ): List { - val eventsRequest = calendar.events().list(calendarId) + // Build the events request with required parameters + val eventsRequest = buildEventsRequest(calendarId, from, to, q, returnInstances) + + return try { + // Execute the request and get the events + val fetchedEvents = eventsRequest.execute().items ?: emptyList() + + // Process and filter the events + processEvents(fetchedEvents) + } catch (e: GoogleJsonResponseException) { + handleGoogleJsonException(e, calendarId) + emptyList() + } catch (e: Exception) { + logger.error("Unexpected error when listing events from Google Calendar for calendar ID: $calendarId", e) + emptyList() + } + } + + /** + * Builds a Google Calendar events request with the specified parameters. + */ + private fun buildEventsRequest( + calendarId: String, + from: Instant, + to: Instant?, + q: String?, + returnInstances: Boolean + ): Calendar.Events.List { + val request = calendar.events().list(calendarId) .setTimeMin(DateTime(from.toEpochMilli())) .setSingleEvents(returnInstances) - if (to != null) { - eventsRequest.timeMax = DateTime(to.toEpochMilli()) + // Add optional parameters if provided + to?.let { request.timeMax = DateTime(it.toEpochMilli()) } + q?.let { request.q = it } + + return request + } + + /** + * Processes and filters the fetched events according to business rules. + */ + private fun processEvents(events: List): List { + // Remove events where all human participants have declined + val eventsAfterParticipantCheck = events.filter { event -> + !shouldRemoveEventWithAllDeclinedParticipants(event) } - if (q != null) { - eventsRequest.q = q + // Remove events where any resource has declined + return eventsAfterParticipantCheck.filter { event -> + !hasAnyResourceDeclined(event) } + } - return try { - eventsRequest.execute().items ?: emptyList() - } catch (e: GoogleJsonResponseException) { - logger.error("Failed to list events from Google Calendar: {}", e.details) - if (e.statusCode == 404) { - logger.warn("Calendar with ID {} not found", calendarId) - } else if (e.statusCode == 403) { - logger.warn("Permission denied for calendar with ID {}", calendarId) - } - emptyList() - } catch (e: Exception) { - logger.error("Unexpected error when listing events from Google Calendar", e) - emptyList() + /** + * Checks if an event should be removed because all human participants have declined. + * If all participants have declined, attempts to delete the event. + * + * @return true if the event should be removed from results, false otherwise + */ + private fun shouldRemoveEventWithAllDeclinedParticipants(event: Event): Boolean { + val isDefaultOrganizer = event.organizer?.email == defaultCalendar + + // Only check events organized by our default calendar + if (event.attendees == null || event.attendees.isEmpty() || !isDefaultOrganizer) { + return false + } + + // Get all human participants (non-resource attendees) + val humanParticipants = event.attendees.filter { it.resource == false || it.resource == null } + + // Check if all human participants have declined + val allHumansDeclined = humanParticipants.isNotEmpty() && + humanParticipants.all { it.responseStatus == RESPONSE_STATUS_DECLINED } + + // If all humans declined, try to delete the event + if (allHumansDeclined) { + logger.warn("Deleting event with ID {} because all participants declined", event.id) + val deletionSucceeded = deleteEvent(defaultCalendar, event.id) + return deletionSucceeded + } + + return false + } + + /** + * Checks if any resource (room/workspace) attendee has declined the event. + * + * @return true if any resource has declined, false otherwise + */ + private fun hasAnyResourceDeclined(event: Event): Boolean { + return event.attendees?.any { attendee -> + attendee.resource == true && attendee.responseStatus == RESPONSE_STATUS_DECLINED + } ?: false + } + + /** + * Handles Google JSON API exceptions with appropriate logging. + */ + private fun handleGoogleJsonException(e: GoogleJsonResponseException, calendarId: String) { + logger.error("Failed to list events from Google Calendar: {}", e.details) + when (e.statusCode) { + 404 -> logger.warn("Calendar with ID {} not found", calendarId) + 403 -> logger.warn("Permission denied for calendar with ID {}", calendarId) + else -> logger.error("Google API error with status code {} for calendar ID {}", e.statusCode, calendarId) } } diff --git a/backend/feature/notifications/src/main/kotlin/band/effective/office/backend/feature/notifications/controller/NotificationDeduplicator.kt b/backend/feature/notifications/src/main/kotlin/band/effective/office/backend/feature/notifications/controller/NotificationDeduplicator.kt index 1ddba93e3c4b91d2f4c405cb803a7d1d6d31610b..44a0d9bdab64d446f086f14f67d2c4e2cd9b8dbd 100644 --- a/backend/feature/notifications/src/main/kotlin/band/effective/office/backend/feature/notifications/controller/NotificationDeduplicator.kt +++ b/backend/feature/notifications/src/main/kotlin/band/effective/office/backend/feature/notifications/controller/NotificationDeduplicator.kt @@ -10,7 +10,7 @@ import org.springframework.stereotype.Component @Component class NotificationDeduplicator { - private val ttlSeconds = 10L + private val ttlSeconds = 5L @OptIn(ExperimentalTime::class) private val seenEvents = ConcurrentHashMap() diff --git a/clients/tablet/composeApp/build.gradle.kts b/clients/tablet/composeApp/build.gradle.kts index 25a3b5a3810acd3d723c768bf73cc47710cd1bb0..b439ce2ec755aba5115f00cbff580abb8de339d0 100644 --- a/clients/tablet/composeApp/build.gradle.kts +++ b/clients/tablet/composeApp/build.gradle.kts @@ -84,8 +84,8 @@ android { targetSdk = libs.versions.android.targetSdk.get().toInt() applicationId = "band.effective.office.tablet" - versionCode = 1 - versionName = "0.0.2" + versionCode = 2 + versionName = "1.0.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" diff --git a/clients/tablet/composeApp/src/androidMain/kotlin/band/effective/office/tablet/App.android.kt b/clients/tablet/composeApp/src/androidMain/kotlin/band/effective/office/tablet/App.android.kt index babbbbe2df3c9fb6c550a004d9f9ee1c193c550c..262007a3a0dabba75d84fcd86fa52385e9617ddc 100644 --- a/clients/tablet/composeApp/src/androidMain/kotlin/band/effective/office/tablet/App.android.kt +++ b/clients/tablet/composeApp/src/androidMain/kotlin/band/effective/office/tablet/App.android.kt @@ -10,6 +10,7 @@ import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.annotation.RequiresApi import band.effective.office.tablet.root.RootComponent +import band.effective.office.tablet.time.TimeReceiver import com.arkivanov.decompose.defaultComponentContext class AppActivity : ComponentActivity() { @@ -18,15 +19,25 @@ class AppActivity : ComponentActivity() { var isRunKioskMode = false } + val timeReceiver by lazy { TimeReceiver(this) } + @RequiresApi(Build.VERSION_CODES.P) override fun onCreate(savedInstanceState: Bundle?) { runKioskMode() super.onCreate(savedInstanceState) + + timeReceiver.register() enableEdgeToEdge() val root = RootComponent(componentContext = defaultComponentContext()) setContent { App(root) } } + override fun onDestroy() { + // Unregister the time receiver + timeReceiver.unregister() + super.onDestroy() + } + @RequiresApi(Build.VERSION_CODES.P) private fun runKioskMode(){ val context = this diff --git a/clients/tablet/composeApp/src/androidMain/kotlin/band/effective/office/tablet/App.kt b/clients/tablet/composeApp/src/androidMain/kotlin/band/effective/office/tablet/App.kt index c5c283e46b39e427bbf8d511c51159197d93e29d..446f999b6b5f87b0472ec5edd31ff8d3a47a8633 100644 --- a/clients/tablet/composeApp/src/androidMain/kotlin/band/effective/office/tablet/App.kt +++ b/clients/tablet/composeApp/src/androidMain/kotlin/band/effective/office/tablet/App.kt @@ -1,13 +1,15 @@ package band.effective.office.tablet import android.app.Application -import android.util.Log +import band.effective.office.tablet.core.domain.manager.DateResetManager import band.effective.office.tablet.core.domain.model.SettingsManager +import band.effective.office.tablet.core.ui.inactivity.InactivityLifecycleCallbacks import band.effective.office.tablet.di.KoinInitializer import com.google.firebase.messaging.FirebaseMessaging import com.russhwolf.settings.SharedPreferencesSettings import org.koin.android.ext.android.get import org.koin.core.qualifier.named +import kotlin.time.Duration.Companion.minutes class App : Application() { @@ -24,6 +26,7 @@ class App : Application() { ) ) subscribeOnFirebaseTopics() + initializeInactivitySystem() } private fun subscribeOnFirebaseTopics() { @@ -32,4 +35,14 @@ class App : Application() { FirebaseMessaging.getInstance().subscribeToTopic(topic) } } + + private fun initializeInactivitySystem() { + InactivityLifecycleCallbacks.initialize( + application = this, + timeoutMs = 1.minutes.inWholeMilliseconds, + callback = { + DateResetManager.resetDate() + } + ) + } } \ No newline at end of file diff --git a/clients/tablet/composeApp/src/androidMain/kotlin/band/effective/office/tablet/time/TimeReceiver.kt b/clients/tablet/composeApp/src/androidMain/kotlin/band/effective/office/tablet/time/TimeReceiver.kt new file mode 100644 index 0000000000000000000000000000000000000000..60c5e84f787653ce72fa36b9c9b22c96b96cc4d4 --- /dev/null +++ b/clients/tablet/composeApp/src/androidMain/kotlin/band/effective/office/tablet/time/TimeReceiver.kt @@ -0,0 +1,56 @@ +package band.effective.office.tablet.time + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.util.Log +import band.effective.office.tablet.feature.main.domain.CurrentTimeHolder +import kotlinx.coroutines.flow.StateFlow +import kotlinx.datetime.Clock +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime + +/** + * A broadcast receiver that listens for time-related broadcasts and emits the current time. + */ +actual class TimeReceiver(private val context: Context) { + + actual val currentTime: StateFlow = CurrentTimeHolder.currentTime + + private val receiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + when (intent?.action) { + Intent.ACTION_TIME_TICK, Intent.ACTION_TIME_CHANGED -> { + CurrentTimeHolder.updateTime(getCurrentTime()) + } + } + } + } + + /** + * Registers the broadcast receiver to listen for time-related broadcasts. + */ + fun register() { + val filter = IntentFilter().apply { + addAction(Intent.ACTION_TIME_TICK) + addAction(Intent.ACTION_TIME_CHANGED) + } + context.registerReceiver(receiver, filter) + } + + /** + * Unregisters the broadcast receiver. + */ + fun unregister() { + context.unregisterReceiver(receiver) + } + + /** + * Gets the current time as a LocalDateTime. + */ + private fun getCurrentTime(): LocalDateTime { + return Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()) + } +} diff --git a/clients/tablet/composeApp/src/commonMain/kotlin/band/effective/office/tablet/root/RootComponent.kt b/clients/tablet/composeApp/src/commonMain/kotlin/band/effective/office/tablet/root/RootComponent.kt index 1ad530cd5a0a8f59f2ce70f00a431bd7b8af7759..d50101048ab69cd05f57073f75600c36ad2bbf88 100644 --- a/clients/tablet/composeApp/src/commonMain/kotlin/band/effective/office/tablet/root/RootComponent.kt +++ b/clients/tablet/composeApp/src/commonMain/kotlin/band/effective/office/tablet/root/RootComponent.kt @@ -2,7 +2,6 @@ package band.effective.office.tablet.root import band.effective.office.tablet.core.domain.model.EventInfo import band.effective.office.tablet.core.domain.model.RoomInfo -import band.effective.office.tablet.core.domain.model.Slot import band.effective.office.tablet.core.domain.useCase.CheckSettingsUseCase import band.effective.office.tablet.core.domain.useCase.ResourceDisposerUseCase import band.effective.office.tablet.core.ui.common.ModalWindow @@ -153,16 +152,10 @@ class RootComponent( componentContext = componentContext, initialEvent = config.event, roomName = config.room, - onDeleteEvent = ::handleDeleteEvent, onCloseRequest = modalNavigation::dismiss, ) } - private fun handleDeleteEvent(slot: Slot) { - val mainComponent = (childStack.value.active.instance as? Child.MainChild)?.component - mainComponent?.handleDeleteEvent(slot) - } - private fun createFastBookingComponent( config: ModalWindowsConfig.FastEvent, componentContext: ComponentContext diff --git a/clients/tablet/composeApp/src/commonMain/kotlin/band/effective/office/tablet/time/TimeReceiver.kt b/clients/tablet/composeApp/src/commonMain/kotlin/band/effective/office/tablet/time/TimeReceiver.kt new file mode 100644 index 0000000000000000000000000000000000000000..59afa56c0fa56319ba169c2642ed8af41ee12bbf --- /dev/null +++ b/clients/tablet/composeApp/src/commonMain/kotlin/band/effective/office/tablet/time/TimeReceiver.kt @@ -0,0 +1,14 @@ +package band.effective.office.tablet.time + +import kotlinx.coroutines.flow.StateFlow +import kotlinx.datetime.LocalDateTime + +/** + * A receiver that emits the current time. + */ +expect class TimeReceiver { + /** + * A flow that emits the current time. + */ + val currentTime: StateFlow +} diff --git a/clients/tablet/composeApp/src/iosMain/kotlin/band/effective/office/tablet/time/TimeReceiver.kt b/clients/tablet/composeApp/src/iosMain/kotlin/band/effective/office/tablet/time/TimeReceiver.kt new file mode 100644 index 0000000000000000000000000000000000000000..17e7c1556180125be9e1adabf34fcf670f1978fe --- /dev/null +++ b/clients/tablet/composeApp/src/iosMain/kotlin/band/effective/office/tablet/time/TimeReceiver.kt @@ -0,0 +1,44 @@ +package band.effective.office.tablet.time + +import band.effective.office.tablet.feature.main.domain.CurrentTimeHolder +import kotlinx.coroutines.flow.StateFlow +import kotlinx.datetime.Clock +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime +import platform.Foundation.NSNotificationCenter +import platform.Foundation.NSDefaultRunLoopMode +import platform.Foundation.NSRunLoop +import platform.Foundation.NSDate +import platform.Foundation.NSTimer +import platform.Foundation.timeIntervalSince1970 + +/** + * iOS implementation of TimeReceiver that uses a timer to update the current time. + */ +actual class TimeReceiver { + actual val currentTime: StateFlow = CurrentTimeHolder.currentTime + + private var timer: NSTimer? = null + + init { + // Update time every minute + timer = NSTimer.scheduledTimerWithTimeInterval( + 60.0, // 60 seconds + true, // repeats + { + CurrentTimeHolder.updateTime(getCurrentTime()) + } + ) + + // Add timer to the main run loop + NSRunLoop.mainRunLoop.addTimer(timer!!, NSDefaultRunLoopMode) + } + + /** + * Gets the current time as a LocalDateTime. + */ + private fun getCurrentTime(): LocalDateTime { + return Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()) + } +} diff --git a/clients/tablet/core/domain/src/commonMain/kotlin/band/effective/office/tablet/core/domain/manager/DateResetManager.kt b/clients/tablet/core/domain/src/commonMain/kotlin/band/effective/office/tablet/core/domain/manager/DateResetManager.kt new file mode 100644 index 0000000000000000000000000000000000000000..2324e09259948dba2c1aa6301941a1b7cb167fb4 --- /dev/null +++ b/clients/tablet/core/domain/src/commonMain/kotlin/band/effective/office/tablet/core/domain/manager/DateResetManager.kt @@ -0,0 +1,51 @@ +package band.effective.office.tablet.core.domain.manager + +import band.effective.office.tablet.core.domain.util.currentLocalDateTime +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import kotlinx.datetime.LocalDateTime + +/** + * Singleton manager that handles date reset operations. + * This class facilitates communication between different parts of the application + * that need to reset dates, such as when inactivity is detected. + */ +object DateResetManager { + // Create a CoroutineScope with a SupervisorJob to handle suspending functions + private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + + // Use a suspending function type for the callback + private var dateResetCallback: (suspend (LocalDateTime) -> Unit)? = null + + /** + * Registers a callback that will be called when the date needs to be reset. + * + * @param callback The callback to execute when the date needs to be reset. + * The callback receives the current local date and time. + */ + fun registerDateResetCallback(callback: suspend (LocalDateTime) -> Unit) { + dateResetCallback = callback + } + + /** + * Unregisters the date reset callback. + */ + fun unregisterDateResetCallback() { + dateResetCallback = null + } + + /** + * Triggers a date reset with the current local date and time. + * This will call the registered callback if one exists. + */ + fun resetDate() { + val callback = dateResetCallback ?: return + + // Launch a coroutine to execute the suspending callback + coroutineScope.launch { + callback(currentLocalDateTime) + } + } +} diff --git a/clients/tablet/core/domain/src/commonMain/kotlin/band/effective/office/tablet/core/domain/useCase/SlotUseCase.kt b/clients/tablet/core/domain/src/commonMain/kotlin/band/effective/office/tablet/core/domain/useCase/SlotUseCase.kt index 470d539fe71aeecfcaa47c8322d4d4614e707819..ca171fe975aa6296d8ad50cb2d37a9df68df8845 100644 --- a/clients/tablet/core/domain/src/commonMain/kotlin/band/effective/office/tablet/core/domain/useCase/SlotUseCase.kt +++ b/clients/tablet/core/domain/src/commonMain/kotlin/band/effective/office/tablet/core/domain/useCase/SlotUseCase.kt @@ -22,7 +22,7 @@ class SlotUseCase( currentEvent: EventInfo?, ): List { return events - .filter { it.startTime >= start && it.startTime <= finish } + .filter { it.startTime >= start && it.startTime < finish && it.finishTime <= finish } .fold( getEmptyMinSlots(start, finish, minSlotDur) ) { acc, eventInfo -> acc.addEvent(eventInfo) } @@ -61,9 +61,12 @@ class SlotUseCase( if (isEmpty()) return listOf(eventInfo.toSlot()) val list = this.toMutableList() list.removeEmptySlot(eventInfo) - val predSlot = list.firstOrNull { it.finish > eventInfo.startTime } ?: list.first() - val predSlotIndex = list.indexOf(predSlot) - list.add(predSlotIndex, eventInfo.toSlot()) + val predSlotIndex = list.indexOfFirst { it.start >= eventInfo.startTime } + if (predSlotIndex != -1) { + list.add(predSlotIndex, eventInfo.toSlot()) + } else { + list.add(eventInfo.toSlot()) + } return list } diff --git a/clients/tablet/core/ui/build.gradle.kts b/clients/tablet/core/ui/build.gradle.kts index 0b70ba248cdba4297b68035aeb57c4e7fdf86d2e..3e7671809ccd832159075017d4ae85d1913dfb97 100644 --- a/clients/tablet/core/ui/build.gradle.kts +++ b/clients/tablet/core/ui/build.gradle.kts @@ -10,6 +10,7 @@ kotlin { implementation(libs.decompose.compose.jetbrains) implementation(libs.essenty.lifecycle) implementation(libs.essenty.state.keeper) + api(libs.kotlinx.datetime) } } } diff --git a/clients/tablet/core/ui/src/androidMain/kotlin/band/effective/office/tablet/core/ui/inactivity/InactivityLifecycleCallbacks.kt b/clients/tablet/core/ui/src/androidMain/kotlin/band/effective/office/tablet/core/ui/inactivity/InactivityLifecycleCallbacks.kt new file mode 100644 index 0000000000000000000000000000000000000000..bfeb2d4ca6dba3a3d9af09f87f3d7ea9d6087acd --- /dev/null +++ b/clients/tablet/core/ui/src/androidMain/kotlin/band/effective/office/tablet/core/ui/inactivity/InactivityLifecycleCallbacks.kt @@ -0,0 +1,70 @@ +package band.effective.office.tablet.core.ui.inactivity + +import android.app.Activity +import android.app.Application +import android.os.Bundle + +/** + * ActivityLifecycleCallbacks implementation that hooks into all activities + * and sets up the Window.Callback wrapper to track user inactivity. + */ +class InactivityLifecycleCallbacks : Application.ActivityLifecycleCallbacks { + + override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) { + // Get the original Window.Callback + val originalCallback = activity.window.callback + + // Set our custom Window.Callback wrapper + activity.window.callback = InactivityWindowCallback(originalCallback) + } + + override fun onActivityStarted(activity: Activity) { + // No implementation needed + } + + override fun onActivityResumed(activity: Activity) { + // Reset the inactivity timer when an activity is resumed + InactivityManager.resetTimer() + } + + override fun onActivityPaused(activity: Activity) { + // No implementation needed + } + + override fun onActivityStopped(activity: Activity) { + // No implementation needed + } + + override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) { + // No implementation needed + } + + override fun onActivityDestroyed(activity: Activity) { + // No implementation needed + } + + companion object { + /** + * Registers the InactivityLifecycleCallbacks with the Application + * and starts tracking user inactivity. + * + * @param application The Application instance. + * @param timeoutMs The inactivity timeout in milliseconds. Default is 1 minute. + * @param callback The callback to execute when inactivity is detected. + */ + fun initialize( + application: Application, + timeoutMs: Long = InactivityManager.DEFAULT_INACTIVITY_TIMEOUT_MS, + callback: (() -> Unit)? = null, + ) { + // Register the ActivityLifecycleCallbacks + application.registerActivityLifecycleCallbacks(InactivityLifecycleCallbacks()) + + // Start tracking inactivity + InactivityManager.startTracking(timeoutMs) { + // Execute the original callback + callback?.invoke() + } + } + } +} diff --git a/clients/tablet/core/ui/src/androidMain/kotlin/band/effective/office/tablet/core/ui/inactivity/InactivityManager.kt b/clients/tablet/core/ui/src/androidMain/kotlin/band/effective/office/tablet/core/ui/inactivity/InactivityManager.kt new file mode 100644 index 0000000000000000000000000000000000000000..12eb6b0b2eef749dfae7d3119c60a4e6a60a5e06 --- /dev/null +++ b/clients/tablet/core/ui/src/androidMain/kotlin/band/effective/office/tablet/core/ui/inactivity/InactivityManager.kt @@ -0,0 +1,76 @@ +package band.effective.office.tablet.core.ui.inactivity + +import android.os.Handler +import android.os.Looper +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * Singleton manager that tracks user inactivity across the application. + * It resets a timer whenever there is any UI interaction (touch event, key event) + * and executes a callback after a specified period of inactivity. + */ +object InactivityManager { + /** + * Default inactivity timeout in milliseconds (1 minute) + */ + const val DEFAULT_INACTIVITY_TIMEOUT_MS = 60000L + + private val handler = Handler(Looper.getMainLooper()) + private var inactivityTimeoutMs = DEFAULT_INACTIVITY_TIMEOUT_MS + private var inactivityCallback: (() -> Unit)? = null + private var isTracking = false + + private val _inactivityState = MutableStateFlow(false) + val inactivityState: StateFlow = _inactivityState.asStateFlow() + + private val inactivityRunnable = Runnable { + _inactivityState.value = true + inactivityCallback?.invoke() + } + + /** + * Starts tracking user inactivity. + * + * @param timeoutMs The inactivity timeout in milliseconds. Default is 1 minute. + * @param callback The callback to execute when inactivity is detected. + */ + fun startTracking( + timeoutMs: Long = DEFAULT_INACTIVITY_TIMEOUT_MS, + callback: (() -> Unit)? = null + ) { + inactivityTimeoutMs = timeoutMs + inactivityCallback = callback + isTracking = true + resetTimer() + } + + /** + * Stops tracking user inactivity. + */ + fun stopTracking() { + isTracking = false + handler.removeCallbacks(inactivityRunnable) + _inactivityState.value = false + } + + /** + * Resets the inactivity timer. This should be called whenever a user interaction is detected. + */ + fun resetTimer() { + if (!isTracking) return + + handler.removeCallbacks(inactivityRunnable) + _inactivityState.value = false + handler.postDelayed(inactivityRunnable, inactivityTimeoutMs) + } + + /** + * Notifies the manager that a user interaction has occurred. + * This will reset the inactivity timer. + */ + fun onUserInteraction() { + resetTimer() + } +} diff --git a/clients/tablet/core/ui/src/androidMain/kotlin/band/effective/office/tablet/core/ui/inactivity/InactivityWindowCallback.kt b/clients/tablet/core/ui/src/androidMain/kotlin/band/effective/office/tablet/core/ui/inactivity/InactivityWindowCallback.kt new file mode 100644 index 0000000000000000000000000000000000000000..645bd35f60e7f22acd0d8c3c7c89b12095f39bc7 --- /dev/null +++ b/clients/tablet/core/ui/src/androidMain/kotlin/band/effective/office/tablet/core/ui/inactivity/InactivityWindowCallback.kt @@ -0,0 +1,42 @@ +package band.effective.office.tablet.core.ui.inactivity + +import android.view.KeyEvent +import android.view.MotionEvent +import android.view.Window + +/** + * A Window.Callback wrapper that intercepts touch and key events to track user activity. + * It preserves the original Window.Callback behavior by delegating to it after intercepting events. + * + * @param original The original Window.Callback to delegate to. + */ +class InactivityWindowCallback(private val original: Window.Callback) : Window.Callback by original { + + /** + * Intercepts touch events and notifies the InactivityManager before delegating to the original callback. + * + * @param event The touch event. + * @return The result of the original callback's dispatchTouchEvent method. + */ + override fun dispatchTouchEvent(event: MotionEvent): Boolean { + // Notify the InactivityManager that a user interaction has occurred + InactivityManager.onUserInteraction() + + // Delegate to the original callback + return original.dispatchTouchEvent(event) + } + + /** + * Intercepts key events and notifies the InactivityManager before delegating to the original callback. + * + * @param event The key event. + * @return The result of the original callback's dispatchKeyEvent method. + */ + override fun dispatchKeyEvent(event: KeyEvent): Boolean { + // Notify the InactivityManager that a user interaction has occurred + InactivityManager.onUserInteraction() + + // Delegate to the original callback + return original.dispatchKeyEvent(event) + } +} \ No newline at end of file diff --git a/clients/tablet/core/ui/src/androidMain/kotlin/band/effective/office/tablet/core/ui/util/DateFormatter.android.kt b/clients/tablet/core/ui/src/androidMain/kotlin/band/effective/office/tablet/core/ui/util/DateFormatter.android.kt new file mode 100644 index 0000000000000000000000000000000000000000..e6c60aa7c5431c70377488334b23bef0b08ac0b0 --- /dev/null +++ b/clients/tablet/core/ui/src/androidMain/kotlin/band/effective/office/tablet/core/ui/util/DateFormatter.android.kt @@ -0,0 +1,13 @@ +package band.effective.office.tablet.core.ui.utils + +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.toJavaLocalDateTime +import java.time.format.DateTimeFormatter +import java.util.Locale + +actual fun LocalDateTime.toLocalisedString(pattern: String): String { + val formatter = DateTimeFormatter.ofPattern(pattern, Locale.getDefault()) + return this.toJavaLocalDateTime().format(formatter) +} + +actual fun getCurrentLanguageCode(): String = Locale.getDefault().language \ No newline at end of file diff --git a/clients/tablet/core/ui/src/commonMain/composeResources/values-ru/strings_ru.xml b/clients/tablet/core/ui/src/commonMain/composeResources/values-ru/strings_ru.xml new file mode 100644 index 0000000000000000000000000000000000000000..3c13527d1cc2f297ec6b61419bb8da0d79a4e58c --- /dev/null +++ b/clients/tablet/core/ui/src/commonMain/composeResources/values-ru/strings_ru.xml @@ -0,0 +1,36 @@ + + + Нет соединения с сервером + Перезагрузите приложение + Перезагрузить приложение + Когда + На сколько + Организатор + ${date} с ${time} + - 15 мин + + 30 мин + мин + ч + Выберите организатора + Неверный организатор, попробуйте еще раз + Занять с + Подтвердить + Нет подключения к интернету + Забронирована до %1$s + Отменить бронь + Свободных нет + Через %1$s мин освободится %2$s. Пока что попробуйте найти тихое место в офисе + Вас кто-то опередил + Попробуйте занять другую переговорку, либо выберите другое время брони + Это время уже занято + %1$s, %2$s — %3$s + На главную + Вы заняли %1$s + Отмена + Загрузка слота на время: %1$s — %2$s + Свободно %1$s мин + Занято %1$s + %1$s брони + Слот загружается + Ошибка. Повторите попытку позже + \ No newline at end of file diff --git a/clients/tablet/core/ui/src/commonMain/composeResources/values/strings.xml b/clients/tablet/core/ui/src/commonMain/composeResources/values/strings.xml index 3ccdf63f76759e4c30816f2c672b21b69629f637..9f52148492849de2daca01a877debe5714fd074e 100644 --- a/clients/tablet/core/ui/src/commonMain/composeResources/values/strings.xml +++ b/clients/tablet/core/ui/src/commonMain/composeResources/values/strings.xml @@ -1,36 +1,36 @@ - Нет соединения с сервером - Перезагрузите приложение - Перезагрузить приложение - Когда - На сколько - Организатор - ${date} с ${time} - - 15 мин - + 30 мин - мин - ч - Выберите организатора - Неверный организатор, попробуйте еще раз - Занять с - Подтвердить - Нет подключения к интернету - Забронирована до %1$s - Отменить бронь - Свободных нет - Через %1$s мин освободится %2$s. Пока что попробуйте найти тихое место в офисе - Вас кто-то опередил - Попробуйте занять другую переговорку, либо выберите другое время брони - Это время уже занято + No server connection + Restart the app + Restart app + When + Duration + Organizer + ${date} at ${time} + - 15 min + + 30 min + min + h + Choose organizer + Invalid organizer, try again + Book from + Confirm + No internet connection + Booked until %1$s + Cancel booking + No free rooms + In %1$s min, %2$s will be free. For now, try finding a quiet place in the office + Someone booked it before you + Try booking another meeting room or choose a different time + This time is already booked %1$s, %2$s — %3$s - На главную - Вы заняли %1$s - Отмена - Загрузка слота на время: %1$s - %2$s - Свободно %1$s мин - Занято %1$s - %1$s брони - Слот загружается - Ошибка. Повторите попытку позже + Back to main + You booked %1$s + Cancel + Loading slot for time: %1$s — %2$s + Available for %1$s min + Booked by %1$s + %1$s bookings + Slot is loading + Error. Try again later \ No newline at end of file diff --git a/clients/tablet/core/ui/src/commonMain/kotlin/band/effective/office/tablet/core/ui/common/EventDurationView.kt b/clients/tablet/core/ui/src/commonMain/kotlin/band/effective/office/tablet/core/ui/common/EventDurationView.kt index 994efdabf08dbecf77fb7fc387c5d003fd07f60c..4fc370f9faccfd6a8a725d30185dda0a9000bc88 100644 --- a/clients/tablet/core/ui/src/commonMain/kotlin/band/effective/office/tablet/core/ui/common/EventDurationView.kt +++ b/clients/tablet/core/ui/src/commonMain/kotlin/band/effective/office/tablet/core/ui/common/EventDurationView.kt @@ -30,12 +30,15 @@ import band.effective.office.tablet.core.ui.theme.h6 import band.effective.office.tablet.core.ui.theme.h8 import org.jetbrains.compose.resources.stringResource +private const val MIN_EVENT_DURATION_MINUTES = 15 + @Composable fun EventDurationView( modifier: Modifier = Modifier, currentDuration: Int, increment: () -> Unit, - decrement: () -> Unit + decrement: () -> Unit, + canIncrementDuration: Boolean = true ) { Column(modifier = modifier) { Text( @@ -52,6 +55,7 @@ fun EventDurationView( Button( modifier = Modifier.fillMaxHeight().weight(1f).clip(RoundedCornerShape(15.dp)), onClick = { decrement() }, + enabled = currentDuration > MIN_EVENT_DURATION_MINUTES, colors = ButtonDefaults.buttonColors( containerColor = LocalCustomColorsPalette.current.elevationBackground ) @@ -73,6 +77,7 @@ fun EventDurationView( onClick = { increment() }, + enabled = canIncrementDuration, colors = ButtonDefaults.buttonColors( containerColor = LocalCustomColorsPalette.current.elevationBackground ) @@ -92,9 +97,9 @@ private fun Int.getDurationString(): String { val hours = this / 60 val minutes = this % 60 return when { - hours == 0 -> "$minutes${stringResource(Res.string.short_minuets)}" - minutes == 0 -> "$hours${stringResource(Res.string.short_hours)}" - else -> "$hours${stringResource( Res.string.short_hours)} " + - "$minutes${stringResource(Res.string.short_minuets)}" + hours == 0 -> "$minutes ${stringResource(Res.string.short_minuets)}" + minutes == 0 -> "$hours ${stringResource(Res.string.short_hours)}" + else -> "$hours ${stringResource( Res.string.short_hours)} " + + "$minutes ${stringResource(Res.string.short_minuets)}" } } diff --git a/clients/tablet/core/ui/src/commonMain/kotlin/band/effective/office/tablet/core/ui/date/DateTimeView.kt b/clients/tablet/core/ui/src/commonMain/kotlin/band/effective/office/tablet/core/ui/date/DateTimeView.kt index 14e96ef835d7d798b3911dafe4ed563258c489c0..810cd692781d139ebc3850eb880363c0bd690e80 100644 --- a/clients/tablet/core/ui/src/commonMain/kotlin/band/effective/office/tablet/core/ui/date/DateTimeView.kt +++ b/clients/tablet/core/ui/src/commonMain/kotlin/band/effective/office/tablet/core/ui/date/DateTimeView.kt @@ -40,13 +40,14 @@ import kotlin.time.ExperimentalTime import kotlinx.datetime.LocalDateTime import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource +import band.effective.office.tablet.core.ui.utils.DateDisplayMapper @OptIn(ExperimentalTime::class) @Composable fun DateTimeView( modifier: Modifier, selectDate: LocalDateTime, - currentDate: LocalDateTime? = null, + currentDate: LocalDateTime, increment: () -> Unit, decrement: () -> Unit, onOpenDateTimePickerModal: () -> Unit, @@ -89,7 +90,7 @@ private fun RowScope.NextDateButton(increment: () -> Unit) { private fun RowScope.SelectedDate( onOpenDateTimePickerModal: () -> Unit, selectDate: LocalDateTime, - currentDate: LocalDateTime? + currentDate: LocalDateTime ) { val timeDayMonthDateFormat = remember { dateTimeFormat } val dayMonthDateFormat = remember { dayMonthFormat } @@ -100,39 +101,67 @@ private fun RowScope.SelectedDate( else AnimatedContentTransitionScope.SlideDirection.Right } - val displayedFormat by remember(selectDate) { - mutableStateOf( - if (currentDate != null && selectDate.date > currentDate.date) dayMonthDateFormat else timeDayMonthDateFormat - ) - } - LaunchedEffect(selectDate) { previousDate = selectDate } - Button( - modifier = Modifier - .fillMaxHeight() - .weight(4f) - .clip(RoundedCornerShape(15.dp)), - onClick = onOpenDateTimePickerModal, - colors = ButtonDefaults.buttonColors( - containerColor = LocalCustomColorsPalette.current.elevationBackground - ), - contentPadding = PaddingValues(0.dp) - ) { - AnimatedContent( - targetState = displayedFormat.format(selectDate), - transitionSpec = { - slideIntoContainer(slideDirection) + fadeIn() with - slideOutOfContainer(slideDirection.opposite()) + fadeOut() - }, - label = "AnimatedDateChange" - ) { formattedDate -> - Text( - text = formattedDate, - style = MaterialTheme.typography.h6 - ) + if (currentDate.date < selectDate.date) { + Button( + modifier = Modifier + .fillMaxHeight() + .weight(4f) + .clip(RoundedCornerShape(15.dp)), + onClick = onOpenDateTimePickerModal, + colors = ButtonDefaults.buttonColors( + containerColor = LocalCustomColorsPalette.current.elevationBackground + ), + contentPadding = PaddingValues(0.dp) + ) { + AnimatedContent( + targetState = DateDisplayMapper.map( + selectDate = selectDate, + currentDate = currentDate + ), + transitionSpec = { + slideIntoContainer(slideDirection) + fadeIn() with + slideOutOfContainer(slideDirection.opposite()) + fadeOut() + }, + label = "AnimatedDateChange" + ) { formattedDate -> + Text( + text = formattedDate, + style = MaterialTheme.typography.h6 + ) + } + } + } else { + Button( + modifier = Modifier + .fillMaxHeight() + .weight(4f) + .clip(RoundedCornerShape(15.dp)), + onClick = onOpenDateTimePickerModal, + colors = ButtonDefaults.buttonColors( + containerColor = LocalCustomColorsPalette.current.elevationBackground + ), + contentPadding = PaddingValues(0.dp) + ) { + AnimatedContent( + targetState = DateDisplayMapper.map( + selectDate = currentDate, + currentDate = currentDate + ), + transitionSpec = { + slideIntoContainer(slideDirection) + fadeIn() with + slideOutOfContainer(slideDirection.opposite()) + fadeOut() + }, + label = "AnimatedDateChange" + ) { formattedDate -> + Text( + text = formattedDate, + style = MaterialTheme.typography.h6 + ) + } } } } diff --git a/clients/tablet/core/ui/src/commonMain/kotlin/band/effective/office/tablet/core/ui/utils/DateDisplayMapper.kt b/clients/tablet/core/ui/src/commonMain/kotlin/band/effective/office/tablet/core/ui/utils/DateDisplayMapper.kt new file mode 100644 index 0000000000000000000000000000000000000000..0486415abe6734f801009848395781af2897267f --- /dev/null +++ b/clients/tablet/core/ui/src/commonMain/kotlin/band/effective/office/tablet/core/ui/utils/DateDisplayMapper.kt @@ -0,0 +1,66 @@ +package band.effective.office.tablet.core.ui.utils + +import kotlinx.datetime.LocalDateTime + +/** + * Data class to store date and time format patterns for a specific locale. + * + * @param default Format for full date and time. + * @param future Format for future dates. + * @param time Format for time only. + */ +private data class LocaleFormatPatterns( + val default: String, + val future: String, + val time: String +) + +/** + * Utility object for formatting dates and times based on the current locale. + * Falls back to English formats for unsupported locales. + */ +object DateDisplayMapper { + + private val formatPatterns: Map = mapOf( + "ru" to LocaleFormatPatterns( + default = "d MMMM, HH:mm", + future = "d MMMM", + time = "HH:mm" + ), + "en" to LocaleFormatPatterns( + default = "MMM d h:mm a", + future = "MMM d", + time = "h:mm a" + ) + ) + private val defaultFormats: LocaleFormatPatterns = formatPatterns["en"]!! + + private fun getPatternsForLocale(): LocaleFormatPatterns { + val currentLanguage = getCurrentLanguageCode() + return formatPatterns[currentLanguage] ?: defaultFormats + } + + fun map(selectDate: LocalDateTime, currentDate: LocalDateTime?): String { + val patterns = getPatternsForLocale() + val pattern = if (currentDate != null && selectDate.date > currentDate.date) { + patterns.future + } else { + patterns.default + } + return selectDate.toLocalisedString(pattern) + } + + fun formatForPicker(date: LocalDateTime): String { + val patterns = getPatternsForLocale() + return date.toLocalisedString(patterns.future) + } + + fun formatTime(time: LocalDateTime): String { + val patterns = getPatternsForLocale() + return time.toLocalisedString(patterns.time) + } + + fun is24HourFormat(): Boolean { + return getCurrentLanguageCode() != "en" + } +} \ No newline at end of file diff --git a/clients/tablet/core/ui/src/commonMain/kotlin/band/effective/office/tablet/core/ui/utils/DateFormatter.kt b/clients/tablet/core/ui/src/commonMain/kotlin/band/effective/office/tablet/core/ui/utils/DateFormatter.kt new file mode 100644 index 0000000000000000000000000000000000000000..72f9dce67273278eede313af4ac1f36aaec7b460 --- /dev/null +++ b/clients/tablet/core/ui/src/commonMain/kotlin/band/effective/office/tablet/core/ui/utils/DateFormatter.kt @@ -0,0 +1,7 @@ +package band.effective.office.tablet.core.ui.utils + +import kotlinx.datetime.LocalDateTime + +@OptIn(kotlinx.datetime.format.FormatStringsInDatetimeFormats::class) +expect fun LocalDateTime.toLocalisedString(pattern: String): String +expect fun getCurrentLanguageCode(): String \ No newline at end of file diff --git a/clients/tablet/core/ui/src/iosMain/kotlin/band/effective/office/tablet/core/ui/utils/DateFormatter.ios.kt b/clients/tablet/core/ui/src/iosMain/kotlin/band/effective/office/tablet/core/ui/utils/DateFormatter.ios.kt new file mode 100644 index 0000000000000000000000000000000000000000..b4ca439d9a6fbf0d37748b7e042cccf0454f5583 --- /dev/null +++ b/clients/tablet/core/ui/src/iosMain/kotlin/band/effective/office/tablet/core/ui/utils/DateFormatter.ios.kt @@ -0,0 +1,25 @@ +package band.effective.office.tablet.core.ui.utils + +import kotlinx.datetime.LocalDateTime +import platform.Foundation.NSDate +import platform.Foundation.NSDateFormatter +import platform.Foundation.NSLocale +import platform.Foundation.currentLocale + + +import kotlinx.datetime.toNSDateComponents +import platform.Foundation.* + +actual fun LocalDateTime.toLocalisedString(pattern: String): String { + val dateFormatter = NSDateFormatter() + dateFormatter.dateFormat = pattern + dateFormatter.locale = NSLocale.currentLocale + + val calendar = NSCalendar.currentCalendar + val dateComponents = toNSDateComponents() + + val date = calendar.dateFromComponents(dateComponents) ?: NSDate() + return dateFormatter.stringFromDate(date) +} + +actual fun getCurrentLanguageCode(): String = NSLocale.currentLocale.languageCode \ No newline at end of file diff --git a/clients/tablet/feature/bookingEditor/src/commonMain/composeResources/values-ru/strings_ru.xml b/clients/tablet/feature/bookingEditor/src/commonMain/composeResources/values-ru/strings_ru.xml new file mode 100644 index 0000000000000000000000000000000000000000..95b290008edc6321c5bc7a3d71373a2a6cbe2d1f --- /dev/null +++ b/clients/tablet/feature/bookingEditor/src/commonMain/composeResources/values-ru/strings_ru.xml @@ -0,0 +1,12 @@ + + + Занять %1$s + Изменить бронь + Занять c %1$s до %2$s + Сохранить изменения + Удалить бронь + Произошла ошибка + Ошибка, выбрана неправильная дата + Ошибка при создании события + Ошибка при удалении события + diff --git a/clients/tablet/feature/bookingEditor/src/commonMain/composeResources/values/strings.xml b/clients/tablet/feature/bookingEditor/src/commonMain/composeResources/values/strings.xml index c73b265e276f65216228bcd4ab75fefc66cdd2b4..0e4ac31daa8dc6f464b0f27ef336c154c9c8772e 100644 --- a/clients/tablet/feature/bookingEditor/src/commonMain/composeResources/values/strings.xml +++ b/clients/tablet/feature/bookingEditor/src/commonMain/composeResources/values/strings.xml @@ -1,12 +1,12 @@ - Занять %1$s - Измененить бронь - Занять c %1$s до %2$s - Изменить - Удалить бронь - Произошла ошибка - Ошибка, выбрана неправильная дата + Book %1$s + Edit booking + Book from %1$s to %2$s + Confirm changes + Delete booking + An error occurred + Error, incorrect date selected Error creating event - Error deleting event" + Error deleting event \ No newline at end of file diff --git a/clients/tablet/feature/bookingEditor/src/commonMain/kotlin/band/effective/office/tablet/feature/bookingEditor/presentation/BookingEditor.kt b/clients/tablet/feature/bookingEditor/src/commonMain/kotlin/band/effective/office/tablet/feature/bookingEditor/presentation/BookingEditor.kt index 92e6b5ee2f8a562fdb53985f8c9514283d8fc122..038c919792aaf65685f78c2ae8a0a751f5648138 100644 --- a/clients/tablet/feature/bookingEditor/src/commonMain/kotlin/band/effective/office/tablet/feature/bookingEditor/presentation/BookingEditor.kt +++ b/clients/tablet/feature/bookingEditor/src/commonMain/kotlin/band/effective/office/tablet/feature/bookingEditor/presentation/BookingEditor.kt @@ -120,7 +120,8 @@ fun BookingEditor( finish = state.event.finishTime.format(timeFormatter), room = component.roomName, isTimeInPastError = state.isTimeInPastError, - isEditable = state.event.isEditable + isEditable = state.event.isEditable, + canIncrementDuration = state.canIncrementDuration ) } } @@ -163,7 +164,8 @@ private fun BookingEditor( finish: String, room: String, isTimeInPastError: Boolean, - isEditable: Boolean = true + isEditable: Boolean = true, + canIncrementDuration: Boolean ) { val snackbarHostState = remember { SnackbarHostState() } val timeInPastErrorMessage = stringResource(Res.string.is_time_in_past_error) @@ -197,14 +199,16 @@ private fun BookingEditor( increment = incrementData, decrement = decrementData, onOpenDateTimePickerModal = onOpenDateTimePickerModal, - showTitle = true + showTitle = true, + currentDate = selectData, ) Spacer(modifier = Modifier.height(15.dp)) EventDurationView( modifier = Modifier.fillMaxWidth().height(100.dp), currentDuration = selectDuration, increment = incrementDuration, - decrement = decrementDuration + decrement = decrementDuration, + canIncrementDuration = canIncrementDuration ) Spacer(modifier = Modifier.height(15.dp)) EventOrganizerView( diff --git a/clients/tablet/feature/bookingEditor/src/commonMain/kotlin/band/effective/office/tablet/feature/bookingEditor/presentation/BookingEditorComponent.kt b/clients/tablet/feature/bookingEditor/src/commonMain/kotlin/band/effective/office/tablet/feature/bookingEditor/presentation/BookingEditorComponent.kt index 08f4e5a96841c05894a511bc596d6fbee767ca69..c1bd35dfe1f17096dc51ff82175b5a406357b31a 100644 --- a/clients/tablet/feature/bookingEditor/src/commonMain/kotlin/band/effective/office/tablet/feature/bookingEditor/presentation/BookingEditorComponent.kt +++ b/clients/tablet/feature/bookingEditor/src/commonMain/kotlin/band/effective/office/tablet/feature/bookingEditor/presentation/BookingEditorComponent.kt @@ -1,12 +1,13 @@ package band.effective.office.tablet.feature.bookingEditor.presentation +import band.effective.office.tablet.core.domain.Either import band.effective.office.tablet.core.domain.OfficeTime import band.effective.office.tablet.core.domain.model.EventInfo import band.effective.office.tablet.core.domain.model.Organizer -import band.effective.office.tablet.core.domain.model.Slot import band.effective.office.tablet.core.domain.unbox import band.effective.office.tablet.core.domain.useCase.CheckBookingUseCase import band.effective.office.tablet.core.domain.useCase.CreateBookingUseCase +import band.effective.office.tablet.core.domain.useCase.DeleteBookingUseCase import band.effective.office.tablet.core.domain.useCase.OrganizersInfoUseCase import band.effective.office.tablet.core.domain.useCase.UpdateBookingUseCase import band.effective.office.tablet.core.domain.util.asInstant @@ -24,6 +25,7 @@ import kotlin.time.Duration.Companion.days import kotlin.time.Duration.Companion.minutes import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update @@ -41,11 +43,12 @@ import org.koin.core.component.inject * Component responsible for editing booking events. * Handles creating new bookings and updating existing ones. */ +const val DURATION_INCREMENT_MINUTES = 30 +const val DELETE_SUCCESS_DELAY = 2000L class BookingEditorComponent( componentContext: ComponentContext, initialEvent: EventInfo, val roomName: String, - private val onDeleteEvent: (Slot) -> Unit, private val onCloseRequest: () -> Unit, ) : ComponentContext by componentContext, KoinComponent, ModalWindow { @@ -68,6 +71,7 @@ class BookingEditorComponent( private val checkBookingUseCase: CheckBookingUseCase by inject() private val updateBookingUseCase: UpdateBookingUseCase by inject() private val createBookingUseCase: CreateBookingUseCase by inject() + private val deleteBookingUseCase: DeleteBookingUseCase by inject() // Mappers private val eventInfoMapper: EventInfoMapper by inject() @@ -158,9 +162,25 @@ class BookingEditorComponent( */ private fun deleteEvent() = coroutineScope.launch { mutableState.update { it.copy(isLoadDelete = true) } - onDeleteEvent(eventInfoMapper.mapToSlot(state.value.event)) - mutableState.update { it.copy(isLoadDelete = false) } - onCloseRequest() + val deleteResult = withContext(Dispatchers.IO) { + deleteBookingUseCase(roomName, state.value.event) + } + + when (deleteResult) { + is Either.Error -> { + mutableState.update { + it.copy( + isLoadDelete = false, + isErrorDelete = true, + ) + } + } + + is Either.Success -> { + delay(DELETE_SUCCESS_DELAY) + onCloseRequest() + } + } } /** @@ -231,18 +251,26 @@ class BookingEditorComponent( ) val isTimeInPast = newDate <= getCurrentTime() + val newFinishTime = newDate.asInstant.plus(duration.minutes).asLocalDateTime + val finishWorkTime = OfficeTime.finishWorkTime(newDate.date) + val isFinishTimeExceeded = newFinishTime > finishWorkTime + + val nextIncrementFinishTime = newDate.asInstant.plus((duration + DURATION_INCREMENT_MINUTES).minutes).asLocalDateTime + val canIncrementDuration = nextIncrementFinishTime <= finishWorkTime + updateStateWithNewEventDetails( newDate = newDate, newDuration = duration, newOrganizer = selectOrganizer, busyEvents = busyEvents, - isTimeInPast = isTimeInPast + isTimeInPast = isTimeInPast, + canIncrementDuration = canIncrementDuration ) if (selectOrganizer != Organizer.default) { updateButtonState( inputError = isInputError, - busyEvent = busyEvents.isNotEmpty() + busyEvent = busyEvents.isNotEmpty() || isFinishTimeExceeded ) } } @@ -263,6 +291,14 @@ class BookingEditorComponent( it.fullName == newOrganizer.fullName } ?: event.organizer val isTimeInPast = newDate <= getCurrentTime() + + val finishWorkTime = OfficeTime.finishWorkTime(newDate.date) + val newFinishTime = newDate.asInstant.plus(newDuration.minutes).asLocalDateTime + val isFinishTimeExceeded = newFinishTime > finishWorkTime + + val nextIncrementFinishTime = newDate.asInstant.plus((newDuration + DURATION_INCREMENT_MINUTES).minutes).asLocalDateTime + val canIncrementDuration = nextIncrementFinishTime <= finishWorkTime + val busyEvents = checkForBusyEvents( date = newDate, duration = newDuration, @@ -274,12 +310,13 @@ class BookingEditorComponent( newDuration = newDuration, newOrganizer = resolvedOrganizer, busyEvents = busyEvents, - isTimeInPast = isTimeInPast + isTimeInPast = isTimeInPast, + canIncrementDuration = canIncrementDuration ) updateButtonState( inputError = !organizers.contains(resolvedOrganizer), - busyEvent = busyEvents.isNotEmpty() + busyEvent = busyEvents.isNotEmpty() || isFinishTimeExceeded ) } } @@ -334,7 +371,8 @@ class BookingEditorComponent( newDuration: Int, newOrganizer: Organizer, busyEvents: List, - isTimeInPast: Boolean + isTimeInPast: Boolean, + canIncrementDuration: Boolean ) { val updatedEvent = createEventInfo( id = state.value.event.id, @@ -350,7 +388,8 @@ class BookingEditorComponent( selectOrganizer = newOrganizer, event = updatedEvent, isBusyEvent = busyEvents.isNotEmpty(), - isTimeInPastError = isTimeInPast + isTimeInPastError = isTimeInPast, + canIncrementDuration = canIncrementDuration ) } } diff --git a/clients/tablet/feature/bookingEditor/src/commonMain/kotlin/band/effective/office/tablet/feature/bookingEditor/presentation/State.kt b/clients/tablet/feature/bookingEditor/src/commonMain/kotlin/band/effective/office/tablet/feature/bookingEditor/presentation/State.kt index aeed462f0b8d5b94f0490c4b769e50935373572f..18acb27d74e7e0a76fb945d6b958702457cddecd 100644 --- a/clients/tablet/feature/bookingEditor/src/commonMain/kotlin/band/effective/office/tablet/feature/bookingEditor/presentation/State.kt +++ b/clients/tablet/feature/bookingEditor/src/commonMain/kotlin/band/effective/office/tablet/feature/bookingEditor/presentation/State.kt @@ -24,7 +24,8 @@ data class State( val showSelectDate: Boolean, val enableUpdateButton: Boolean, val isBusyEvent: Boolean, - val isTimeInPastError: Boolean + val isTimeInPastError: Boolean, + val canIncrementDuration: Boolean ) { companion object { val defaultValue = State( @@ -46,7 +47,8 @@ data class State( showSelectDate = false, enableUpdateButton = false, isBusyEvent = false, - isTimeInPastError = false + isTimeInPastError = false, + canIncrementDuration = true ) } diff --git a/clients/tablet/feature/bookingEditor/src/commonMain/kotlin/band/effective/office/tablet/feature/bookingEditor/presentation/datetimepicker/DateTimePickerModalView.kt b/clients/tablet/feature/bookingEditor/src/commonMain/kotlin/band/effective/office/tablet/feature/bookingEditor/presentation/datetimepicker/DateTimePickerModalView.kt index 62c63d4195e6ca1c104669662ea7800fb3c53d15..98a2a784113a088853cef78c8e3934806833cc21 100644 --- a/clients/tablet/feature/bookingEditor/src/commonMain/kotlin/band/effective/office/tablet/feature/bookingEditor/presentation/datetimepicker/DateTimePickerModalView.kt +++ b/clients/tablet/feature/bookingEditor/src/commonMain/kotlin/band/effective/office/tablet/feature/bookingEditor/presentation/datetimepicker/DateTimePickerModalView.kt @@ -24,11 +24,11 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties -import band.effective.office.tablet.core.domain.util.toFormattedString import band.effective.office.tablet.core.ui.common.CrossButtonView import band.effective.office.tablet.core.ui.theme.LocalCustomColorsPalette import band.effective.office.tablet.core.ui.theme.header8 import band.effective.office.tablet.core.ui.time_booked +import band.effective.office.tablet.core.ui.utils.DateDisplayMapper import band.effective.office.tablet.feature.bookingEditor.presentation.datetimepicker.components.DatePickerView import band.effective.office.tablet.feature.bookingEditor.presentation.datetimepicker.components.TimePickerView import kotlinx.datetime.LocalDate @@ -114,7 +114,7 @@ fun DateTimePickerModalView( ) { Text( text = when (enableDateButton) { - true -> "${currentDate.dayOfMonth} ${currentDate.month.name} ${currentDate.toFormattedString("HH:mm")}" + true -> DateDisplayMapper.formatForPicker(currentDate) false -> stringResource(band.effective.office.tablet.core.ui.Res.string.time_booked) }, style = header8, diff --git a/clients/tablet/feature/bookingEditor/src/commonMain/kotlin/band/effective/office/tablet/feature/bookingEditor/presentation/datetimepicker/components/TimePickerView.kt b/clients/tablet/feature/bookingEditor/src/commonMain/kotlin/band/effective/office/tablet/feature/bookingEditor/presentation/datetimepicker/components/TimePickerView.kt index 3f5bdca478cea5f88f73c817d505aa2b0fab2cec..b13bc3637bc7b4d8dfb7bf2e5214cf951a8b2e89 100644 --- a/clients/tablet/feature/bookingEditor/src/commonMain/kotlin/band/effective/office/tablet/feature/bookingEditor/presentation/datetimepicker/components/TimePickerView.kt +++ b/clients/tablet/feature/bookingEditor/src/commonMain/kotlin/band/effective/office/tablet/feature/bookingEditor/presentation/datetimepicker/components/TimePickerView.kt @@ -7,6 +7,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier import band.effective.office.tablet.core.ui.theme.LocalCustomColorsPalette +import band.effective.office.tablet.core.ui.utils.DateDisplayMapper import com.mohamedrejeb.calf.ui.timepicker.AdaptiveTimePicker import com.mohamedrejeb.calf.ui.timepicker.rememberAdaptiveTimePickerState import kotlinx.datetime.LocalDateTime @@ -19,10 +20,12 @@ fun TimePickerView( currentDate: LocalDateTime, onSnap: (LocalTime) -> Unit ) { + val is24Hour = DateDisplayMapper.is24HourFormat() + val state = rememberAdaptiveTimePickerState( initialHour = currentDate.hour, initialMinute = currentDate.minute, - is24Hour = true + is24Hour = is24Hour ) LaunchedEffect(state.hour, state.minute) { val hour = state.hour diff --git a/clients/tablet/feature/bookingEditor/src/commonMain/kotlin/band/effective/office/tablet/feature/bookingEditor/presentation/mapper/EventInfoMapper.kt b/clients/tablet/feature/bookingEditor/src/commonMain/kotlin/band/effective/office/tablet/feature/bookingEditor/presentation/mapper/EventInfoMapper.kt index 3b19223f1be563599034c20e048add6f986748fc..6c76139a454e29903b91ac3fcde03caaf7026d4e 100644 --- a/clients/tablet/feature/bookingEditor/src/commonMain/kotlin/band/effective/office/tablet/feature/bookingEditor/presentation/mapper/EventInfoMapper.kt +++ b/clients/tablet/feature/bookingEditor/src/commonMain/kotlin/band/effective/office/tablet/feature/bookingEditor/presentation/mapper/EventInfoMapper.kt @@ -1,5 +1,6 @@ package band.effective.office.tablet.feature.bookingEditor.presentation.mapper +import band.effective.office.tablet.core.domain.OfficeTime import band.effective.office.tablet.core.domain.model.EventInfo import band.effective.office.tablet.core.domain.model.Slot import band.effective.office.tablet.core.domain.util.asInstant @@ -12,12 +13,15 @@ class EventInfoMapper { fun mapToUpdateBookingState(eventInfo: EventInfo): State { val duration = eventInfo.finishTime.asInstant.minus(eventInfo.startTime.asInstant).inWholeMinutes.toInt() + val finishWorkTime = OfficeTime.finishWorkTime(eventInfo.startTime.date) + val canIncrementDuration = eventInfo.finishTime < finishWorkTime return State.defaultValue.copy( date = eventInfo.startTime, duration = duration, selectOrganizer = eventInfo.organizer, inputText = eventInfo.organizer.fullName, - event = eventInfo + event = eventInfo, + canIncrementDuration = canIncrementDuration ) } } \ No newline at end of file diff --git a/clients/tablet/feature/main/src/commonMain/composeResources/values-ru/strings_ru.xml b/clients/tablet/feature/main/src/commonMain/composeResources/values-ru/strings_ru.xml new file mode 100644 index 0000000000000000000000000000000000000000..78dbe7c3950f3e5b3374351521ea69d92d4eafd6 --- /dev/null +++ b/clients/tablet/feature/main/src/commonMain/composeResources/values-ru/strings_ru.xml @@ -0,0 +1,52 @@ + + + Занято до %1$s + · До конца %1$s + Свободна до %1$s + · До начала %1$s + Освободить + TV + USB + Розетка + Розетки + Розеток + час + часа + часов + минута + минуты + минут + Занять + Занять %1$s + До конца + Смотреть другие переговорки + на сегодня + %1$s с %2$s + %1$s мин + Занятое время + Занятое время + Выберите организатора + В это время будет другая бронь + Нет подключения к интернету + Нет соединения с сервером + Перезагрузите приложение + Перезагрузить приложение + Выход + Выбрать %1$s + Сейчас свободна + Данные брони могут быть неактуальны + %1$s мин + Занять любую переговорку + Освободить переговорку? + Попробовать ещё раз + Освободить + с %1$s до %2$s + Занять %2$s? + Когда + На сколько + Организатор + %1$s мин + %1$sч %2$sмин + Занять %1$s + Это время уже занято + \ No newline at end of file diff --git a/clients/tablet/feature/main/src/commonMain/composeResources/values/strings.xml b/clients/tablet/feature/main/src/commonMain/composeResources/values/strings.xml index bed24afc7c0287bcb05d113b470f9d7c937405be..dbf9a9bb43b8a97d0da3ac6d9904a9aec00c939d 100644 --- a/clients/tablet/feature/main/src/commonMain/composeResources/values/strings.xml +++ b/clients/tablet/feature/main/src/commonMain/composeResources/values/strings.xml @@ -1,52 +1,52 @@ - Занято до %1$s - · До конца %1$s - Свободна до %1$s - · До начала %1$s - Освободить + Booked until %1$s + · %1$s left + Free until %1$s + · Until start %1$s + Release TV USB - Розетка - Розетки - Розеток - час - часа - часов - минута - минуты - минут - Занять - Занять %1$s - До конца - Смотреть другие переговорки - на сегодня - %1$s с %2$s - %1$s мин - Занятое время - Занятое время - Выберите организатора - В это время будет другая бронь - Нет подключения к интернету - Нет соединения с сервером - Перезагрузите приложение - Перезагрузить приложение - Выход - Выбрать %1$s - Сейчас свободна - Данные брони могут быть неактуальны - %1$s мин - Занять любую переговорку на: - Освободить переговорку? - Попробовать ещё раз - Освободить - с %1$s до %2$s - Занять %2$s? - Когда - На сколько - Организатор - %1$s мин - %1$sч %2$sмин - Занять %1$s - Это время уже занято + Socket + Sockets + Sockets + hour + hours + hours + minute + minutes + minutes + Book + Book %1$s + Until end + View other meeting rooms + for today + %1$s at %2$s + %1$s min + Booked time + Booked time + Select an organizer + Another booking is scheduled at this time + No internet connection + No server connection + Restart the app + Restart app + Exit + Choose %1$s + Available now + Booking data may be outdated + %1$s min + Book any available room + Release the meeting room? + Try again + Release + from %1$s to %2$s + Book %2$s? + When + Duration + Organizer + %1$s min + %1$s h %2$s min + Book %1$s + This time is already booked \ No newline at end of file diff --git a/clients/tablet/feature/main/src/commonMain/kotlin/band/effective/office/tablet/feature/main/components/uiComponent/CommonRoomInfoComponent.kt b/clients/tablet/feature/main/src/commonMain/kotlin/band/effective/office/tablet/feature/main/components/uiComponent/CommonRoomInfoComponent.kt index 9f1554bd2c8751104dc1c863c0a09793dd7a35db..77e1ed98385698ae3ce533c62b3cd23195a2c303 100644 --- a/clients/tablet/feature/main/src/commonMain/kotlin/band/effective/office/tablet/feature/main/components/uiComponent/CommonRoomInfoComponent.kt +++ b/clients/tablet/feature/main/src/commonMain/kotlin/band/effective/office/tablet/feature/main/components/uiComponent/CommonRoomInfoComponent.kt @@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -85,19 +86,20 @@ fun RoomProperty( electricSocketCount: Int ) { Row { - RoomPropertyComponent( - image = Res.drawable.quantity, - text = "$capacity", - color = roomInfoColor - ) if (isHaveTv) { - Spacer(modifier = Modifier.width(spaceBetweenProperty)) RoomPropertyComponent( image = Res.drawable.tv, - text = stringResource(band.effective.office.tablet.feature.main.Res.string.tv_property), + text = "", color = roomInfoColor ) + Spacer(modifier = Modifier.width(spaceBetweenProperty)) } + RoomPropertyComponent( + image = Res.drawable.quantity, + text = "$capacity", + color = roomInfoColor, + textModifier = Modifier.widthIn(min = 20.dp) + ) if (electricSocketCount > 0) { Spacer(modifier = Modifier.width(spaceBetweenProperty)) RoomPropertyComponent( @@ -110,7 +112,12 @@ fun RoomProperty( } @Composable -fun RoomPropertyComponent(image: DrawableResource, text: String, color: Color) { +fun RoomPropertyComponent( + image: DrawableResource, + text: String, + color: Color, + textModifier: Modifier = Modifier +) { Row( horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically @@ -121,7 +128,9 @@ fun RoomPropertyComponent(image: DrawableResource, text: String, color: Color) { contentDescription = null, colorFilter = ColorFilter.tint(color) ) - Spacer(modifier = Modifier.width(8.dp)) - Text(text = text, style = MaterialTheme.typography.h8) + if (text.isNotEmpty()) { + Spacer(modifier = Modifier.width(8.dp)) + Text(text = text, style = MaterialTheme.typography.h8, modifier = textModifier) + } } } \ No newline at end of file diff --git a/clients/tablet/feature/main/src/commonMain/kotlin/band/effective/office/tablet/feature/main/components/uiComponent/RoomInfoLeftPanel.kt b/clients/tablet/feature/main/src/commonMain/kotlin/band/effective/office/tablet/feature/main/components/uiComponent/RoomInfoLeftPanel.kt index 7171e485a82430803d3953f34b01c647298267d9..1e19aeae9d0d3fc9abeb55bbdb5438973cfc7bca 100644 --- a/clients/tablet/feature/main/src/commonMain/kotlin/band/effective/office/tablet/feature/main/components/uiComponent/RoomInfoLeftPanel.kt +++ b/clients/tablet/feature/main/src/commonMain/kotlin/band/effective/office/tablet/feature/main/components/uiComponent/RoomInfoLeftPanel.kt @@ -90,8 +90,15 @@ fun RoomInfoLeftPanel( SlotView( slotUi = it, onClick = { slotComponent.sendIntent(SlotIntent.ClickToEdit(this)) }, - onToggle = { (it as? SlotUi.MultiSlot)?.let { slotComponent.sendIntent(SlotIntent.ClickToToggle(it)) } }, - onCancel = { deleteSlot -> slotComponent.sendIntent(SlotIntent.OnCancelDelete(deleteSlot)) } + onToggle = { + (it as? SlotUi.MultiSlot)?.let { + slotComponent.sendIntent( + SlotIntent.ClickToToggle( + it + ) + ) + } + }, ) } Spacer(Modifier.height(20.dp)) diff --git a/clients/tablet/feature/main/src/commonMain/kotlin/band/effective/office/tablet/feature/main/domain/CurrentTimeHolder.kt b/clients/tablet/feature/main/src/commonMain/kotlin/band/effective/office/tablet/feature/main/domain/CurrentTimeHolder.kt new file mode 100644 index 0000000000000000000000000000000000000000..50204e6104d50596b4b39c5fc21e8d49c0ca2ca2 --- /dev/null +++ b/clients/tablet/feature/main/src/commonMain/kotlin/band/effective/office/tablet/feature/main/domain/CurrentTimeHolder.kt @@ -0,0 +1,33 @@ +package band.effective.office.tablet.feature.main.domain + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.datetime.Clock +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime + +/** + * A singleton that holds the current time. + */ +object CurrentTimeHolder { + private val defaultTimeZone = TimeZone.Companion.currentSystemDefault() + + private val _currentTime = MutableStateFlow(Clock.System.now().toLocalDateTime(defaultTimeZone)) + val currentTime: StateFlow = _currentTime.asStateFlow() + + /** + * Updates the current time. + */ + fun updateTime(time: LocalDateTime) { + _currentTime.value = time + } + + /** + * Gets the current time. + */ + fun getCurrentTime(): LocalDateTime { + return _currentTime.value + } +} \ No newline at end of file diff --git a/clients/tablet/feature/main/src/commonMain/kotlin/band/effective/office/tablet/feature/main/presentation/main/MainComponent.kt b/clients/tablet/feature/main/src/commonMain/kotlin/band/effective/office/tablet/feature/main/presentation/main/MainComponent.kt index 9439e74324f6c1635d30f8ddebef5a4bbde5df1d..04dd80f5c079c8a178e4559ddbc832817941e37e 100644 --- a/clients/tablet/feature/main/src/commonMain/kotlin/band/effective/office/tablet/feature/main/presentation/main/MainComponent.kt +++ b/clients/tablet/feature/main/src/commonMain/kotlin/band/effective/office/tablet/feature/main/presentation/main/MainComponent.kt @@ -2,9 +2,9 @@ package band.effective.office.tablet.feature.main.presentation.main import band.effective.office.tablet.core.domain.Either import band.effective.office.tablet.core.domain.ErrorWithData +import band.effective.office.tablet.core.domain.manager.DateResetManager import band.effective.office.tablet.core.domain.model.EventInfo import band.effective.office.tablet.core.domain.model.RoomInfo -import band.effective.office.tablet.core.domain.model.Slot import band.effective.office.tablet.core.domain.useCase.CheckSettingsUseCase import band.effective.office.tablet.core.domain.useCase.DeleteBookingUseCase import band.effective.office.tablet.core.domain.useCase.RoomInfoUseCase @@ -15,6 +15,7 @@ import band.effective.office.tablet.core.domain.util.currentLocalDateTime import band.effective.office.tablet.core.domain.util.minus import band.effective.office.tablet.core.domain.util.plus import band.effective.office.tablet.core.ui.utils.componentCoroutineScope +import band.effective.office.tablet.feature.main.domain.CurrentTimeHolder import band.effective.office.tablet.feature.main.domain.GetRoomIndexUseCase import band.effective.office.tablet.feature.main.domain.GetTimeToNextEventUseCase import band.effective.office.tablet.feature.slot.presentation.SlotComponent @@ -22,7 +23,6 @@ import band.effective.office.tablet.feature.slot.presentation.SlotIntent import com.arkivanov.decompose.ComponentContext import kotlin.math.abs import kotlin.time.Duration.Companion.days -import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds import kotlin.time.ExperimentalTime import kotlinx.coroutines.Dispatchers @@ -94,6 +94,23 @@ class MainComponent( // Set up event listeners setupEventListeners() + + // Initialize date reset manager + initializeDateResetManager() + } + + /** + * Initializes the DateResetManager to handle date reset on inactivity. + * This registers a callback that will reset the selected date and current room + * when inactivity is detected. + */ + private fun initializeDateResetManager() { + DateResetManager.registerDateResetCallback { date -> + mutableState.update { it.copy(selectedDate = date) } + reboot(refresh = true, resetSelectRoom = true) + updateTimeToNextEvent() + slotComponent.sendIntent(SlotIntent.InactivityTimeout) + } } /** @@ -110,13 +127,6 @@ class MainComponent( } } - // Update time to next event periodically - timerUseCase.timer(coroutineScope, 1.seconds) { _ -> - withContext(Dispatchers.Main) { - updateTimeToNextEvent() - } - } - // Listen for room info changes coroutineScope.launch(Dispatchers.Main) { roomInfoUseCase.subscribe().collect { roomsInfo -> @@ -126,12 +136,8 @@ class MainComponent( } } - // reset select date - currentTimeTimer.start(1.minutes) { - withContext(Dispatchers.Main) { - mutableState.update { it.copy(selectedDate = currentLocalDateTime) } - slotComponent.sendIntent(SlotIntent.UpdateDate(currentLocalDateTime)) - } + coroutineScope.launch { + CurrentTimeHolder.currentTime.collect { updateTimeToNextEvent() } } } @@ -199,34 +205,6 @@ class MainComponent( } } - /** - * Handles deleting an event. - */ - fun handleDeleteEvent(slot: Slot) { - slotComponent.sendIntent( - SlotIntent.Delete( - slot = slot, - onDelete = { - deleteEventFromSlot(slot) - } - ) - ) - } - - /** - * Deletes an event from a slot. - */ - private fun deleteEventFromSlot(slot: Slot) { - coroutineScope.launch { - (slot as? Slot.EventSlot)?.eventInfo?.let { eventInfo -> - deleteBookingUseCase( - eventInfo = eventInfo, - roomName = getCurrentRoomName() - ) - } - } - } - /** * Updates the selected date. */ @@ -239,7 +217,8 @@ class MainComponent( // Only update if the new date is not in the past if (newDate.date >= currentLocalDateTime.date) { mutableState.update { it.copy(selectedDate = newDate) } - slotComponent.sendIntent(SlotIntent.UpdateDate(newDate)) + val selectedRoom = state.value.roomList[state.value.indexSelectRoom] + slotComponent.sendIntent(SlotIntent.UpdateRequest(selectedRoom.name, state.value.selectedDate)) } } @@ -270,18 +249,10 @@ class MainComponent( val selectedRoom = state.value.roomList.getOrNull(index) if (selectedRoom != null) { - updateComponents(selectedRoom, state.value.selectedDate) + slotComponent.sendIntent(SlotIntent.UpdateRequest(room = selectedRoom.name, state.value.selectedDate)) } } - /** - * Updates child components with new room and date information. - */ - private fun updateComponents(roomInfo: RoomInfo, date: LocalDateTime) { - slotComponent.sendIntent(SlotIntent.UpdateRequest(room = roomInfo.name)) - slotComponent.sendIntent(SlotIntent.UpdateDate(date)) - } - /** * Data class to hold the result of loading rooms. */ @@ -337,20 +308,31 @@ class MainComponent( */ private fun updateStateWithRoomsResult(roomsResult: RoomsResult) { mutableState.update { - it.copy( - isLoad = false, - isData = roomsResult.isSuccess, - isError = !roomsResult.isSuccess, - roomList = roomsResult.roomList, - indexSelectRoom = roomsResult.indexSelectRoom, - timeToNextEvent = getTimeToNextEventUseCase( - state.value.roomList, - state.value.indexSelectRoom - ), - ) + if (roomsResult.roomList.isEmpty()) { + it.copy( + isLoad = false, + isData = false, + isError = true, + roomList = listOf(RoomInfo.defaultValue), + indexSelectRoom = 0, + timeToNextEvent = 0 + ) + } else { + val selectedRoom = roomsResult.roomList[roomsResult.indexSelectRoom.coerceIn(0, roomsResult.roomList.size - 1)] + slotComponent.sendIntent(SlotIntent.UpdateRequest(selectedRoom.name, state.value.selectedDate)) + it.copy( + isLoad = false, + isData = roomsResult.isSuccess, + isError = !roomsResult.isSuccess, + roomList = roomsResult.roomList, + indexSelectRoom = roomsResult.indexSelectRoom, + timeToNextEvent = getTimeToNextEventUseCase( + rooms = roomsResult.roomList, + selectedRoomIndex = roomsResult.indexSelectRoom + ) + ) + } } - val selectedRoom = roomsResult.roomList[roomsResult.indexSelectRoom] - updateComponents(selectedRoom, state.value.selectedDate) } /** @@ -375,7 +357,7 @@ class MainComponent( loadRooms(roomIndex) currentState.roomList.getOrNull(roomIndex)?.let { roomInfo -> - updateComponents(roomInfo, currentState.selectedDate) + slotComponent.sendIntent(SlotIntent.UpdateRequest(roomInfo.name, currentState.selectedDate)) } } diff --git a/clients/tablet/feature/main/src/commonMain/kotlin/band/effective/office/tablet/feature/main/presentation/main/MainScreen.kt b/clients/tablet/feature/main/src/commonMain/kotlin/band/effective/office/tablet/feature/main/presentation/main/MainScreen.kt index 629febe2eb66dfb61c2b06e1a7652d508f9aefd8..1b5be6381d7f97b7eb64f806f214b21c4be2f6fd 100644 --- a/clients/tablet/feature/main/src/commonMain/kotlin/band/effective/office/tablet/feature/main/presentation/main/MainScreen.kt +++ b/clients/tablet/feature/main/src/commonMain/kotlin/band/effective/office/tablet/feature/main/presentation/main/MainScreen.kt @@ -11,12 +11,14 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import band.effective.office.tablet.core.ui.LoadMainScreen import band.effective.office.tablet.core.ui.common.ErrorMainScreen +import band.effective.office.tablet.feature.main.domain.CurrentTimeHolder import kotlin.time.ExperimentalTime @OptIn(ExperimentalTime::class) @Composable fun MainScreen(component: MainComponent) { val state by component.state.collectAsState() + val currentDate by CurrentTimeHolder.currentTime.collectAsState() Column( modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp), horizontalAlignment = Alignment.CenterHorizontally @@ -39,7 +41,7 @@ fun MainScreen(component: MainComponent) { onIncrementData = { component.sendIntent(Intent.OnUpdateSelectDate(updateInDays = 1)) }, onDecrementData = { component.sendIntent(Intent.OnUpdateSelectDate(updateInDays = -1)) }, selectedDate = state.selectedDate, - currentDate = state.currentDate, + currentDate = currentDate, onOpenDateTimePickerModalRequest = {}, // TODO ) } diff --git a/clients/tablet/feature/settings/src/commonMain/composeResources/values-ru/strings_ru.xml b/clients/tablet/feature/settings/src/commonMain/composeResources/values-ru/strings_ru.xml new file mode 100644 index 0000000000000000000000000000000000000000..45c0da64618a195eaad61e644867da1707a63007 --- /dev/null +++ b/clients/tablet/feature/settings/src/commonMain/composeResources/values-ru/strings_ru.xml @@ -0,0 +1,6 @@ + + + Выход + Переговорки + Выбрать ${nameRoom} + \ No newline at end of file diff --git a/clients/tablet/feature/settings/src/commonMain/composeResources/values/strings.xml b/clients/tablet/feature/settings/src/commonMain/composeResources/values/strings.xml index 45c0da64618a195eaad61e644867da1707a63007..3f37dbb1417e5b787582b954da8ce9ec3decaca8 100644 --- a/clients/tablet/feature/settings/src/commonMain/composeResources/values/strings.xml +++ b/clients/tablet/feature/settings/src/commonMain/composeResources/values/strings.xml @@ -1,6 +1,6 @@ - Выход - Переговорки - Выбрать ${nameRoom} + Exit + Meeting Rooms + Choose ${nameRoom} \ No newline at end of file diff --git a/clients/tablet/feature/slot/src/commonMain/kotlin/band/effective/office/tablet/feature/slot/presentation/SlotComponent.kt b/clients/tablet/feature/slot/src/commonMain/kotlin/band/effective/office/tablet/feature/slot/presentation/SlotComponent.kt index 74bf5858deb376d9000c387ed8969e75945a49a0..e140a019d38cf18c7964f50c3580678bccc4ed1d 100644 --- a/clients/tablet/feature/slot/src/commonMain/kotlin/band/effective/office/tablet/feature/slot/presentation/SlotComponent.kt +++ b/clients/tablet/feature/slot/src/commonMain/kotlin/band/effective/office/tablet/feature/slot/presentation/SlotComponent.kt @@ -23,7 +23,6 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import kotlinx.datetime.LocalDateTime import org.koin.core.component.KoinComponent import org.koin.core.component.inject @@ -60,6 +59,24 @@ class SlotComponent( } } + private fun resetAllMultiSlotStates() { + val slots = state.value.slots.toMutableList() + var hasChanges = false + + // Find all MultiSlot instances that are open and close them + slots.forEachIndexed { index, slot -> + if (slot is SlotUi.MultiSlot && slot.isOpen) { + slots[index] = slot.copy(isOpen = false) + hasChanges = true + } + } + + // Only update state if there were changes + if (hasChanges) { + mutableState.update { it.copy(slots = slots) } + } + } + private suspend fun updateSlots(uiSlots: List) = withContext(Dispatchers.Main.immediate) { if (uiSlots.isNotEmpty()) { val firstSlotStartInstant = uiSlots.first().slot.start.asInstant @@ -74,10 +91,8 @@ class SlotComponent( when (intent) { is SlotIntent.ClickToEdit -> handleClickToEdit(intent.slot) is SlotIntent.ClickToToggle -> openMultislot(intent.slot) - is SlotIntent.Delete -> deleteSlot(intent) - is SlotIntent.OnCancelDelete -> cancelDeletingSlot(intent) - is SlotIntent.UpdateDate -> updateDate(intent.newDate) is SlotIntent.UpdateRequest -> updateRequest(intent) + SlotIntent.InactivityTimeout -> resetAllMultiSlotStates() } } @@ -90,83 +105,20 @@ class SlotComponent( } private fun updateRequest(intent: SlotIntent.UpdateRequest) = coroutineScope.launch { - roomInfoUseCase.getRoom(intent.room)?.let { roomInfo -> - val slots = getSlotsByRoomUseCase(roomInfo = roomInfo) + roomInfoUseCase.getRoom(room = intent.room)?.let { roomInfo -> + val slots = getSlotsByRoomUseCase( + roomInfo = roomInfo, + start = maxOf( + OfficeTime.startWorkTime(intent.newDate.date).asInstant, + currentInstant, + ).asLocalDateTime + ) val uiSlots = slots.map(slotUiMapper::map) - mutableState.update { it.copy(slots = uiSlots) } - } - } - - private fun cancelDeletingSlot(intent: SlotIntent.OnCancelDelete) { - val slots = state.value.slots - val original = intent.slot.original - val newSlots = if (intent.slot.mainSlotIndex == null) { - slots.toMutableList().apply { this[intent.slot.index] = original } - } else { - val mainSlot = - (slots[intent.slot.mainSlotIndex as Int] as SlotUi.MultiSlot).run { - copy( - subSlots = subSlots.toMutableList() - .apply { this[intent.slot.index] = original } - ) - } - slots.toMutableList().apply { this[intent.slot.mainSlotIndex as Int] = mainSlot } + // Use updateSlots instead of directly updating state to preserve DeleteSlot and isOpen states + updateSlots(uiSlots) } - mutableState.update { it.copy(slots = newSlots) } } - private fun deleteSlot(intent: SlotIntent.Delete) { - val slots = state.value.slots - var mainSlot: SlotUi.MultiSlot? = null - val uiSlot = slots.firstOrNull { it.slot == intent.slot } - ?: slots.mapNotNull { (it as? SlotUi.MultiSlot)?.subSlots }.flatten() - .firstOrNull { it.slot == intent.slot } - ?.apply { - mainSlot = slots.mapNotNull { it as? SlotUi.MultiSlot } - .first { it.subSlots.contains(this) } - } - when { - uiSlot == null -> {} - mainSlot != null -> { - val indexInMultiSlot = mainSlot!!.subSlots.indexOf(uiSlot) - val indexMultiSlot = slots.indexOf(mainSlot) - val newMainSlot = mainSlot!!.copy( - subSlots = mainSlot!!.subSlots.toMutableList().apply { - this[indexInMultiSlot] = - SlotUi.DeleteSlot( - slot = intent.slot, - onDelete = intent.onDelete, - original = uiSlot, - index = indexInMultiSlot, - mainSlotIndex = indexMultiSlot - ) - }) - mutableState.update { - it.copy(slots = slots.toMutableList().apply { - this[indexMultiSlot] = newMainSlot - }) - } - } - - else -> { - val index = slots.indexOf(uiSlot) - mutableState.update { - it.copy( - slots = slots.toMutableList().apply { - this[index] = - SlotUi.DeleteSlot( - slot = intent.slot, - onDelete = intent.onDelete, - original = uiSlot, - index = index, - mainSlotIndex = null - ) - } - ) - } - } - } - } private fun Slot.execute() = when (this) { is Slot.EmptySlot -> executeFreeSlot(this) is Slot.EventSlot -> executeEventSlot(this) @@ -205,20 +157,6 @@ class SlotComponent( isLoading = false, ) - private fun updateDate(newDate: LocalDateTime) = coroutineScope.launch { - roomInfoUseCase.getRoom(room = roomName())?.let { roomInfo -> - val slots = getSlotsByRoomUseCase( - roomInfo = roomInfo, - start = maxOf( - OfficeTime.startWorkTime(newDate.date).asInstant, - currentInstant, - ).asLocalDateTime - ) - val uiSlots = slots.map(slotUiMapper::map) - mutableState.update { it.copy(slots = uiSlots) } - } - } - private fun setupRoomAvailabilityWatcher() { updateTimer.init(SLOT_UPDATE_INTERVAL_MINUTES) { withContext(Dispatchers.Main) { diff --git a/clients/tablet/feature/slot/src/commonMain/kotlin/band/effective/office/tablet/feature/slot/presentation/SlotIntent.kt b/clients/tablet/feature/slot/src/commonMain/kotlin/band/effective/office/tablet/feature/slot/presentation/SlotIntent.kt index bcb31aeefdfefcf3b2653d350b3fe784c1124523..ec6faa411220f4e825cf467d69f717a5b48ebbfd 100644 --- a/clients/tablet/feature/slot/src/commonMain/kotlin/band/effective/office/tablet/feature/slot/presentation/SlotIntent.kt +++ b/clients/tablet/feature/slot/src/commonMain/kotlin/band/effective/office/tablet/feature/slot/presentation/SlotIntent.kt @@ -1,14 +1,11 @@ package band.effective.office.tablet.feature.slot.presentation -import band.effective.office.tablet.core.domain.model.Slot import kotlinx.datetime.LocalDateTime sealed interface SlotIntent { data class ClickToEdit(val slot: SlotUi) : SlotIntent data class ClickToToggle(val slot: SlotUi.MultiSlot) : SlotIntent - data class UpdateDate(val newDate: LocalDateTime) : SlotIntent - data class UpdateRequest(val room: String) : SlotIntent - data class Delete(val slot: Slot, val onDelete: () -> Unit) : SlotIntent - data class OnCancelDelete(val slot: SlotUi.DeleteSlot) : SlotIntent + data class UpdateRequest(val room: String, val newDate: LocalDateTime) : SlotIntent + data object InactivityTimeout: SlotIntent } diff --git a/clients/tablet/feature/slot/src/commonMain/kotlin/band/effective/office/tablet/feature/slot/presentation/SlotUi.kt b/clients/tablet/feature/slot/src/commonMain/kotlin/band/effective/office/tablet/feature/slot/presentation/SlotUi.kt index e61244349dbe2edcd9618890193fa25285499d26..2270986f32bcd6fb67878b7074126bd1b9905048 100644 --- a/clients/tablet/feature/slot/src/commonMain/kotlin/band/effective/office/tablet/feature/slot/presentation/SlotUi.kt +++ b/clients/tablet/feature/slot/src/commonMain/kotlin/band/effective/office/tablet/feature/slot/presentation/SlotUi.kt @@ -9,14 +9,6 @@ sealed interface SlotUi { data class MultiSlot(override val slot: Slot, val subSlots: List, val isOpen: Boolean) : SlotUi - data class DeleteSlot( - override val slot: Slot, - val onDelete: () -> Unit, - val original: SlotUi, - val index: Int, - val mainSlotIndex: Int? - ) : SlotUi - data class NestedSlot(override val slot: Slot) : SlotUi data class LoadingSlot(override val slot: Slot) : SlotUi } diff --git a/clients/tablet/feature/slot/src/commonMain/kotlin/band/effective/office/tablet/feature/slot/presentation/components/BorderIndicator.kt b/clients/tablet/feature/slot/src/commonMain/kotlin/band/effective/office/tablet/feature/slot/presentation/components/BorderIndicator.kt deleted file mode 100644 index 80610fc8e296adb2da74dbb39c06049d358e88ac..0000000000000000000000000000000000000000 --- a/clients/tablet/feature/slot/src/commonMain/kotlin/band/effective/office/tablet/feature/slot/presentation/components/BorderIndicator.kt +++ /dev/null @@ -1,91 +0,0 @@ -package band.effective.office.tablet.feature.slot.presentation.components - -import androidx.compose.animation.core.LinearEasing -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.tween -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -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.geometry.CornerRadius -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Rect -import androidx.compose.ui.geometry.RoundRect -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.Path -import androidx.compose.ui.graphics.PathMeasure -import androidx.compose.ui.graphics.drawscope.Stroke -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import band.effective.office.tablet.core.ui.theme.deleteProgressColor -import kotlinx.coroutines.delay - -//https://stackoverflow.com/questions/75745905/rectangle-border-progress-bar -@Composable -fun BorderIndicator( - modifier: Modifier = Modifier.fillMaxSize(), - startDurationInSeconds: Int = 10, - stokeWidth: Dp, - onDispose: () -> Unit -) { - var targetValue by remember { - mutableStateOf(100f) - } - // This is the progress path which wis changed using path measure - val pathWithProgress by remember { mutableStateOf(Path()) } - - // using path - val pathMeasure by remember { mutableStateOf(PathMeasure()) } - - val path = remember { Path() } - - val progress by animateFloatAsState( - targetValue = targetValue, - animationSpec = tween(startDurationInSeconds * 1000, easing = LinearEasing), label = "" - ) - - Box(contentAlignment = Alignment.Center) { - Canvas(modifier = modifier) { - if (path.isEmpty) { - path.addRoundRect( - RoundRect( - Rect(offset = Offset.Zero, size), - cornerRadius = CornerRadius(100.dp.toPx(), 100.dp.toPx()) - ) - ) - } - pathWithProgress.reset() - pathMeasure.setPath(path, forceClosed = false) - pathMeasure.getSegment( - startDistance = 0f, - stopDistance = pathMeasure.length * progress / 100f, - destination = pathWithProgress, - startWithMoveTo = true - ) - - drawPath( - path = path, - style = Stroke(stokeWidth.toPx()), - color = Color.Gray - ) - - drawPath( - path = pathWithProgress, - style = Stroke(stokeWidth.toPx()), - color = deleteProgressColor - ) - } - } - LaunchedEffect(Unit) { - targetValue = 0f - delay(startDurationInSeconds * 1000L) - onDispose() - } -} \ No newline at end of file diff --git a/clients/tablet/feature/slot/src/commonMain/kotlin/band/effective/office/tablet/feature/slot/presentation/components/CommonSlotView.kt b/clients/tablet/feature/slot/src/commonMain/kotlin/band/effective/office/tablet/feature/slot/presentation/components/CommonSlotView.kt index e4d18c9dbe98224351af3fea421202413c9ffbef..15e075052c9ca0409736b4fbc33b516268b8aa6e 100644 --- a/clients/tablet/feature/slot/src/commonMain/kotlin/band/effective/office/tablet/feature/slot/presentation/components/CommonSlotView.kt +++ b/clients/tablet/feature/slot/src/commonMain/kotlin/band/effective/office/tablet/feature/slot/presentation/components/CommonSlotView.kt @@ -9,9 +9,9 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import band.effective.office.tablet.core.domain.util.toFormattedString import band.effective.office.tablet.core.ui.theme.h5 import band.effective.office.tablet.core.ui.theme.h7 +import band.effective.office.tablet.core.ui.utils.DateDisplayMapper import band.effective.office.tablet.feature.slot.presentation.SlotUi @Composable @@ -28,7 +28,7 @@ fun CommonSlotView( ) { Column { Text( - text = "${slot.start.toFormattedString("HH:mm")} - ${slot.finish.toFormattedString("HH:mm")}", + text = "${DateDisplayMapper.formatTime(slot.start)} — ${DateDisplayMapper.formatTime(slot.finish)}", style = MaterialTheme.typography.h5, color = MaterialTheme.colorScheme.onPrimary ) diff --git a/clients/tablet/feature/slot/src/commonMain/kotlin/band/effective/office/tablet/feature/slot/presentation/components/DeletedSlotView.kt b/clients/tablet/feature/slot/src/commonMain/kotlin/band/effective/office/tablet/feature/slot/presentation/components/DeletedSlotView.kt deleted file mode 100644 index a0c0eb9dd9b4cb0fe325998cff5506a7f5f4da36..0000000000000000000000000000000000000000 --- a/clients/tablet/feature/slot/src/commonMain/kotlin/band/effective/office/tablet/feature/slot/presentation/components/DeletedSlotView.kt +++ /dev/null @@ -1,56 +0,0 @@ -package band.effective.office.tablet.feature.slot.presentation.components - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.IntrinsicSize -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.unit.dp -import band.effective.office.tablet.core.ui.Res -import band.effective.office.tablet.core.ui.cancel -import band.effective.office.tablet.core.ui.theme.h7 -import band.effective.office.tablet.feature.slot.presentation.SlotUi -import org.jetbrains.compose.resources.stringResource - -@Composable -fun DeletedSlotView( - modifier: Modifier = Modifier, - slotUi: SlotUi.DeleteSlot, - onCancel: (SlotUi.DeleteSlot) -> Unit, - paddingValues: PaddingValues -) { - Box(modifier = modifier.height(IntrinsicSize.Min).width(IntrinsicSize.Min)) { - var isCancelability by remember { mutableStateOf(true) } - CommonSlotView( - modifier = Modifier - .padding(paddingValues) - .fillMaxWidth(), - slotUi = slotUi - ) { - if (isCancelability) { - Text( - modifier = Modifier.clickable { onCancel(slotUi) }, - text = stringResource(Res.string.cancel), - style = MaterialTheme.typography.h7, - color = Color.White - ) - } - } - BorderIndicator(onDispose = { - slotUi.onDelete() - isCancelability = false - }, stokeWidth = 10.dp) - } -} diff --git a/clients/tablet/feature/slot/src/commonMain/kotlin/band/effective/office/tablet/feature/slot/presentation/components/LoadingSlotView.kt b/clients/tablet/feature/slot/src/commonMain/kotlin/band/effective/office/tablet/feature/slot/presentation/components/LoadingSlotView.kt index 0e890a7f422b397243798d1d5c3265c182dfbf05..7e7fb4036974f1a00a9ccb430b74a899af974fdf 100644 --- a/clients/tablet/feature/slot/src/commonMain/kotlin/band/effective/office/tablet/feature/slot/presentation/components/LoadingSlotView.kt +++ b/clients/tablet/feature/slot/src/commonMain/kotlin/band/effective/office/tablet/feature/slot/presentation/components/LoadingSlotView.kt @@ -9,11 +9,11 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import band.effective.office.tablet.core.domain.util.toFormattedString import band.effective.office.tablet.core.ui.Res import band.effective.office.tablet.core.ui.loading_slot_for_time import band.effective.office.tablet.core.ui.theme.h5 import band.effective.office.tablet.core.ui.theme.h7 +import band.effective.office.tablet.core.ui.utils.DateDisplayMapper import band.effective.office.tablet.feature.slot.presentation.SlotUi import org.jetbrains.compose.resources.stringResource @@ -33,8 +33,8 @@ fun LoadingSlotView( Text( text = stringResource( Res.string.loading_slot_for_time, - slot.start.toFormattedString("HH:mm"), - slot.finish.toFormattedString("HH:mm") + DateDisplayMapper.formatTime(slot.start), + DateDisplayMapper.formatTime(slot.finish) ), style = MaterialTheme.typography.h5, color = MaterialTheme.colorScheme.onPrimary diff --git a/clients/tablet/feature/slot/src/commonMain/kotlin/band/effective/office/tablet/feature/slot/presentation/components/MultiSlotView.kt b/clients/tablet/feature/slot/src/commonMain/kotlin/band/effective/office/tablet/feature/slot/presentation/components/MultiSlotView.kt index d522e9f663c6e115d2a7354ea3903d9b9daf5efa..0fe0e9153f67ec1d46598cc0e093744ba2ab2412 100644 --- a/clients/tablet/feature/slot/src/commonMain/kotlin/band/effective/office/tablet/feature/slot/presentation/components/MultiSlotView.kt +++ b/clients/tablet/feature/slot/src/commonMain/kotlin/band/effective/office/tablet/feature/slot/presentation/components/MultiSlotView.kt @@ -2,7 +2,6 @@ package band.effective.office.tablet.feature.slot.presentation.components import androidx.compose.animation.animateContentSize import androidx.compose.foundation.Image -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight @@ -21,19 +20,16 @@ fun MultiSlotView( modifier: Modifier = Modifier, slotUi: SlotUi.MultiSlot, onItemClick: SlotUi.() -> Unit, - onToggle: SlotUi.() -> Unit, - onCancel: (SlotUi.DeleteSlot) -> Unit ) { Column(Modifier.animateContentSize()) { CommonSlotView( - modifier = modifier.clickable { onToggle(slotUi) }, + modifier = modifier, slotUi = slotUi ) { Image( modifier = Modifier .fillMaxHeight() - .rotate(if (slotUi.isOpen) 180f else 0f) - .clickable { onToggle(slotUi) }, + .rotate(if (slotUi.isOpen) 180f else 0f), painter = painterResource(Res.drawable.arrow_to_down), contentDescription = null ) @@ -45,8 +41,7 @@ fun MultiSlotView( SlotView( slotUi = it, onClick = onItemClick, - onToggle = onToggle, - onCancel = onCancel + onToggle = { /*Nothing*/ }, ) } } diff --git a/clients/tablet/feature/slot/src/commonMain/kotlin/band/effective/office/tablet/feature/slot/presentation/components/SlotView.kt b/clients/tablet/feature/slot/src/commonMain/kotlin/band/effective/office/tablet/feature/slot/presentation/components/SlotView.kt index e96e33cc1b0e5515c8d4d85fae9a85707294444c..4c1b0dec435acb1eb6ba380ce6f1cefa50143236 100644 --- a/clients/tablet/feature/slot/src/commonMain/kotlin/band/effective/office/tablet/feature/slot/presentation/components/SlotView.kt +++ b/clients/tablet/feature/slot/src/commonMain/kotlin/band/effective/office/tablet/feature/slot/presentation/components/SlotView.kt @@ -3,7 +3,6 @@ package band.effective.office.tablet.feature.slot.presentation.components import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.CircleShape @@ -11,7 +10,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import band.effective.office.tablet.core.domain.model.Slot import band.effective.office.tablet.core.domain.util.freeTime @@ -30,44 +28,31 @@ fun SlotView( slotUi: SlotUi, onClick: SlotUi.() -> Unit, onToggle: SlotUi.() -> Unit, - onCancel: (SlotUi.DeleteSlot) -> Unit ) { val borderShape = CircleShape - val baseModifier = Modifier + + val itemModifier = Modifier .fillMaxWidth() .clip(borderShape) - .background(MaterialTheme.colorScheme.surface) - - val itemModifier = baseModifier - .run { - when (slotUi) { - is SlotUi.MultiSlot -> this - else -> clickable { slotUi.onClick() } - } - } .then( - if (slotUi !is SlotUi.DeleteSlot) Modifier.border( - width = 5.dp, - color = slotUi.borderColor(), - shape = borderShape - ).padding(vertical = 15.dp, horizontal = 30.dp) - else Modifier + if (slotUi is SlotUi.MultiSlot) + Modifier.clickable { slotUi.onToggle() } + else + Modifier.clickable { slotUi.onClick() } ) + .background(MaterialTheme.colorScheme.surface) + .border( + width = 5.dp, + color = slotUi.borderColor(), + shape = borderShape + ).padding(vertical = 15.dp, horizontal = 30.dp) when (slotUi) { - is SlotUi.DeleteSlot -> DeletedSlotView( - modifier = itemModifier, - slotUi = slotUi, - onCancel = onCancel, - paddingValues = PaddingValues(vertical = 15.dp, horizontal = 30.dp) - ) is SlotUi.MultiSlot -> MultiSlotView( modifier = itemModifier, slotUi = slotUi, onItemClick = onClick, - onToggle = onToggle, - onCancel = onCancel ) is SlotUi.SimpleSlot -> CommonSlotView( @@ -89,7 +74,6 @@ fun SlotView( @Composable private fun SlotUi.borderColor() = when (this) { - is SlotUi.DeleteSlot -> Color.Gray is SlotUi.MultiSlot -> LocalCustomColorsPalette.current.busyStatus is SlotUi.NestedSlot -> subslotColor is SlotUi.SimpleSlot -> when (slot) { diff --git a/gradle.properties b/gradle.properties index cfac532387f9fee05f205ef648a58e582b1c81f5..7f2c12bb8c089134868c061f1060fc1019c3f089 100644 --- a/gradle.properties +++ b/gradle.properties @@ -9,6 +9,6 @@ kotlin.incremental=true # Project properties group=band.effective.office -version=0.0.2 +version=1.0.0 android.useAndroidX=true diff --git a/iosApp/iosApp/Info.plist b/iosApp/iosApp/Info.plist index 11845e1da7e0b58bbd6e684ba65da1e31ec829ae..bb19ebf6a4a3338d7cf1566b32de08fa60bf4a9d 100644 --- a/iosApp/iosApp/Info.plist +++ b/iosApp/iosApp/Info.plist @@ -4,5 +4,12 @@ CADisableMinimumFrameDurationOnPhone + CFBundleDevelopmentRegion + en + CFBundleLocalizations + + en + ru + diff --git a/media/tablet/demo-tablet.gif b/media/tablet/demo-tablet.gif index e6afa21c6e39706073a51847c0126be983877a52..4255f2a26ee1eff70b49f222d297a49e8c4b352a 100644 Binary files a/media/tablet/demo-tablet.gif and b/media/tablet/demo-tablet.gif differ