Skip to content

Commit 6607cf7

Browse files
authored
Merge pull request #359 from effective-dev-opensource/release/1.0.0
Release/1.0.0
2 parents 9cdc770 + 0d8b2d1 commit 6607cf7

File tree

53 files changed

+1158
-555
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

53 files changed

+1158
-555
lines changed

README.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ technologies as little as possible.
1717

1818
<img src="media/tablet/demo-tablet.gif" style="height: 50%;" />
1919

20-
### 🔧 Features Overview
20+
### Features Overview
2121

2222
| Feature | Description |
2323
|----------------------------|--------------------------------------------------------------|
@@ -54,6 +54,7 @@ technologies as little as possible.
5454
```bash
5555
cp backend/app/src/main/resources/env.example backend/app/src/main/resources/.env
5656
```
57+
Edit the `.env` file with your configuration.
5758

5859
4. Set up required credentials:
5960
- Add `google-credentials.json` for Google Calendar API
@@ -75,6 +76,12 @@ technologies as little as possible.
7576
./gradlew :backend:app:bootRun --args='--spring.profiles.active=local'
7677
```
7778

79+
#### Run Clients
80+
1. Open the project in Android Studio or IntelliJ IDEA
81+
2. Sync the Gradle project to download dependencies
82+
3. Choose the appropriate run configuration in IDE
83+
4. Run (Shift+F10 or Control+R)
84+
7885
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.
7986

8087
## Project Structure
@@ -123,7 +130,7 @@ An application for tracking foosball match results. It allows users to log games
123130
- [Tatyana Terleeva](https://t.me/tatyana_terleeva)
124131
- [Stanislav Radchenko](https://github.com/Radch-enko)
125132
- [Vitaly Smirnov](https://github.com/KrugarValdes)
126-
- [Victoria Maksimovna](https://t.me/the_koheskine)
133+
- [Viktoriya Kokh](https://t.me/the_koheskine)
127134

128135
## License
129136
The code is available as open source under the terms of the [MIT LICENSE](LICENSE).

backend/feature/booking/calendar/google/src/main/kotlin/band/effective/office/backend/feature/booking/calendar/google/GoogleCalendarProvider.kt

Lines changed: 138 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,10 @@ class GoogleCalendarProvider(
3333

3434
private val logger = LoggerFactory.getLogger(GoogleCalendarProvider::class.java)
3535

36+
companion object {
37+
private const val RESPONSE_STATUS_DECLINED = "declined"
38+
}
39+
3640
@Value("\${calendar.default-calendar}")
3741
private lateinit var defaultCalendar: String
3842

@@ -203,38 +207,154 @@ class GoogleCalendarProvider(
203207
}
204208
}
205209

210+
/**
211+
* Deletes an event from Google Calendar.
212+
*
213+
* @param calendarId The ID of the calendar containing the event
214+
* @param eventId The ID of the event to delete
215+
* @return true if the event was successfully deleted, false otherwise
216+
*/
217+
private fun deleteEvent(calendarId: String, eventId: String): Boolean {
218+
return try {
219+
calendar.events().delete(calendarId, eventId).execute()
220+
logger.info("Successfully deleted event $eventId from calendar $calendarId because all participants declined")
221+
true
222+
} catch (e: GoogleJsonResponseException) {
223+
logger.error("Failed to delete event $eventId from calendar $calendarId: {}", e.details)
224+
false
225+
} catch (e: Exception) {
226+
logger.error("Unexpected error when deleting event $eventId from calendar $calendarId", e)
227+
false
228+
}
229+
}
230+
231+
/**
232+
* Retrieves a list of events from a Google Calendar within a specified time range.
233+
*
234+
* This method performs the following operations:
235+
* 1. Fetches events from the specified calendar within the given time range
236+
* 2. Filters out events where all human participants have declined (and optionally deletes them)
237+
* 3. Filters out events where any resource (room/workspace) has declined
238+
*
239+
* @param calendarId The ID of the Google Calendar to query
240+
* @param from The start time for the query (inclusive)
241+
* @param to The end time for the query (exclusive), or null for no end time limit
242+
* @param q Optional search term to filter events by
243+
* @param returnInstances Whether to expand recurring events into individual instances
244+
* @return A filtered list of Google Calendar events
245+
*/
206246
private fun listEvents(
207247
calendarId: String,
208248
from: Instant,
209249
to: Instant?,
210250
q: String? = null,
211251
returnInstances: Boolean = true
212252
): List<Event> {
213-
val eventsRequest = calendar.events().list(calendarId)
253+
// Build the events request with required parameters
254+
val eventsRequest = buildEventsRequest(calendarId, from, to, q, returnInstances)
255+
256+
return try {
257+
// Execute the request and get the events
258+
val fetchedEvents = eventsRequest.execute().items ?: emptyList()
259+
260+
// Process and filter the events
261+
processEvents(fetchedEvents)
262+
} catch (e: GoogleJsonResponseException) {
263+
handleGoogleJsonException(e, calendarId)
264+
emptyList()
265+
} catch (e: Exception) {
266+
logger.error("Unexpected error when listing events from Google Calendar for calendar ID: $calendarId", e)
267+
emptyList()
268+
}
269+
}
270+
271+
/**
272+
* Builds a Google Calendar events request with the specified parameters.
273+
*/
274+
private fun buildEventsRequest(
275+
calendarId: String,
276+
from: Instant,
277+
to: Instant?,
278+
q: String?,
279+
returnInstances: Boolean
280+
): Calendar.Events.List {
281+
val request = calendar.events().list(calendarId)
214282
.setTimeMin(DateTime(from.toEpochMilli()))
215283
.setSingleEvents(returnInstances)
216284

217-
if (to != null) {
218-
eventsRequest.timeMax = DateTime(to.toEpochMilli())
285+
// Add optional parameters if provided
286+
to?.let { request.timeMax = DateTime(it.toEpochMilli()) }
287+
q?.let { request.q = it }
288+
289+
return request
290+
}
291+
292+
/**
293+
* Processes and filters the fetched events according to business rules.
294+
*/
295+
private fun processEvents(events: List<Event>): List<Event> {
296+
// Remove events where all human participants have declined
297+
val eventsAfterParticipantCheck = events.filter { event ->
298+
!shouldRemoveEventWithAllDeclinedParticipants(event)
219299
}
220300

221-
if (q != null) {
222-
eventsRequest.q = q
301+
// Remove events where any resource has declined
302+
return eventsAfterParticipantCheck.filter { event ->
303+
!hasAnyResourceDeclined(event)
223304
}
305+
}
224306

225-
return try {
226-
eventsRequest.execute().items ?: emptyList()
227-
} catch (e: GoogleJsonResponseException) {
228-
logger.error("Failed to list events from Google Calendar: {}", e.details)
229-
if (e.statusCode == 404) {
230-
logger.warn("Calendar with ID {} not found", calendarId)
231-
} else if (e.statusCode == 403) {
232-
logger.warn("Permission denied for calendar with ID {}", calendarId)
233-
}
234-
emptyList()
235-
} catch (e: Exception) {
236-
logger.error("Unexpected error when listing events from Google Calendar", e)
237-
emptyList()
307+
/**
308+
* Checks if an event should be removed because all human participants have declined.
309+
* If all participants have declined, attempts to delete the event.
310+
*
311+
* @return true if the event should be removed from results, false otherwise
312+
*/
313+
private fun shouldRemoveEventWithAllDeclinedParticipants(event: Event): Boolean {
314+
val isDefaultOrganizer = event.organizer?.email == defaultCalendar
315+
316+
// Only check events organized by our default calendar
317+
if (event.attendees == null || event.attendees.isEmpty() || !isDefaultOrganizer) {
318+
return false
319+
}
320+
321+
// Get all human participants (non-resource attendees)
322+
val humanParticipants = event.attendees.filter { it.resource == false || it.resource == null }
323+
324+
// Check if all human participants have declined
325+
val allHumansDeclined = humanParticipants.isNotEmpty() &&
326+
humanParticipants.all { it.responseStatus == RESPONSE_STATUS_DECLINED }
327+
328+
// If all humans declined, try to delete the event
329+
if (allHumansDeclined) {
330+
logger.warn("Deleting event with ID {} because all participants declined", event.id)
331+
val deletionSucceeded = deleteEvent(defaultCalendar, event.id)
332+
return deletionSucceeded
333+
}
334+
335+
return false
336+
}
337+
338+
/**
339+
* Checks if any resource (room/workspace) attendee has declined the event.
340+
*
341+
* @return true if any resource has declined, false otherwise
342+
*/
343+
private fun hasAnyResourceDeclined(event: Event): Boolean {
344+
return event.attendees?.any { attendee ->
345+
attendee.resource == true && attendee.responseStatus == RESPONSE_STATUS_DECLINED
346+
} ?: false
347+
}
348+
349+
/**
350+
* Handles Google JSON API exceptions with appropriate logging.
351+
*/
352+
private fun handleGoogleJsonException(e: GoogleJsonResponseException, calendarId: String) {
353+
logger.error("Failed to list events from Google Calendar: {}", e.details)
354+
when (e.statusCode) {
355+
404 -> logger.warn("Calendar with ID {} not found", calendarId)
356+
403 -> logger.warn("Permission denied for calendar with ID {}", calendarId)
357+
else -> logger.error("Google API error with status code {} for calendar ID {}", e.statusCode, calendarId)
238358
}
239359
}
240360

backend/feature/notifications/src/main/kotlin/band/effective/office/backend/feature/notifications/controller/NotificationDeduplicator.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import org.springframework.stereotype.Component
1010
@Component
1111
class NotificationDeduplicator {
1212

13-
private val ttlSeconds = 10L
13+
private val ttlSeconds = 5L
1414

1515
@OptIn(ExperimentalTime::class)
1616
private val seenEvents = ConcurrentHashMap<String, Instant>()

clients/tablet/composeApp/build.gradle.kts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,8 +84,8 @@ android {
8484
targetSdk = libs.versions.android.targetSdk.get().toInt()
8585

8686
applicationId = "band.effective.office.tablet"
87-
versionCode = 1
88-
versionName = "0.0.2"
87+
versionCode = 2
88+
versionName = "1.0.0"
8989

9090
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
9191

clients/tablet/composeApp/src/androidMain/kotlin/band/effective/office/tablet/App.android.kt

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import androidx.activity.compose.setContent
1010
import androidx.activity.enableEdgeToEdge
1111
import androidx.annotation.RequiresApi
1212
import band.effective.office.tablet.root.RootComponent
13+
import band.effective.office.tablet.time.TimeReceiver
1314
import com.arkivanov.decompose.defaultComponentContext
1415

1516
class AppActivity : ComponentActivity() {
@@ -18,15 +19,25 @@ class AppActivity : ComponentActivity() {
1819
var isRunKioskMode = false
1920
}
2021

22+
val timeReceiver by lazy { TimeReceiver(this) }
23+
2124
@RequiresApi(Build.VERSION_CODES.P)
2225
override fun onCreate(savedInstanceState: Bundle?) {
2326
runKioskMode()
2427
super.onCreate(savedInstanceState)
28+
29+
timeReceiver.register()
2530
enableEdgeToEdge()
2631
val root = RootComponent(componentContext = defaultComponentContext())
2732
setContent { App(root) }
2833
}
2934

35+
override fun onDestroy() {
36+
// Unregister the time receiver
37+
timeReceiver.unregister()
38+
super.onDestroy()
39+
}
40+
3041
@RequiresApi(Build.VERSION_CODES.P)
3142
private fun runKioskMode(){
3243
val context = this

clients/tablet/composeApp/src/androidMain/kotlin/band/effective/office/tablet/App.kt

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
package band.effective.office.tablet
22

33
import android.app.Application
4-
import android.util.Log
4+
import band.effective.office.tablet.core.domain.manager.DateResetManager
55
import band.effective.office.tablet.core.domain.model.SettingsManager
6+
import band.effective.office.tablet.core.ui.inactivity.InactivityLifecycleCallbacks
67
import band.effective.office.tablet.di.KoinInitializer
78
import com.google.firebase.messaging.FirebaseMessaging
89
import com.russhwolf.settings.SharedPreferencesSettings
910
import org.koin.android.ext.android.get
1011
import org.koin.core.qualifier.named
12+
import kotlin.time.Duration.Companion.minutes
1113

1214
class App : Application() {
1315

@@ -24,6 +26,7 @@ class App : Application() {
2426
)
2527
)
2628
subscribeOnFirebaseTopics()
29+
initializeInactivitySystem()
2730
}
2831

2932
private fun subscribeOnFirebaseTopics() {
@@ -32,4 +35,14 @@ class App : Application() {
3235
FirebaseMessaging.getInstance().subscribeToTopic(topic)
3336
}
3437
}
38+
39+
private fun initializeInactivitySystem() {
40+
InactivityLifecycleCallbacks.initialize(
41+
application = this,
42+
timeoutMs = 1.minutes.inWholeMilliseconds,
43+
callback = {
44+
DateResetManager.resetDate()
45+
}
46+
)
47+
}
3548
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
package band.effective.office.tablet.time
2+
3+
import android.content.BroadcastReceiver
4+
import android.content.Context
5+
import android.content.Intent
6+
import android.content.IntentFilter
7+
import android.util.Log
8+
import band.effective.office.tablet.feature.main.domain.CurrentTimeHolder
9+
import kotlinx.coroutines.flow.StateFlow
10+
import kotlinx.datetime.Clock
11+
import kotlinx.datetime.LocalDateTime
12+
import kotlinx.datetime.TimeZone
13+
import kotlinx.datetime.toLocalDateTime
14+
15+
/**
16+
* A broadcast receiver that listens for time-related broadcasts and emits the current time.
17+
*/
18+
actual class TimeReceiver(private val context: Context) {
19+
20+
actual val currentTime: StateFlow<LocalDateTime> = CurrentTimeHolder.currentTime
21+
22+
private val receiver = object : BroadcastReceiver() {
23+
override fun onReceive(context: Context?, intent: Intent?) {
24+
when (intent?.action) {
25+
Intent.ACTION_TIME_TICK, Intent.ACTION_TIME_CHANGED -> {
26+
CurrentTimeHolder.updateTime(getCurrentTime())
27+
}
28+
}
29+
}
30+
}
31+
32+
/**
33+
* Registers the broadcast receiver to listen for time-related broadcasts.
34+
*/
35+
fun register() {
36+
val filter = IntentFilter().apply {
37+
addAction(Intent.ACTION_TIME_TICK)
38+
addAction(Intent.ACTION_TIME_CHANGED)
39+
}
40+
context.registerReceiver(receiver, filter)
41+
}
42+
43+
/**
44+
* Unregisters the broadcast receiver.
45+
*/
46+
fun unregister() {
47+
context.unregisterReceiver(receiver)
48+
}
49+
50+
/**
51+
* Gets the current time as a LocalDateTime.
52+
*/
53+
private fun getCurrentTime(): LocalDateTime {
54+
return Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault())
55+
}
56+
}

clients/tablet/composeApp/src/commonMain/kotlin/band/effective/office/tablet/root/RootComponent.kt

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ package band.effective.office.tablet.root
22

33
import band.effective.office.tablet.core.domain.model.EventInfo
44
import band.effective.office.tablet.core.domain.model.RoomInfo
5-
import band.effective.office.tablet.core.domain.model.Slot
65
import band.effective.office.tablet.core.domain.useCase.CheckSettingsUseCase
76
import band.effective.office.tablet.core.domain.useCase.ResourceDisposerUseCase
87
import band.effective.office.tablet.core.ui.common.ModalWindow
@@ -153,16 +152,10 @@ class RootComponent(
153152
componentContext = componentContext,
154153
initialEvent = config.event,
155154
roomName = config.room,
156-
onDeleteEvent = ::handleDeleteEvent,
157155
onCloseRequest = modalNavigation::dismiss,
158156
)
159157
}
160158

161-
private fun handleDeleteEvent(slot: Slot) {
162-
val mainComponent = (childStack.value.active.instance as? Child.MainChild)?.component
163-
mainComponent?.handleDeleteEvent(slot)
164-
}
165-
166159
private fun createFastBookingComponent(
167160
config: ModalWindowsConfig.FastEvent,
168161
componentContext: ComponentContext

0 commit comments

Comments
 (0)