diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 4b65913..40bb1b3 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -40,6 +40,9 @@
android:exported="false"
android:foregroundServiceType="microphone" />
+
(null)
+ val state: StateFlow = _state.asStateFlow()
+
+ private var player: MediaPlayer? = null
+ private var generation = 0
+ private var poll: Job? = null
+ private var audioManager: AudioManager? = null
+ private var focusRequest: AudioFocusRequest? = null
+
+ companion object {
+ const val ACTION_STOP = "com.shonar.playback.STOP"
+ const val CHANNEL_ID = "shonar_playback"
+ const val NOTIFICATION_ID = 1002
+ val SPEEDS = listOf(0.75f, 1f, 1.25f, 1.5f, 2f)
+
+ fun stopIntent(ctx: Context): PendingIntent {
+ val i = Intent(ctx, PlaybackStopReceiver::class.java).setAction(ACTION_STOP)
+ return PendingIntent.getBroadcast(
+ ctx, 61, i, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )
+ }
+ }
+
+ /** Play/pause toggle for [id]. Anything else playing stops first. */
+ fun toggle(id: String, title: String, filePath: String) {
+ val cur = _state.value
+ if (cur != null && cur.id == id) {
+ val p = player
+ if (p != null && cur.playing) {
+ pauseLocked()
+ } else if (p != null) {
+ runCatching { applySpeed(p, cur.speed); p.start() }
+ _state.value = cur.copy(playing = true, error = null)
+ startPolling()
+ pushNotification()
+ }
+ return
+ }
+ startNew(id, title, filePath, cur?.speed ?: 1f)
+ }
+
+ fun seekTo(positionMs: Long) {
+ val cur = _state.value ?: return
+ val clamped = positionMs.coerceIn(0, cur.durationMs.coerceAtLeast(1))
+ runCatching { player?.seekTo(clamped.toInt()) }
+ _state.value = cur.copy(positionMs = clamped)
+ }
+
+ fun cycleSpeed() {
+ val cur = _state.value ?: return
+ val next = SPEEDS[(SPEEDS.indexOf(cur.speed) + 1).coerceAtLeast(0) % SPEEDS.size]
+ player?.let { applySpeed(it, next) }
+ _state.value = cur.copy(speed = next)
+ }
+
+ fun stop() {
+ generation++
+ poll?.cancel()
+ poll = null
+ runCatching {
+ player?.apply {
+ runCatching { stop() }
+ release()
+ }
+ }
+ player = null
+ abandonFocus()
+ _state.value = null
+ cancelNotification()
+ }
+
+ /** Call when the owning process goes down for good (tests). */
+ fun release() {
+ stop()
+ scope.cancel()
+ }
+
+ // ---- internals --------------------------------------------------------
+
+ private fun startNew(id: String, title: String, filePath: String, speed: Float) {
+ val gen = ++generation
+ poll?.cancel()
+ poll = null
+ runCatching { player?.release() }
+ player = null
+ val mp: MediaPlayer
+ try {
+ mp = MediaPlayer().apply {
+ setDataSource(filePath)
+ setOnPreparedListener { prepared ->
+ if (gen != generation) {
+ runCatching { prepared.release() }
+ return@setOnPreparedListener
+ }
+ requestFocus()
+ applySpeed(prepared, speed)
+ runCatching { prepared.start() }
+ val dur = runCatching { prepared.duration.toLong() }.getOrDefault(1L)
+ .coerceAtLeast(1)
+ _state.value = State(id, title, playing = true, 0, dur, speed)
+ startPolling()
+ pushNotification()
+ }
+ setOnCompletionListener {
+ if (gen != generation) {
+ runCatching { it.release() }
+ return@setOnCompletionListener
+ }
+ stop()
+ }
+ setOnErrorListener { _, _, _ ->
+ if (gen == generation) {
+ _state.value = State(id, title, false, 0, 1, speed, "Could not play this file.")
+ cancelNotification()
+ }
+ true
+ }
+ prepareAsync()
+ }
+ } catch (_: Exception) {
+ if (gen == generation) {
+ _state.value = State(id, title, false, 0, 1, speed, "Could not play this file.")
+ }
+ return
+ }
+ player = mp
+ // Tentative row state so a second tap lands on this session, not a new one.
+ _state.value = State(id, title, false, 0, 1, speed)
+ }
+
+ private fun pauseLocked() {
+ val cur = _state.value ?: return
+ val p = player ?: return
+ runCatching { if (p.isPlaying) p.pause() }
+ poll?.cancel()
+ poll = null
+ _state.value = cur.copy(playing = false, positionMs = currentPosition())
+ pushNotification()
+ }
+
+ private fun currentPosition(): Long =
+ runCatching { player?.currentPosition?.toLong() }.getOrNull()
+ ?: _state.value?.positionMs ?: 0L
+
+ private fun applySpeed(p: MediaPlayer, speed: Float) {
+ runCatching {
+ val params = if (Build.VERSION.SDK_INT >= 23) {
+ p.playbackParams ?: android.media.PlaybackParams()
+ } else {
+ return
+ }
+ p.playbackParams = params.apply { this.speed = speed }
+ }
+ }
+
+ private fun startPolling() {
+ poll?.cancel()
+ poll = scope.launch {
+ while (isActive) {
+ delay(250)
+ val p = player ?: break
+ if (!runCatching { p.isPlaying }.getOrDefault(false)) break
+ val cur = _state.value ?: break
+ _state.value = cur.copy(
+ positionMs = runCatching { p.currentPosition.toLong() }.getOrDefault(cur.positionMs),
+ durationMs = runCatching { p.duration.toLong() }.getOrDefault(cur.durationMs).coerceAtLeast(1),
+ )
+ }
+ }
+ }
+
+ private val focusListener = AudioManager.OnAudioFocusChangeListener { change ->
+ when (change) {
+ AudioManager.AUDIOFOCUS_LOSS, AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> pauseLocked()
+ else -> Unit
+ }
+ }
+
+ private fun requestFocus() {
+ val am = app.getSystemService(AudioManager::class.java) ?: return
+ audioManager = am
+ if (Build.VERSION.SDK_INT >= 26) {
+ val req = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
+ .setAudioAttributes(
+ AudioAttributes.Builder()
+ .setUsage(AudioAttributes.USAGE_MEDIA)
+ .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
+ .build()
+ )
+ .setOnAudioFocusChangeListener(focusListener)
+ .build()
+ focusRequest = req
+ runCatching { am.requestAudioFocus(req) }
+ } else {
+ @Suppress("DEPRECATION")
+ runCatching { am.requestAudioFocus(focusListener, AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN) }
+ }
+ }
+
+ private fun abandonFocus() {
+ val am = audioManager ?: return
+ if (Build.VERSION.SDK_INT >= 26) {
+ focusRequest?.let { runCatching { am.abandonAudioFocusRequest(it) } }
+ focusRequest = null
+ } else {
+ @Suppress("DEPRECATION")
+ runCatching { am.abandonAudioFocus(focusListener) }
+ }
+ audioManager = null
+ }
+
+ // ---- notification -----------------------------------------------------
+
+ private fun notifications(): NotificationManager =
+ app.getSystemService(NotificationManager::class.java)
+
+ private fun ensureChannel() {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
+ val mgr = notifications()
+ if (runCatching { mgr.getNotificationChannel(CHANNEL_ID) }.getOrNull() == null) {
+ mgr.createNotificationChannel(
+ NotificationChannel(CHANNEL_ID, "Playback", NotificationManager.IMPORTANCE_LOW)
+ )
+ }
+ }
+
+ private fun pushNotification() {
+ val s = _state.value ?: return cancelNotification()
+ ensureChannel()
+ val openApp = PendingIntent.getActivity(
+ app, 60,
+ Intent(app, MainActivity::class.java)
+ .setAction(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER),
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
+ )
+ val notif: Notification = NotificationCompat.Builder(app, CHANNEL_ID)
+ .setSmallIcon(R.drawable.ic_stat_mic)
+ .setContentTitle(if (s.playing) "Playing" else "Paused")
+ .setContentText(s.title)
+ .setOngoing(s.playing)
+ .setOnlyAlertOnce(true)
+ .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
+ .setContentIntent(openApp)
+ .setShowWhen(false)
+ .addAction(R.drawable.ic_notif_close, "Stop", stopIntent(app))
+ .build()
+ runCatching { notifications().notify(NOTIFICATION_ID, notif) }
+ }
+
+ private fun cancelNotification() {
+ runCatching { notifications().cancel(NOTIFICATION_ID) }
+ }
+}
+
+/** Shade "Stop" button: silences playback even with the app closed. */
+class PlaybackStopReceiver : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent) {
+ if (intent.action != PlaybackController.ACTION_STOP) return
+ val app = context.applicationContext
+ // Route through the shared controller when the process is alive…
+ runCatching { (app as? PlaybackOwner)?.playback?.stop() }
+ // …and always clear the notification (covers a restarted process
+ // holding no player while the card is still posted).
+ runCatching {
+ (app.getSystemService(NotificationManager::class.java) as NotificationManager)
+ .cancel(PlaybackController.NOTIFICATION_ID)
+ }
+ }
+}
+
+/** Implemented by [com.shonar.ShonarApplication]: one controller per process. */
+interface PlaybackOwner {
+ val playback: PlaybackController
+}
diff --git a/android/app/src/main/java/com/shonar/recording/RecordingRepository.kt b/android/app/src/main/java/com/shonar/recording/RecordingRepository.kt
index be09d28..9928c60 100644
--- a/android/app/src/main/java/com/shonar/recording/RecordingRepository.kt
+++ b/android/app/src/main/java/com/shonar/recording/RecordingRepository.kt
@@ -45,6 +45,15 @@ class RecordingRepository(
importExistingFiles()
}
+ /** False when the OS hasn't granted file access (fresh install) — the
+ * library stays empty until the user flips it on in system Settings. */
+ suspend fun libraryAccessible(): Boolean = withContext(Dispatchers.IO) {
+ runCatching {
+ val root = runCatching { rootResolver?.invoke() }.getOrNull() ?: libraryRoot
+ root.exists() && root.canRead()
+ }.getOrDefault(false)
+ }
+
/**
* Adopt audio files already sitting in the library folder (put there by
* the user's own file sync, USB, …). Flat scan, top level only;
diff --git a/android/app/src/main/java/com/shonar/ui/detail/DetailsViewModel.kt b/android/app/src/main/java/com/shonar/ui/detail/DetailsViewModel.kt
index a550d28..352a4c4 100644
--- a/android/app/src/main/java/com/shonar/ui/detail/DetailsViewModel.kt
+++ b/android/app/src/main/java/com/shonar/ui/detail/DetailsViewModel.kt
@@ -1,14 +1,11 @@
package com.shonar.ui.detail
-import android.media.MediaPlayer
-import android.media.PlaybackParams
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.shonar.ShonarApplication
+import com.shonar.recording.PlaybackController
import com.shonar.recording.RecordingEntity
-import kotlinx.coroutines.Job
-import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -46,11 +43,8 @@ class DetailsViewModel(
private val _state = MutableStateFlow(UiState.Loading)
val state: StateFlow = _state.asStateFlow()
- private var player: MediaPlayer? = null
- private var pollJob: Job? = null
-
companion object {
- val SPEEDS = listOf(0.75f, 1f, 1.25f, 1.5f, 2f)
+ val SPEEDS = PlaybackController.SPEEDS
fun factory(app: ShonarApplication, recordingId: String): ViewModelProvider.Factory =
object : ViewModelProvider.Factory {
@@ -70,6 +64,26 @@ class DetailsViewModel(
init {
refresh()
+ // Mirror the shared player: what you hear is what this screen shows,
+ // even if playback started from the Home list.
+ viewModelScope.launch {
+ app.playback.state.collect { ps ->
+ val cur = current() ?: return@collect
+ if (ps == null || ps.id != recordingId) {
+ _state.value = UiState.ReadyState(cur.copy(playing = false))
+ } else {
+ _state.value = UiState.ReadyState(
+ cur.copy(
+ playing = ps.playing,
+ positionMs = ps.positionMs,
+ durationMs = ps.durationMs,
+ speed = ps.speed,
+ playbackError = ps.error,
+ )
+ )
+ }
+ }
+ }
}
fun refresh() {
@@ -115,120 +129,20 @@ class DetailsViewModel(
_state.value = UiState.ReadyState(cur.copy(saveError = null))
}
- // ---- playback ------------------------------------------------------------
+ // ---- playback (shared controller; this screen only mirrors it) ----
fun togglePlayback() {
val cur = current() ?: return
- val p = player
- if (p != null) {
- if (p.isPlaying) {
- p.pause()
- stopPolling()
- _state.value = UiState.ReadyState(cur.copy(playing = false, positionMs = p.currentPosition.toLong()))
- } else {
- applySpeed(p, cur.speed)
- p.start()
- startPolling()
- _state.value = UiState.ReadyState(cur.copy(playing = true))
- }
- return
- }
- viewModelScope.launch {
- val fresh = (current() ?: return@launch).copy(playbackError = null)
- _state.value = UiState.ReadyState(fresh)
- val mp = runCatching {
- MediaPlayer().apply {
- setDataSource(fresh.recording.filePath)
- setOnPreparedListener { prepared ->
- applySpeed(prepared, fresh.speed)
- prepared.start()
- startPolling()
- _state.value = UiState.ReadyState(
- (current() ?: fresh).copy(
- playing = true,
- durationMs = prepared.duration.toLong().coerceAtLeast(1),
- )
- )
- }
- setOnCompletionListener {
- stopPolling()
- _state.value = UiState.ReadyState(
- (current() ?: fresh).copy(playing = false, positionMs = 0)
- )
- it.seekTo(0)
- }
- setOnErrorListener { _, _, _ ->
- stopPolling()
- _state.value = UiState.ReadyState(
- (current() ?: fresh).copy(
- playing = false,
- playbackError = "Could not play this file.",
- )
- )
- true
- }
- prepareAsync()
- }
- }.getOrNull()
- if (mp == null) {
- _state.value = UiState.ReadyState(fresh.copy(playbackError = "Could not play this file."))
- } else {
- player = mp
- }
- }
+ app.playback.toggle(cur.recording.id, cur.recording.title, cur.recording.filePath)
}
fun seekTo(positionMs: Long) {
- val cur = current() ?: return
- val duration = cur.durationMs.coerceAtLeast(1)
- val clamped = positionMs.coerceIn(0, duration)
- player?.seekTo(clamped.toInt())
- _state.value = UiState.ReadyState(cur.copy(positionMs = clamped))
+ app.playback.seekTo(positionMs)
}
fun cycleSpeed() {
- val cur = current() ?: return
- val next = SPEEDS[(SPEEDS.indexOf(cur.speed) + 1).coerceAtLeast(0) % SPEEDS.size]
- player?.let { applySpeed(it, next) }
- _state.value = UiState.ReadyState(cur.copy(speed = next))
- }
-
- private fun applySpeed(p: MediaPlayer, speed: Float) {
- runCatching {
- p.playbackParams = (p.playbackParams ?: PlaybackParams()).apply { this.speed = speed }
- }
- }
-
- private fun startPolling() {
- pollJob?.cancel()
- pollJob = viewModelScope.launch {
- while (true) {
- delay(250)
- val p = player ?: break
- if (!p.isPlaying) break
- val cur = current() ?: break
- _state.value = UiState.ReadyState(
- cur.copy(
- positionMs = p.currentPosition.toLong(),
- durationMs = runCatching { p.duration.toLong() }
- .getOrDefault(cur.durationMs).coerceAtLeast(1),
- )
- )
- }
- }
- }
-
- private fun stopPolling() {
- pollJob?.cancel()
- pollJob = null
+ app.playback.cycleSpeed()
}
private fun current(): Ready? = (_state.value as? UiState.ReadyState)?.ready
-
- override fun onCleared() {
- stopPolling()
- player?.release()
- player = null
- super.onCleared()
- }
}
diff --git a/android/app/src/main/java/com/shonar/ui/home/HomeScreen.kt b/android/app/src/main/java/com/shonar/ui/home/HomeScreen.kt
index 631336f..a1a3f5b 100644
--- a/android/app/src/main/java/com/shonar/ui/home/HomeScreen.kt
+++ b/android/app/src/main/java/com/shonar/ui/home/HomeScreen.kt
@@ -68,6 +68,25 @@ fun HomeScreen(
val notice by vm.notice.collectAsState()
val scope = rememberCoroutineScope()
var consentSeen by remember { mutableStateOf(null) }
+ var storageOk by remember { mutableStateOf(null) }
+ val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current
+ // Re-check file access on every return (user may have just flipped it
+ // on in system Settings).
+ androidx.compose.runtime.DisposableEffect(lifecycleOwner) {
+ val obs = androidx.lifecycle.LifecycleEventObserver { _, event ->
+ if (event == androidx.lifecycle.Lifecycle.Event.ON_RESUME) {
+ scope.launch {
+ app.settingsManager.ensureLoaded()
+ consentSeen = app.settingsManager.bool(BuiltInSettings.CONSENT)
+ runCatching { app.recordingRepository.refreshRoot() }
+ storageOk = runCatching { app.recordingRepository.libraryAccessible() }
+ .getOrDefault(true)
+ }
+ }
+ }
+ lifecycleOwner.lifecycle.addObserver(obs)
+ onDispose { lifecycleOwner.lifecycle.removeObserver(obs) }
+ }
val permissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission(),
) { granted -> if (granted) vm.start() }
@@ -87,6 +106,8 @@ fun HomeScreen(
consentSeen = app.settingsManager.bool(BuiltInSettings.CONSENT)
// Adopt files added outside the app (own file sync, USB…) into the library.
runCatching { app.recordingRepository.refreshRoot() }
+ storageOk = runCatching { app.recordingRepository.libraryAccessible() }
+ .getOrDefault(true)
}
Scaffold(
@@ -124,6 +145,19 @@ fun HomeScreen(
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
StorageChip()
+ if (storageOk == false) {
+ val ctx = context
+ StorageAccessCard(onGrant = {
+ runCatching {
+ ctx.startActivity(
+ android.content.Intent(
+ android.provider.Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION,
+ android.net.Uri.parse("package:${ctx.packageName}"),
+ ).addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
+ )
+ }
+ })
+ }
Spacer(Modifier.height(24.dp))
QuickCard("Settings", "Appearance, custom settings",
Icons.Filled.Settings, onOpenSettings)
@@ -285,6 +319,23 @@ private fun formatBytes(bytes: Long): String = when {
else -> "%.1f MB".format(bytes / (1024.0 * 1024.0))
}
+/** Shown when the OS file grant is missing: library stays empty until then. */
+@Composable
+private fun StorageAccessCard(onGrant: () -> Unit) {
+ Card(colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.errorContainer)) {
+ Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ Text("File access is off — your recordings can't be listed.",
+ style = MaterialTheme.typography.titleSmall,
+ color = MaterialTheme.colorScheme.onErrorContainer)
+ Text("SHONAR keeps its library in your Music folder. Allow all-files access, then come back.",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onErrorContainer)
+ TextButton(onClick = onGrant) { Text("Open system settings") }
+ }
+ }
+}
+
/** Fixed on-phone storage indicator — no selection, nothing leaves the phone. */
@Composable
private fun StorageChip() {
diff --git a/android/app/src/main/java/com/shonar/ui/home/RecordingViewModel.kt b/android/app/src/main/java/com/shonar/ui/home/RecordingViewModel.kt
index 8379d2e..a66ab81 100644
--- a/android/app/src/main/java/com/shonar/ui/home/RecordingViewModel.kt
+++ b/android/app/src/main/java/com/shonar/ui/home/RecordingViewModel.kt
@@ -1,16 +1,17 @@
package com.shonar.ui.home
-import android.media.MediaPlayer
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.shonar.ShonarApplication
+import com.shonar.recording.PlaybackController
import com.shonar.recording.RecordingEntity
import com.shonar.recording.RecordingService
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
@@ -18,12 +19,14 @@ class RecordingViewModel(private val app: ShonarApplication) : ViewModel() {
val recordings: StateFlow> = app.recordingRepository.recordings
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
val recorderState = RecordingService.snapshot
- private val _playingId = kotlinx.coroutines.flow.MutableStateFlow(null)
- val playingId: StateFlow = _playingId
+ /** Shared player state; null = nothing loaded. Row shows Pause only while playing. */
+ val playback: StateFlow = app.playback.state
+ val playingId: StateFlow = app.playback.state
+ .map { s -> s?.takeIf { it.playing }?.id }
+ .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
private val _notice = kotlinx.coroutines.flow.MutableStateFlow(null)
/** Transient rename/delete failures surface here; cleared on next action. */
val notice: StateFlow = _notice
- private var player: MediaPlayer? = null
fun start() = RecordingService.command(app, RecordingService.ACTION_START)
fun pause() = RecordingService.command(app, RecordingService.ACTION_PAUSE)
@@ -34,24 +37,12 @@ class RecordingViewModel(private val app: ShonarApplication) : ViewModel() {
fun discard() = RecordingService.command(app, RecordingService.ACTION_DELETE)
fun togglePlayback(recording: RecordingEntity) {
- if (_playingId.value == recording.id) {
- player?.let { if (it.isPlaying) it.pause() else it.start() }
- return
- }
- player?.release()
- player = runCatching {
- MediaPlayer().apply {
- setDataSource(recording.filePath)
- setOnPreparedListener { it.start(); _playingId.value = recording.id }
- setOnCompletionListener { _playingId.value = null; it.release(); player = null }
- prepareAsync()
- }
- }.getOrNull()
+ app.playback.toggle(recording.id, recording.title, recording.filePath)
}
fun delete(recording: RecordingEntity) {
_notice.value = null
- if (_playingId.value == recording.id) stopPlayback()
+ if (app.playback.state.value?.id == recording.id) app.playback.stop()
viewModelScope.launch {
runCatching { app.recordingRepository.delete(recording) }
.onFailure { _notice.value = it.message ?: "Delete failed." }
@@ -66,17 +57,6 @@ class RecordingViewModel(private val app: ShonarApplication) : ViewModel() {
}
}
- private fun stopPlayback() {
- player?.release()
- player = null
- _playingId.value = null
- }
-
- override fun onCleared() {
- stopPlayback()
- super.onCleared()
- }
-
companion object {
val Factory: ViewModelProvider.Factory = viewModelFactory {
initializer {