commit
6b7ec41f32
23 changed files with 207 additions and 57 deletions
1
.idea/inspectionProfiles/Project_Default.xml
generated
1
.idea/inspectionProfiles/Project_Default.xml
generated
|
@ -42,5 +42,6 @@
|
|||
<option name="processLiterals" value="true" />
|
||||
<option name="processComments" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="TestFunctionName" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
|
||||
</profile>
|
||||
</component>
|
2
.idea/misc.xml
generated
2
.idea/misc.xml
generated
|
@ -1,7 +1,7 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_17_PREVIEW" project-jdk-name="jbr-17" project-jdk-type="JavaSDK">
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_17" default="true" project-jdk-name="jbr-17" project-jdk-type="JavaSDK">
|
||||
<output url="file://$PROJECT_DIR$/build/classes" />
|
||||
</component>
|
||||
<component name="ProjectType">
|
||||
|
|
|
@ -66,6 +66,7 @@ dependencies {
|
|||
implementation "androidx.compose.ui:ui-tooling-preview:$compose_version"
|
||||
implementation 'androidx.compose.material:material:1.2.0'
|
||||
|
||||
|
||||
debugImplementation "androidx.compose.ui:ui-test-manifest:$compose_version"
|
||||
|
||||
// ViewModel
|
||||
|
@ -97,6 +98,9 @@ dependencies {
|
|||
testImplementation 'junit:junit:4.13.2'
|
||||
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
|
||||
|
||||
// Coroutine testing
|
||||
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.4'
|
||||
|
||||
// Mocking
|
||||
testImplementation 'org.mockito.kotlin:mockito-kotlin:3.2.0'
|
||||
|
||||
|
|
|
@ -10,9 +10,15 @@ import androidx.compose.material.Text
|
|||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import be.ugent.sel.studeez.StudeezApp
|
||||
import be.ugent.sel.studeez.screens.session.InvisibleSessionManager
|
||||
import be.ugent.sel.studeez.ui.theme.StudeezTheme
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
var onTimerInvisible: Job? = null
|
||||
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
@ -30,6 +36,18 @@ class MainActivity : ComponentActivity() {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
onTimerInvisible = lifecycleScope.launch {
|
||||
InvisibleSessionManager.updateTimer()
|
||||
}
|
||||
super.onStop()
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
onTimerInvisible?.cancel()
|
||||
super.onStart()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
package be.ugent.sel.studeez.common.composable.navbar
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.material.BottomNavigation
|
||||
import androidx.compose.material.BottomNavigationItem
|
||||
import androidx.compose.material.Icon
|
||||
|
|
|
@ -1,9 +1,5 @@
|
|||
package be.ugent.sel.studeez.data.local.models.timer_functional
|
||||
|
||||
import be.ugent.sel.studeez.data.local.models.SessionReport
|
||||
import be.ugent.sel.studeez.screens.session.sessionScreens.CustomSessionScreen
|
||||
import be.ugent.sel.studeez.screens.session.sessionScreens.AbstractSessionScreen
|
||||
|
||||
class FunctionalCustomTimer(studyTime: Int) : FunctionalTimer(studyTime) {
|
||||
|
||||
override fun tick() {
|
||||
|
|
|
@ -1,8 +1,5 @@
|
|||
package be.ugent.sel.studeez.data.local.models.timer_functional
|
||||
|
||||
import be.ugent.sel.studeez.screens.session.sessionScreens.BreakSessionScreen
|
||||
import be.ugent.sel.studeez.screens.session.sessionScreens.AbstractSessionScreen
|
||||
|
||||
class FunctionalPomodoroTimer(
|
||||
private var studyTime: Int,
|
||||
private var breakTime: Int, repeats: Int
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
package be.ugent.sel.studeez.data.local.models.timer_functional
|
||||
|
||||
import be.ugent.sel.studeez.data.local.models.SessionReport
|
||||
import be.ugent.sel.studeez.screens.session.sessionScreens.AbstractSessionScreen
|
||||
import com.google.firebase.Timestamp
|
||||
|
||||
abstract class FunctionalTimer(initialValue: Int) {
|
||||
|
|
|
@ -5,7 +5,6 @@ import androidx.compose.runtime.Composable
|
|||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import be.ugent.sel.studeez.R
|
||||
import be.ugent.sel.studeez.common.composable.BasicTextButton
|
||||
import be.ugent.sel.studeez.common.composable.LabelledInputField
|
||||
|
|
|
@ -0,0 +1,29 @@
|
|||
package be.ugent.sel.studeez.screens.session
|
||||
|
||||
import android.media.MediaPlayer
|
||||
import kotlinx.coroutines.delay
|
||||
import javax.inject.Singleton
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
@Singleton
|
||||
object InvisibleSessionManager {
|
||||
private var viewModel: SessionViewModel? = null
|
||||
private lateinit var mediaPlayer: MediaPlayer
|
||||
|
||||
fun setParameters(viewModel: SessionViewModel, mediaplayer: MediaPlayer) {
|
||||
this.viewModel = viewModel
|
||||
this.mediaPlayer = mediaplayer
|
||||
}
|
||||
|
||||
suspend fun updateTimer() {
|
||||
viewModel?.let {
|
||||
while (!it.getTimer().hasEnded()) {
|
||||
delay(1.seconds)
|
||||
it.getTimer().tick()
|
||||
if (it.getTimer().hasCurrentCountdownEnded()) {
|
||||
mediaPlayer.start()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -12,7 +12,7 @@ import be.ugent.sel.studeez.screens.session.sessionScreens.GetSessionScreen
|
|||
data class SessionActions(
|
||||
val getTimer: () -> FunctionalTimer,
|
||||
val getTask: () -> String,
|
||||
val prepareMediaPlayer: () -> Unit,
|
||||
val startMediaPlayer: () -> Unit,
|
||||
val releaseMediaPlayer: () -> Unit,
|
||||
val endSession: () -> Unit
|
||||
)
|
||||
|
@ -26,8 +26,8 @@ private fun getSessionActions(
|
|||
getTimer = viewModel::getTimer,
|
||||
getTask = viewModel::getTask,
|
||||
endSession = { viewModel.endSession(openAndPopUp) },
|
||||
prepareMediaPlayer = mediaplayer::prepareAsync,
|
||||
releaseMediaPlayer = mediaplayer::release
|
||||
startMediaPlayer = mediaplayer::start,
|
||||
releaseMediaPlayer = mediaplayer::release,
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -39,26 +39,15 @@ fun SessionRoute(
|
|||
) {
|
||||
val context = LocalContext.current
|
||||
val uri: Uri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION)
|
||||
val mediaplayer = MediaPlayer()
|
||||
mediaplayer.setDataSource(context, uri)
|
||||
mediaplayer.setOnCompletionListener {
|
||||
mediaplayer.stop()
|
||||
//if (timerEnd) {
|
||||
// mediaplayer.release()
|
||||
//}
|
||||
}
|
||||
mediaplayer.setOnPreparedListener {
|
||||
// mediaplayer.start()
|
||||
}
|
||||
val mediaplayer = MediaPlayer.create(context, uri)
|
||||
mediaplayer.isLooping = false
|
||||
|
||||
val sessionScreen: AbstractSessionScreen = viewModel.getTimer().accept(GetSessionScreen())
|
||||
InvisibleSessionManager.setParameters(
|
||||
viewModel = viewModel,
|
||||
mediaplayer = mediaplayer
|
||||
)
|
||||
|
||||
//val sessionScreen = when (val timer = viewModel.getTimer()) {
|
||||
// is FunctionalCustomTimer -> CustomSessionScreen(timer)
|
||||
// is FunctionalPomodoroTimer -> BreakSessionScreen(timer)
|
||||
// is FunctionalEndlessTimer -> EndlessSessionScreen()
|
||||
// else -> throw java.lang.IllegalArgumentException("Unknown Timer")
|
||||
//}
|
||||
val sessionScreen: AbstractSessionScreen = viewModel.getTimer().accept(GetSessionScreen(mediaplayer))
|
||||
|
||||
sessionScreen(
|
||||
open = open,
|
||||
|
|
|
@ -19,15 +19,12 @@ import androidx.compose.ui.tooling.preview.Preview
|
|||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import be.ugent.sel.studeez.data.local.models.timer_functional.FunctionalEndlessTimer
|
||||
import be.ugent.sel.studeez.navigation.StudeezDestinations
|
||||
import be.ugent.sel.studeez.screens.session.SessionActions
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
abstract class AbstractSessionScreen {
|
||||
|
||||
var timerEnd = false
|
||||
|
||||
@Composable
|
||||
operator fun invoke(
|
||||
open: (String) -> Unit,
|
||||
|
@ -74,22 +71,10 @@ abstract class AbstractSessionScreen {
|
|||
LaunchedEffect(tikker) {
|
||||
delay(1.seconds)
|
||||
sessionActions.getTimer().tick()
|
||||
callMediaPlayer()
|
||||
tikker = !tikker
|
||||
}
|
||||
|
||||
if (
|
||||
sessionActions.getTimer().hasCurrentCountdownEnded() && !sessionActions.getTimer()
|
||||
.hasEnded()
|
||||
) {
|
||||
// sessionActions.prepareMediaPlayer()
|
||||
}
|
||||
|
||||
if (!timerEnd && sessionActions.getTimer().hasEnded()) {
|
||||
// sessionActions.prepareMediaPlayer()
|
||||
timerEnd =
|
||||
true // Placeholder, vanaf hier moet het report opgestart worden en de sessie afgesloten
|
||||
}
|
||||
|
||||
val hms = sessionActions.getTimer().getHoursMinutesSeconds()
|
||||
Column {
|
||||
Text(
|
||||
|
@ -136,6 +121,8 @@ abstract class AbstractSessionScreen {
|
|||
@Composable
|
||||
abstract fun motivationString(): String
|
||||
|
||||
abstract fun callMediaPlayer()
|
||||
|
||||
}
|
||||
|
||||
@Preview
|
||||
|
@ -144,6 +131,7 @@ fun TimerPreview() {
|
|||
val sessionScreen = object : AbstractSessionScreen() {
|
||||
@Composable
|
||||
override fun motivationString(): String = "Test"
|
||||
override fun callMediaPlayer() {}
|
||||
|
||||
}
|
||||
sessionScreen.Timer(sessionActions = SessionActions({ FunctionalEndlessTimer() }, { "Preview" }, {}, {}, {}))
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
package be.ugent.sel.studeez.screens.session.sessionScreens
|
||||
|
||||
import android.media.MediaPlayer
|
||||
import androidx.compose.runtime.Composable
|
||||
import be.ugent.sel.studeez.R
|
||||
import be.ugent.sel.studeez.data.local.models.timer_functional.FunctionalPomodoroTimer
|
||||
|
@ -7,7 +8,8 @@ import be.ugent.sel.studeez.resources
|
|||
import be.ugent.sel.studeez.R.string as AppText
|
||||
|
||||
class BreakSessionScreen(
|
||||
private val funPomoDoroTimer: FunctionalPomodoroTimer
|
||||
private val funPomoDoroTimer: FunctionalPomodoroTimer,
|
||||
private var mediaplayer: MediaPlayer?
|
||||
): AbstractSessionScreen() {
|
||||
|
||||
@Composable
|
||||
|
@ -27,4 +29,17 @@ class BreakSessionScreen(
|
|||
)
|
||||
}
|
||||
|
||||
override fun callMediaPlayer() {
|
||||
if (funPomoDoroTimer.hasEnded()) {
|
||||
mediaplayer?.let { it: MediaPlayer ->
|
||||
it.setOnCompletionListener {
|
||||
it.release()
|
||||
mediaplayer = null
|
||||
}
|
||||
it.start()
|
||||
}
|
||||
} else if (funPomoDoroTimer.hasCurrentCountdownEnded()) {
|
||||
mediaplayer?.start()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,5 +1,6 @@
|
|||
package be.ugent.sel.studeez.screens.session.sessionScreens
|
||||
|
||||
import android.media.MediaPlayer
|
||||
import androidx.compose.runtime.Composable
|
||||
import be.ugent.sel.studeez.data.local.models.timer_functional.FunctionalCustomTimer
|
||||
import be.ugent.sel.studeez.resources
|
||||
|
@ -7,7 +8,8 @@ import be.ugent.sel.studeez.R.string as AppText
|
|||
|
||||
|
||||
class CustomSessionScreen(
|
||||
private val functionalTimer: FunctionalCustomTimer
|
||||
private val functionalTimer: FunctionalCustomTimer,
|
||||
private var mediaplayer: MediaPlayer?
|
||||
): AbstractSessionScreen() {
|
||||
|
||||
@Composable
|
||||
|
@ -18,4 +20,16 @@ class CustomSessionScreen(
|
|||
return resources().getString(AppText.state_focus)
|
||||
}
|
||||
|
||||
override fun callMediaPlayer() {
|
||||
if (functionalTimer.hasEnded()) {
|
||||
mediaplayer?.let { it: MediaPlayer ->
|
||||
it.setOnCompletionListener {
|
||||
it.release()
|
||||
mediaplayer = null
|
||||
}
|
||||
it.start()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -11,4 +11,6 @@ class EndlessSessionScreen : AbstractSessionScreen() {
|
|||
override fun motivationString(): String {
|
||||
return resources().getString(AppText.state_focus)
|
||||
}
|
||||
|
||||
override fun callMediaPlayer() {}
|
||||
}
|
|
@ -1,17 +1,18 @@
|
|||
package be.ugent.sel.studeez.screens.session.sessionScreens
|
||||
|
||||
import android.media.MediaPlayer
|
||||
import be.ugent.sel.studeez.data.local.models.timer_functional.FunctionalCustomTimer
|
||||
import be.ugent.sel.studeez.data.local.models.timer_functional.FunctionalEndlessTimer
|
||||
import be.ugent.sel.studeez.data.local.models.timer_functional.FunctionalPomodoroTimer
|
||||
import be.ugent.sel.studeez.data.local.models.timer_functional.FunctionalTimerVisitor
|
||||
|
||||
class GetSessionScreen : FunctionalTimerVisitor<AbstractSessionScreen> {
|
||||
class GetSessionScreen(private val mediaplayer: MediaPlayer?) : FunctionalTimerVisitor<AbstractSessionScreen> {
|
||||
override fun visitFunctionalCustomTimer(functionalCustomTimer: FunctionalCustomTimer): AbstractSessionScreen =
|
||||
CustomSessionScreen(functionalCustomTimer)
|
||||
CustomSessionScreen(functionalCustomTimer, mediaplayer)
|
||||
|
||||
override fun visitFunctionalEndlessTimer(functionalEndlessTimer: FunctionalEndlessTimer): AbstractSessionScreen =
|
||||
EndlessSessionScreen()
|
||||
|
||||
override fun visitFunctionalBreakTimer(functionalPomodoroTimer: FunctionalPomodoroTimer): AbstractSessionScreen =
|
||||
BreakSessionScreen(functionalPomodoroTimer)
|
||||
BreakSessionScreen(functionalPomodoroTimer, mediaplayer)
|
||||
}
|
|
@ -26,7 +26,7 @@ fun getSessionRecapActions(
|
|||
return SessionRecapActions(
|
||||
viewModel::getSessionReport,
|
||||
{viewModel.saveSession(openAndPopUp)},
|
||||
{viewModel.saveSession(openAndPopUp)}
|
||||
{viewModel.discardSession(openAndPopUp)}
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -45,6 +45,7 @@
|
|||
|
||||
<!-- Sessions -->
|
||||
<string name="sessions">Sessions</string>
|
||||
<string name="end_session">End session</string>
|
||||
|
||||
<!-- Profile -->
|
||||
<string name="profile">Profile</string>
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
package be.ugent.sel.studeez.timer_functional
|
||||
|
||||
import be.ugent.sel.studeez.data.local.models.timer_functional.FunctionalCustomTimer
|
||||
import be.ugent.sel.studeez.data.local.models.timer_functional.FunctionalTimer
|
||||
import org.junit.Assert
|
||||
import org.junit.Test
|
||||
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
package be.ugent.sel.studeez.timer_functional
|
||||
|
||||
import be.ugent.sel.studeez.data.local.models.timer_functional.FunctionalEndlessTimer
|
||||
import be.ugent.sel.studeez.data.local.models.timer_functional.FunctionalTimer
|
||||
import org.junit.Assert
|
||||
import org.junit.Test
|
||||
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
package be.ugent.sel.studeez.timer_functional
|
||||
|
||||
import be.ugent.sel.studeez.data.local.models.timer_functional.FunctionalPomodoroTimer
|
||||
import be.ugent.sel.studeez.data.local.models.timer_functional.FunctionalTimer
|
||||
import org.junit.Assert
|
||||
import org.junit.Test
|
||||
|
||||
|
|
|
@ -0,0 +1,99 @@
|
|||
package be.ugent.sel.studeez.timer_functional
|
||||
|
||||
import android.media.MediaPlayer
|
||||
import be.ugent.sel.studeez.data.SelectedTimerState
|
||||
import be.ugent.sel.studeez.data.local.models.timer_functional.FunctionalCustomTimer
|
||||
import be.ugent.sel.studeez.data.local.models.timer_functional.FunctionalEndlessTimer
|
||||
import be.ugent.sel.studeez.data.local.models.timer_functional.FunctionalPomodoroTimer
|
||||
import be.ugent.sel.studeez.screens.session.InvisibleSessionManager
|
||||
import be.ugent.sel.studeez.screens.session.SessionViewModel
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.advanceTimeBy
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert
|
||||
import org.junit.Test
|
||||
import org.mockito.kotlin.mock
|
||||
|
||||
@ExperimentalCoroutinesApi
|
||||
class InvisibleSessionManagerTest {
|
||||
private var timerState: SelectedTimerState = SelectedTimerState()
|
||||
private lateinit var viewModel: SessionViewModel
|
||||
private var mediaPlayer: MediaPlayer = mock()
|
||||
|
||||
@Test
|
||||
fun InvisibleEndlessTimerTest() = runTest {
|
||||
timerState.selectedTimer = FunctionalEndlessTimer()
|
||||
viewModel = SessionViewModel(timerState, mock())
|
||||
InvisibleSessionManager.setParameters(viewModel, mediaPlayer)
|
||||
|
||||
val test = launch {
|
||||
InvisibleSessionManager.updateTimer()
|
||||
}
|
||||
|
||||
Assert.assertEquals(viewModel.getTimer().time.time, 0)
|
||||
advanceTimeBy(1_000) // Start tikker
|
||||
advanceTimeBy(10_000_000)
|
||||
Assert.assertEquals(viewModel.getTimer().time.time, 10000)
|
||||
|
||||
test.cancel()
|
||||
return@runTest
|
||||
}
|
||||
|
||||
@Test
|
||||
fun InvisiblePomodoroTimerTest() = runTest {
|
||||
val studyTime = 10
|
||||
val breakTime = 5
|
||||
val repeats = 1
|
||||
timerState.selectedTimer = FunctionalPomodoroTimer(studyTime, breakTime, repeats)
|
||||
viewModel = SessionViewModel(timerState, mock())
|
||||
InvisibleSessionManager.setParameters(viewModel, mediaPlayer)
|
||||
|
||||
val test = launch {
|
||||
InvisibleSessionManager.updateTimer()
|
||||
}
|
||||
|
||||
Assert.assertEquals(viewModel.getTimer().time.time, 10)
|
||||
advanceTimeBy(1_000) // start tikker
|
||||
|
||||
advanceTimeBy(9_000)
|
||||
Assert.assertEquals(viewModel.getTimer().time.time, 1)
|
||||
// focus, 9 sec, 1 sec nog
|
||||
|
||||
advanceTimeBy(2_000)
|
||||
Assert.assertEquals(viewModel.getTimer().time.time, 4)
|
||||
// pauze, 11 sec bezig, 4 seconden nog pauze
|
||||
|
||||
advanceTimeBy(5_000)
|
||||
Assert.assertEquals(viewModel.getTimer().time.time, 9)
|
||||
// 2e focus, 16 sec, 9 sec in 2e focus nog
|
||||
|
||||
advanceTimeBy(13_000)
|
||||
Assert.assertTrue(viewModel.getTimer().hasEnded())
|
||||
// Done
|
||||
|
||||
test.cancel()
|
||||
return@runTest
|
||||
}
|
||||
|
||||
@Test
|
||||
fun InvisibleCustomTimerTest() = runTest {
|
||||
timerState.selectedTimer = FunctionalCustomTimer(5)
|
||||
viewModel = SessionViewModel(timerState, mock())
|
||||
InvisibleSessionManager.setParameters(viewModel, mediaPlayer)
|
||||
|
||||
val test = launch {
|
||||
InvisibleSessionManager.updateTimer()
|
||||
}
|
||||
|
||||
Assert.assertEquals(viewModel.getTimer().time.time, 5)
|
||||
advanceTimeBy(1_000) // Start tikker
|
||||
advanceTimeBy(4_000)
|
||||
Assert.assertEquals(viewModel.getTimer().time.time, 1)
|
||||
advanceTimeBy(1_000)
|
||||
Assert.assertEquals(viewModel.getTimer().time.time, 0)
|
||||
|
||||
test.cancel()
|
||||
return@runTest
|
||||
}
|
||||
}
|
|
@ -7,6 +7,8 @@
|
|||
# Specifies the JVM arguments used for the daemon process.
|
||||
# The setting is particularly useful for tweaking memory settings.
|
||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
org.gradle.daemon=true
|
||||
org.gradle.parallel=true
|
||||
# When configured, Gradle will run in incubating parallel mode.
|
||||
# This option should only be used with decoupled projects. More details, visit
|
||||
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
|
||||
|
|
Reference in a new issue