From e77fb6a5c5784440a7ae158c606b643acaf94cff Mon Sep 17 00:00:00 2001 From: ShubertMunthali Date: Mon, 18 Dec 2023 14:46:08 +0200 Subject: [PATCH] Voice notes impl --- app/build.gradle | 2 - app/src/main/AndroidManifest.xml | 1 + .../arkmemo/contracts/PermissionContract.kt | 5 +- .../dev/arkbuilders/arkmemo/di/MediaModule.kt | 21 ++ .../arkmemo/di/RepositoryModule.kt | 5 + .../arkmemo/media/ArkAudioRecorder.kt | 22 ++ .../arkmemo/media/ArkAudioRecorderImpl.kt | 59 ++++++ .../arkmemo/media/ArkMediaPlayer.kt | 24 +++ .../arkmemo/media/ArkMediaPlayerImpl.kt | 48 +++++ .../arkbuilders/arkmemo/models/GraphicNote.kt | 2 +- .../dev/arkbuilders/arkmemo/models/Note.kt | 1 + .../arkbuilders/arkmemo/models/TextNote.kt | 2 +- .../arkbuilders/arkmemo/models/VoiceNote.kt | 19 ++ .../arkmemo/repo/voices/VoiceNotesRepo.kt | 106 ++++++++++ .../arkmemo/ui/activities/MainActivity.kt | 16 +- .../arkmemo/ui/adapters/NotesListAdapter.kt | 76 ++++++- .../ui/fragments/ArkMediaPlayerFragment.kt | 159 +++++++++++++++ .../ui/fragments/ArkRecorderFragment.kt | 191 ++++++++++++++++++ .../ui/fragments/EditGraphicNotesFragment.kt | 4 +- .../ui/fragments/EditTextNotesFragment.kt | 5 +- .../arkmemo/ui/fragments/NotesFragment.kt | 30 ++- .../ui/viewmodels/ArkMediaPlayerViewModel.kt | 107 ++++++++++ .../ui/viewmodels/ArkRecorderViewModel.kt | 147 ++++++++++++++ .../arkmemo/ui/viewmodels/NotesViewModel.kt | 17 +- .../arkbuilders/arkmemo/ui/views/WaveView.kt | 58 ++++++ .../dev/arkbuilders/arkmemo/utils/Utils.kt | 25 ++- app/src/main/res/drawable/ic_mic.xml | 5 + app/src/main/res/drawable/ic_pause.xml | 5 + app/src/main/res/drawable/ic_play.xml | 5 + app/src/main/res/drawable/ic_record.xml | 5 + app/src/main/res/drawable/ic_stop.xml | 5 + .../main/res/layout/fragment_edit_notes.xml | 108 ++++++---- app/src/main/res/layout/fragment_notes.xml | 16 +- app/src/main/res/layout/media_player_view.xml | 46 +++++ app/src/main/res/layout/note.xml | 63 +++--- app/src/main/res/layout/recorder_view.xml | 51 +++++ app/src/main/res/values/colors.xml | 1 + app/src/main/res/values/strings.xml | 5 + 38 files changed, 1376 insertions(+), 91 deletions(-) create mode 100644 app/src/main/java/dev/arkbuilders/arkmemo/di/MediaModule.kt create mode 100644 app/src/main/java/dev/arkbuilders/arkmemo/media/ArkAudioRecorder.kt create mode 100644 app/src/main/java/dev/arkbuilders/arkmemo/media/ArkAudioRecorderImpl.kt create mode 100644 app/src/main/java/dev/arkbuilders/arkmemo/media/ArkMediaPlayer.kt create mode 100644 app/src/main/java/dev/arkbuilders/arkmemo/media/ArkMediaPlayerImpl.kt create mode 100644 app/src/main/java/dev/arkbuilders/arkmemo/models/VoiceNote.kt create mode 100644 app/src/main/java/dev/arkbuilders/arkmemo/repo/voices/VoiceNotesRepo.kt create mode 100644 app/src/main/java/dev/arkbuilders/arkmemo/ui/fragments/ArkMediaPlayerFragment.kt create mode 100644 app/src/main/java/dev/arkbuilders/arkmemo/ui/fragments/ArkRecorderFragment.kt create mode 100644 app/src/main/java/dev/arkbuilders/arkmemo/ui/viewmodels/ArkMediaPlayerViewModel.kt create mode 100644 app/src/main/java/dev/arkbuilders/arkmemo/ui/viewmodels/ArkRecorderViewModel.kt create mode 100644 app/src/main/java/dev/arkbuilders/arkmemo/ui/views/WaveView.kt create mode 100644 app/src/main/res/drawable/ic_mic.xml create mode 100644 app/src/main/res/drawable/ic_pause.xml create mode 100644 app/src/main/res/drawable/ic_play.xml create mode 100644 app/src/main/res/drawable/ic_record.xml create mode 100644 app/src/main/res/drawable/ic_stop.xml create mode 100644 app/src/main/res/layout/media_player_view.xml create mode 100644 app/src/main/res/layout/recorder_view.xml diff --git a/app/build.gradle b/app/build.gradle index 5edaf44d..50e08d7d 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -96,8 +96,6 @@ dependencies { implementation 'com.github.kirich1409:viewbindingpropertydelegate-noreflection:1.5.6' - implementation 'com.google.code.gson:gson:2.8.9' - testImplementation 'junit:junit:4.13.2' androidTestImplementation 'androidx.test.ext:junit:1.1.3' androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 3575161c..3f03c1cb 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -9,6 +9,7 @@ tools:ignore="ScopedStorage" /> + () { @@ -19,4 +22,4 @@ class PermissionContract: ActivityResultContract() { override fun parseResult(resultCode: Int, intent: Intent?): Boolean { return Environment.isExternalStorageManager() } -} \ No newline at end of file +} diff --git a/app/src/main/java/dev/arkbuilders/arkmemo/di/MediaModule.kt b/app/src/main/java/dev/arkbuilders/arkmemo/di/MediaModule.kt new file mode 100644 index 00000000..854595c1 --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/arkmemo/di/MediaModule.kt @@ -0,0 +1,21 @@ +package dev.arkbuilders.arkmemo.di + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import dev.arkbuilders.arkmemo.media.ArkMediaPlayer +import dev.arkbuilders.arkmemo.media.ArkMediaPlayerImpl +import dev.arkbuilders.arkmemo.media.ArkAudioRecorder +import dev.arkbuilders.arkmemo.media.ArkAudioRecorderImpl + +@Module +@InstallIn(SingletonComponent::class) +abstract class MediaModule { + + @Binds + abstract fun bindArkAudioRecorder(impl: ArkAudioRecorderImpl): ArkAudioRecorder + + @Binds + abstract fun bindArkMediaPlayer(impl: ArkMediaPlayerImpl): ArkMediaPlayer +} diff --git a/app/src/main/java/dev/arkbuilders/arkmemo/di/RepositoryModule.kt b/app/src/main/java/dev/arkbuilders/arkmemo/di/RepositoryModule.kt index 666bf376..37b7efe4 100644 --- a/app/src/main/java/dev/arkbuilders/arkmemo/di/RepositoryModule.kt +++ b/app/src/main/java/dev/arkbuilders/arkmemo/di/RepositoryModule.kt @@ -11,8 +11,10 @@ import dev.arkbuilders.arkmemo.repo.NotesRepo import dev.arkbuilders.arkmemo.repo.text.TextNotesRepo import dev.arkbuilders.arkmemo.models.GraphicNote import dev.arkbuilders.arkmemo.models.TextNote +import dev.arkbuilders.arkmemo.models.VoiceNote import dev.arkbuilders.arkmemo.preferences.MemoPreferences import dev.arkbuilders.arkmemo.repo.NotesRepoHelper +import dev.arkbuilders.arkmemo.repo.voices.VoiceNotesRepo @InstallIn(SingletonComponent::class) @@ -24,6 +26,9 @@ abstract class RepositoryModule { @Binds abstract fun bindGraphicNotesRepo(impl: GraphicNotesRepo): NotesRepo + @Binds + abstract fun bindVoiceNotesRepo(impl: VoiceNotesRepo): NotesRepo + companion object { @Provides fun provideNotesRepoHelper( diff --git a/app/src/main/java/dev/arkbuilders/arkmemo/media/ArkAudioRecorder.kt b/app/src/main/java/dev/arkbuilders/arkmemo/media/ArkAudioRecorder.kt new file mode 100644 index 00000000..34404edd --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/arkmemo/media/ArkAudioRecorder.kt @@ -0,0 +1,22 @@ +package dev.arkbuilders.arkmemo.media + +import java.nio.file.Path + +interface ArkAudioRecorder { + + fun init() + + fun start() + + fun pause() + + fun stop() + + fun resume() + + fun reset() + + fun maxAmplitude(): Int + + fun getRecording(): Path +} diff --git a/app/src/main/java/dev/arkbuilders/arkmemo/media/ArkAudioRecorderImpl.kt b/app/src/main/java/dev/arkbuilders/arkmemo/media/ArkAudioRecorderImpl.kt new file mode 100644 index 00000000..d6fca199 --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/arkmemo/media/ArkAudioRecorderImpl.kt @@ -0,0 +1,59 @@ +package dev.arkbuilders.arkmemo.media + +import android.content.Context +import android.media.MediaRecorder +import android.os.Build +import dagger.hilt.android.qualifiers.ApplicationContext +import java.nio.file.Path +import javax.inject.Inject +import kotlin.io.path.createTempFile + +class ArkAudioRecorderImpl @Inject constructor( + @ApplicationContext private val context: Context +): ArkAudioRecorder { + + private var recorder: MediaRecorder? = null + + private val tempFile = createTempFile().toFile() + + override fun init() { + recorder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) + MediaRecorder(context) + else MediaRecorder() + recorder?.apply { + setAudioSource(MediaRecorder.AudioSource.VOICE_RECOGNITION) + setOutputFormat(MediaRecorder.OutputFormat.THREE_GPP) + setOutputFile(tempFile) + setAudioEncoder(MediaRecorder.AudioEncoder.AMR_NB) + prepare() + } + } + + override fun start() { + recorder?.start() + } + + override fun pause() { + recorder?.pause() + } + + override fun resume() { + recorder?.resume() + } + + override fun reset() { + recorder?.reset() + } + + override fun stop() { + recorder?.let { + it.stop() + it.release() + } + recorder = null + } + + override fun maxAmplitude(): Int = recorder?.maxAmplitude!! + + override fun getRecording(): Path = tempFile.toPath() +} \ No newline at end of file diff --git a/app/src/main/java/dev/arkbuilders/arkmemo/media/ArkMediaPlayer.kt b/app/src/main/java/dev/arkbuilders/arkmemo/media/ArkMediaPlayer.kt new file mode 100644 index 00000000..37da17be --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/arkmemo/media/ArkMediaPlayer.kt @@ -0,0 +1,24 @@ +package dev.arkbuilders.arkmemo.media + +import android.media.MediaPlayer + +interface ArkMediaPlayer: MediaPlayer.OnCompletionListener { + + var onCompletion: () -> Unit + + fun init(path: String) + + fun play() + + fun stop() + + fun pause() + + fun seekTo(point: Int) + + fun duration(): Int + + fun currentPosition(): Int + + fun isPlaying(): Boolean +} \ No newline at end of file diff --git a/app/src/main/java/dev/arkbuilders/arkmemo/media/ArkMediaPlayerImpl.kt b/app/src/main/java/dev/arkbuilders/arkmemo/media/ArkMediaPlayerImpl.kt new file mode 100644 index 00000000..54835ecb --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/arkmemo/media/ArkMediaPlayerImpl.kt @@ -0,0 +1,48 @@ +package dev.arkbuilders.arkmemo.media + +import android.media.MediaPlayer +import javax.inject.Inject + +class ArkMediaPlayerImpl @Inject constructor(): ArkMediaPlayer { + + private var player: MediaPlayer? = null + + override var onCompletion: () -> Unit = {} + + override fun init(path: String) { + player = MediaPlayer().apply { + setDataSource(path) + prepare() + } + } + + override fun play() { + player?.start() + } + + override fun stop() { + player?.let { + it.stop() + it.release() + } + player = null + } + + override fun pause() { + player?.pause() + } + + override fun seekTo(point: Int) { + player?.seekTo(point) + } + + override fun duration(): Int = player?.duration!! + + override fun currentPosition(): Int = player?.currentPosition!! + + override fun isPlaying(): Boolean = player?.isPlaying!! + + override fun onCompletion(p0: MediaPlayer?) { + onCompletion() + } +} \ No newline at end of file diff --git a/app/src/main/java/dev/arkbuilders/arkmemo/models/GraphicNote.kt b/app/src/main/java/dev/arkbuilders/arkmemo/models/GraphicNote.kt index 8a95a110..c3215332 100644 --- a/app/src/main/java/dev/arkbuilders/arkmemo/models/GraphicNote.kt +++ b/app/src/main/java/dev/arkbuilders/arkmemo/models/GraphicNote.kt @@ -9,7 +9,7 @@ import kotlinx.parcelize.Parcelize @Parcelize data class GraphicNote( override val title: String = "", - val description: String = "", + override val description: String = "", @IgnoredOnParcel val svg: SVG? = null, @IgnoredOnParcel diff --git a/app/src/main/java/dev/arkbuilders/arkmemo/models/Note.kt b/app/src/main/java/dev/arkbuilders/arkmemo/models/Note.kt index 950c9498..93aa31b5 100644 --- a/app/src/main/java/dev/arkbuilders/arkmemo/models/Note.kt +++ b/app/src/main/java/dev/arkbuilders/arkmemo/models/Note.kt @@ -4,5 +4,6 @@ import dev.arkbuilders.arklib.data.index.Resource interface Note { val title: String + val description: String var resource: Resource? } \ No newline at end of file diff --git a/app/src/main/java/dev/arkbuilders/arkmemo/models/TextNote.kt b/app/src/main/java/dev/arkbuilders/arkmemo/models/TextNote.kt index abc9ad5a..7e79a791 100644 --- a/app/src/main/java/dev/arkbuilders/arkmemo/models/TextNote.kt +++ b/app/src/main/java/dev/arkbuilders/arkmemo/models/TextNote.kt @@ -8,7 +8,7 @@ import kotlinx.parcelize.Parcelize @Parcelize data class TextNote ( override val title: String = "", - val description: String = "", + override val description: String = "", val text: String = "", @IgnoredOnParcel override var resource: Resource? = null diff --git a/app/src/main/java/dev/arkbuilders/arkmemo/models/VoiceNote.kt b/app/src/main/java/dev/arkbuilders/arkmemo/models/VoiceNote.kt new file mode 100644 index 00000000..68ebc986 --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/arkmemo/models/VoiceNote.kt @@ -0,0 +1,19 @@ +package dev.arkbuilders.arkmemo.models + +import android.os.Parcelable +import dev.arkbuilders.arklib.data.index.Resource +import kotlinx.parcelize.IgnoredOnParcel +import kotlinx.parcelize.Parcelize +import java.nio.file.Path +import kotlin.io.path.createTempFile + +@Parcelize +class VoiceNote( + override val title: String = "", + override val description: String = "", + val duration: String = "", + @IgnoredOnParcel + var path: Path = createTempFile(), + @IgnoredOnParcel + override var resource: Resource? = null +): Note, Parcelable \ No newline at end of file diff --git a/app/src/main/java/dev/arkbuilders/arkmemo/repo/voices/VoiceNotesRepo.kt b/app/src/main/java/dev/arkbuilders/arkmemo/repo/voices/VoiceNotesRepo.kt new file mode 100644 index 00000000..bc4568a0 --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/arkmemo/repo/voices/VoiceNotesRepo.kt @@ -0,0 +1,106 @@ +package dev.arkbuilders.arkmemo.repo.voices + +import android.util.Log +import dev.arkbuilders.arklib.computeId +import dev.arkbuilders.arklib.data.index.Resource +import dev.arkbuilders.arkmemo.di.IO_DISPATCHER +import dev.arkbuilders.arkmemo.models.GraphicNote +import dev.arkbuilders.arkmemo.models.SaveNoteResult +import dev.arkbuilders.arkmemo.models.VoiceNote +import dev.arkbuilders.arkmemo.preferences.MemoPreferences +import dev.arkbuilders.arkmemo.repo.NotesRepo +import dev.arkbuilders.arkmemo.repo.NotesRepoHelper +import dev.arkbuilders.arkmemo.utils.listFiles +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext +import java.nio.file.Path +import javax.inject.Inject +import javax.inject.Named +import kotlin.io.path.exists +import kotlin.io.path.fileSize +import kotlin.io.path.createTempFile +import kotlin.io.path.extension +import kotlin.io.path.getLastModifiedTime +import kotlin.io.path.name + +class VoiceNotesRepo @Inject constructor( + private val memoPreferences: MemoPreferences, + @Named(IO_DISPATCHER) private val iODispatcher: CoroutineDispatcher, + private val helper: NotesRepoHelper +): NotesRepo { + + private lateinit var root: Path + + override suspend fun init() { + root = memoPreferences.getNotesStorage() + helper.init() + } + + override suspend fun read(): List = withContext(iODispatcher) { + readStorage() + } + + override suspend fun delete(note: VoiceNote) { + helper.deleteNote(note) + } + + override suspend fun save(note: VoiceNote, callback: (SaveNoteResult) -> Unit) { + write(note) { callback(it) } + } + + private suspend fun write( + note: VoiceNote, + callback: (SaveNoteResult) -> Unit + ) = withContext(iODispatcher) { + val tempPath = note.path + val size = tempPath.fileSize() + val id = computeId(size, tempPath) + + Log.d(VOICES_REPO, "initial resource name is ${tempPath.name}") + + helper.persistNoteProperties(resourceId = id, noteTitle = note.title) + + val resourcePath = root.resolve("${id}.$VOICE_EXT") + if (resourcePath.exists()) { + Log.d( + VOICES_REPO, + "resource with similar content already exists" + ) + callback(SaveNoteResult.ERROR_EXISTING) + return@withContext + } + + helper.renameResource( + note, + tempPath, + resourcePath, + id + ) + note.path = resourcePath + Log.d(VOICES_REPO, "resource renamed to $resourcePath successfully") + callback(SaveNoteResult.SUCCESS) + } + + private suspend fun readStorage(): List = withContext(iODispatcher) { + root.listFiles(VOICE_EXT) { path -> + val id = computeId(path.fileSize(), path) + val resource = Resource( + id = id, + name = path.name, + extension = path.extension, + modified = path.getLastModifiedTime() + ) + + val userNoteProperties = helper.readProperties(id, "") + VoiceNote( + title = userNoteProperties.title, + description = userNoteProperties.description, + path = path, + resource = resource + ) + } + } +} + +private const val VOICES_REPO = "voices-repo" +private const val VOICE_EXT = "3gp" \ No newline at end of file diff --git a/app/src/main/java/dev/arkbuilders/arkmemo/ui/activities/MainActivity.kt b/app/src/main/java/dev/arkbuilders/arkmemo/ui/activities/MainActivity.kt index 750f66c1..bd761959 100644 --- a/app/src/main/java/dev/arkbuilders/arkmemo/ui/activities/MainActivity.kt +++ b/app/src/main/java/dev/arkbuilders/arkmemo/ui/activities/MainActivity.kt @@ -1,5 +1,6 @@ package dev.arkbuilders.arkmemo.ui.activities +import android.Manifest import android.content.Intent import android.os.Bundle import android.view.Menu @@ -39,6 +40,12 @@ class MainActivity : AppCompatActivity(R.layout.activity_main) { var fragment: Fragment = NotesFragment() + private var shouldRecord = false + private val audioRecordingPermissionLauncher = + registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted -> + shouldRecord = isGranted + } + init { FilePickerDialog.readPermLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted -> @@ -60,6 +67,7 @@ class MainActivity : AppCompatActivity(R.layout.activity_main) { binding.toolbar.setNavigationOnClickListener { onBackPressedDispatcher.onBackPressed() } + audioRecordingPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) fun showFragment() { val textDataFromIntent = intent?.getStringExtra(Intent.EXTRA_TEXT) @@ -120,7 +128,7 @@ class MainActivity : AppCompatActivity(R.layout.activity_main) { return true } - fun showSettingsButton(show: Boolean = true){ + fun showSettingsButton(show: Boolean = true) { if(menu != null) { val settingsItem = menu?.findItem(R.id.settings) settingsItem?.isVisible = show @@ -131,6 +139,12 @@ class MainActivity : AppCompatActivity(R.layout.activity_main) { binding.progressBar.isVisible = show } + fun initEditUI() { + title = getString(R.string.edit_note) + supportActionBar?.setDisplayHomeAsUpEnabled(true) + showSettingsButton(false) + } + companion object{ private const val CURRENT_FRAGMENT_TAG = "current fragment tag" } diff --git a/app/src/main/java/dev/arkbuilders/arkmemo/ui/adapters/NotesListAdapter.kt b/app/src/main/java/dev/arkbuilders/arkmemo/ui/adapters/NotesListAdapter.kt index 1332d00a..a80fa9dd 100644 --- a/app/src/main/java/dev/arkbuilders/arkmemo/ui/adapters/NotesListAdapter.kt +++ b/app/src/main/java/dev/arkbuilders/arkmemo/ui/adapters/NotesListAdapter.kt @@ -4,6 +4,8 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.res.ResourcesCompat +import androidx.core.view.isVisible import androidx.fragment.app.FragmentManager import androidx.recyclerview.widget.RecyclerView import by.kirich1409.viewbindingdelegate.viewBinding @@ -12,14 +14,24 @@ import dev.arkbuilders.arkmemo.databinding.NoteBinding import dev.arkbuilders.arkmemo.models.GraphicNote import dev.arkbuilders.arkmemo.models.Note import dev.arkbuilders.arkmemo.models.TextNote +import dev.arkbuilders.arkmemo.models.VoiceNote import dev.arkbuilders.arkmemo.ui.activities.MainActivity import dev.arkbuilders.arkmemo.ui.dialogs.NoteDeleteDialog +import dev.arkbuilders.arkmemo.ui.fragments.ArkMediaPlayerFragment import dev.arkbuilders.arkmemo.ui.fragments.EditGraphicNotesFragment import dev.arkbuilders.arkmemo.ui.fragments.EditTextNotesFragment +import dev.arkbuilders.arkmemo.ui.viewmodels.ArkMediaPlayerSideEffect +import dev.arkbuilders.arkmemo.ui.viewmodels.ArkMediaPlayerState import dev.arkbuilders.arkmemo.utils.replaceFragment -class NotesListAdapter(private val notes: List): - RecyclerView.Adapter() { +class NotesListAdapter( + private val notes: List, + private val onPlayPauseClick: (String) -> Unit, + private val observeViewModel: ( + showState: (ArkMediaPlayerState) -> Unit, + handleSideEffect: (ArkMediaPlayerSideEffect) -> Unit + ) -> Unit +): RecyclerView.Adapter() { private lateinit var activity: MainActivity private lateinit var fragmentManager: FragmentManager @@ -38,13 +50,64 @@ class NotesListAdapter(private val notes: List): } override fun onBindViewHolder(holder: NoteViewHolder, position: Int) { - holder.title.text = notes[position].title - holder.date.text = notes[position].resource?.modified?.toString() ?: + val note = notes[position] + holder.title.text = note.title + holder.date.text = note.resource?.modified?.toString() ?: activity.getString(R.string.ark_memo_just_now) + if (note is VoiceNote) { + holder.btnPlayPause.isVisible = true + holder.btnPlayPause.setOnClickListener { + onPlayPauseClick(note.path.toString()) + } + if (holder.btnPlayPause.isPressed) { + observeViewModel( + {}, + { handleMediaPlayerSideEffect(it, holder) } + ) + } + } } override fun getItemCount() = notes.size + private fun handleMediaPlayerSideEffect( + effect: ArkMediaPlayerSideEffect, + holder: NoteViewHolder + ) { + when (effect) { + is ArkMediaPlayerSideEffect.StartPlaying -> { + showPauseIcon(holder) + } + is ArkMediaPlayerSideEffect.PausePlaying -> { + showPlayIcon(holder) + } + is ArkMediaPlayerSideEffect.StopPlaying -> { + showPlayIcon(holder) + } + is ArkMediaPlayerSideEffect.ResumePlaying -> { + showPauseIcon(holder) + } + } + } + + private fun showPlayIcon(holder: NoteViewHolder) { + val playIcon = ResourcesCompat.getDrawable( + activity.resources, + R.drawable.ic_play, + null + ) + holder.btnPlayPause.setImageDrawable(playIcon) + } + + private fun showPauseIcon(holder: NoteViewHolder) { + val playIcon = ResourcesCompat.getDrawable( + activity.resources, + R.drawable.ic_pause, + null + ) + holder.btnPlayPause.setImageDrawable(playIcon) + } + inner class NoteViewHolder(itemView: View): RecyclerView.ViewHolder(itemView) { private val binding by viewBinding { NoteBinding.bind(itemView) @@ -52,6 +115,7 @@ class NotesListAdapter(private val notes: List): val title = binding.noteTitle val date = binding.noteDate + val btnPlayPause = binding.btnPlayPause private val clickNoteToEditListener = View.OnClickListener { var tag = EditTextNotesFragment.TAG @@ -61,6 +125,10 @@ class NotesListAdapter(private val notes: List): activity.fragment = EditGraphicNotesFragment.newInstance(selectedNote) tag = EditGraphicNotesFragment.TAG } + is VoiceNote -> { + activity.fragment = ArkMediaPlayerFragment.newInstance(selectedNote) + tag = ArkMediaPlayerFragment.TAG + } } activity.replaceFragment(activity.fragment, tag) } diff --git a/app/src/main/java/dev/arkbuilders/arkmemo/ui/fragments/ArkMediaPlayerFragment.kt b/app/src/main/java/dev/arkbuilders/arkmemo/ui/fragments/ArkMediaPlayerFragment.kt new file mode 100644 index 00000000..e5b69d4c --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/arkmemo/ui/fragments/ArkMediaPlayerFragment.kt @@ -0,0 +1,159 @@ +package dev.arkbuilders.arkmemo.ui.fragments + +import android.os.Bundle +import android.text.Editable +import android.text.TextWatcher +import android.view.View +import android.widget.Button +import android.widget.EditText +import android.widget.ImageView +import android.widget.SeekBar +import android.widget.TextView +import androidx.core.content.res.ResourcesCompat +import androidx.core.view.isVisible +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import by.kirich1409.viewbindingdelegate.viewBinding +import dagger.hilt.android.AndroidEntryPoint +import dev.arkbuilders.arkmemo.R +import dev.arkbuilders.arkmemo.databinding.FragmentEditNotesBinding +import dev.arkbuilders.arkmemo.models.VoiceNote +import dev.arkbuilders.arkmemo.ui.activities.MainActivity +import dev.arkbuilders.arkmemo.ui.viewmodels.ArkMediaPlayerSideEffect +import dev.arkbuilders.arkmemo.ui.viewmodels.ArkMediaPlayerState +import dev.arkbuilders.arkmemo.ui.viewmodels.ArkMediaPlayerViewModel +import dev.arkbuilders.arkmemo.ui.viewmodels.NotesViewModel +import kotlinx.coroutines.launch +import java.time.LocalDate +import java.time.format.DateTimeFormatter + +@AndroidEntryPoint +class ArkMediaPlayerFragment: Fragment(R.layout.fragment_edit_notes) { + + private val activity by lazy { + requireActivity() as MainActivity + } + + private val binding by viewBinding(FragmentEditNotesBinding::bind) + private val arkMediaPlayerViewModel: ArkMediaPlayerViewModel by viewModels() + private val notesViewModel: NotesViewModel by activityViewModels() + + private lateinit var seekBar: SeekBar + private lateinit var ivPlayPause: ImageView + private lateinit var tvDuration: TextView + private lateinit var etTitle: EditText + private lateinit var btnSave: Button + + private lateinit var note: VoiceNote + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + observeViewModel() + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + activity.initEditUI() + initUI() + } + + private fun initUI() { + val defaultTitle = getString( + R.string.ark_memo_voice_note, + LocalDate.now().format(DateTimeFormatter.ISO_DATE) + ) + var title = note.title + val textWatcher = object: TextWatcher { + override fun beforeTextChanged(s: CharSequence?, p1: Int, p2: Int, p3: Int) {} + + override fun onTextChanged(s: CharSequence?, p1: Int, p2: Int, p3: Int) { + title = s?.toString() ?: defaultTitle + } + + override fun afterTextChanged(p0: Editable?) {} + + } + binding.mediaPlayerViewBinding.mediaPlayerView.isVisible = true + seekBar = binding.mediaPlayerViewBinding.seekBar + ivPlayPause = binding.mediaPlayerViewBinding.ivPlayPause + tvDuration = binding.mediaPlayerViewBinding.tvDuration + etTitle = binding.noteTitle + btnSave = binding.btnSave + + + etTitle.hint = defaultTitle + etTitle.setText(title) + etTitle.addTextChangedListener(textWatcher) + ivPlayPause.setOnClickListener { + arkMediaPlayerViewModel.onPlayOrPauseClick(note.path.toString()) + } + } + + private fun showState(state: ArkMediaPlayerState) { + seekBar.progress = state.progress.toInt() + tvDuration.text = state.duration + } + + private fun handleSideEffect(effect: ArkMediaPlayerSideEffect) { + when (effect) { + ArkMediaPlayerSideEffect.StartPlaying -> { + showPauseIcon() + } + ArkMediaPlayerSideEffect.StopPlaying -> { + showPlayIcon() + } + ArkMediaPlayerSideEffect.PausePlaying -> { + showPlayIcon() + } + ArkMediaPlayerSideEffect.ResumePlaying -> { + showPauseIcon() + } + } + } + + private fun observeViewModel() { + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.CREATED) { + arkMediaPlayerViewModel.collect( + stateToUI = { showState(it) }, + handleSideEffect = { handleSideEffect(it) } + ) + } + } + } + + private fun setNote(note: VoiceNote) { + this.note = note + } + + private fun showPlayIcon() { + val playIcon = ResourcesCompat.getDrawable( + activity.resources, + R.drawable.ic_play, + null + ) + ivPlayPause.setImageDrawable(playIcon) + } + + private fun showPauseIcon() { + val playIcon = ResourcesCompat.getDrawable( + activity.resources, + R.drawable.ic_pause, + null + ) + ivPlayPause.setImageDrawable(playIcon) + } + + companion object { + + const val TAG = "ark-media-player-fragment" + + fun newInstance(note: VoiceNote) = ArkMediaPlayerFragment().apply { + setNote(note) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/dev/arkbuilders/arkmemo/ui/fragments/ArkRecorderFragment.kt b/app/src/main/java/dev/arkbuilders/arkmemo/ui/fragments/ArkRecorderFragment.kt new file mode 100644 index 00000000..34c10c5a --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/arkmemo/ui/fragments/ArkRecorderFragment.kt @@ -0,0 +1,191 @@ +package dev.arkbuilders.arkmemo.ui.fragments + +import android.Manifest +import android.content.pm.PackageManager +import android.os.Bundle +import android.text.Editable +import android.text.TextWatcher +import android.view.View +import android.widget.ImageView +import android.widget.TextView +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.content.res.ResourcesCompat +import androidx.core.view.isVisible +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import by.kirich1409.viewbindingdelegate.viewBinding +import com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton +import dagger.hilt.android.AndroidEntryPoint +import dev.arkbuilders.arkmemo.R +import dev.arkbuilders.arkmemo.databinding.FragmentEditNotesBinding +import dev.arkbuilders.arkmemo.models.VoiceNote +import dev.arkbuilders.arkmemo.ui.activities.MainActivity +import dev.arkbuilders.arkmemo.ui.viewmodels.NotesViewModel +import dev.arkbuilders.arkmemo.ui.viewmodels.RecorderSideEffect +import dev.arkbuilders.arkmemo.ui.viewmodels.RecorderState +import dev.arkbuilders.arkmemo.ui.viewmodels.ArkRecorderViewModel +import dev.arkbuilders.arkmemo.ui.views.WaveView +import dev.arkbuilders.arkmemo.utils.observeSaveResult +import kotlinx.coroutines.launch +import java.time.LocalDate +import java.time.format.DateTimeFormatter + +@AndroidEntryPoint +class ArkRecorderFragment: Fragment(R.layout.fragment_edit_notes) { + + private val activity by lazy { requireActivity() as MainActivity } + private val binding by viewBinding(FragmentEditNotesBinding::bind) + + private var shouldRecord = false + private val audioRecordingPermissionLauncher = + registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted -> + shouldRecord = isGranted + if (!shouldRecord) activity.onBackPressedDispatcher.onBackPressed() + } + + private val notesViewModel: NotesViewModel by activityViewModels() + private val arkRecorderViewModel: ArkRecorderViewModel by viewModels() + + private lateinit var ivRecord: ImageView + private lateinit var ivPauseResume: ImageView + private lateinit var tvDuration: TextView + private lateinit var btnSave: ExtendedFloatingActionButton + private lateinit var waveView: WaveView + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + shouldRecord = context?.let { + it.checkSelfPermission(Manifest.permission.RECORD_AUDIO) == + PackageManager.PERMISSION_GRANTED + } ?: false + if (shouldRecord) { + notesViewModel.init {} + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.CREATED) { + arkRecorderViewModel.collect( + stateToUI = { showState(it) }, + handleSideEffect = { handleSideEffect(it) } + ) + } + } + observeSaveResult(notesViewModel.getSaveNoteResultLiveData()) + } else audioRecordingPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + initUI() + } + + private fun initUI() { + val defaultTitle = getString( + R.string.ark_memo_voice_note, + LocalDate.now().format(DateTimeFormatter.ISO_DATE) + ) + var title = "" + val etTitle = binding.noteTitle + val etTitleWatcher = object: TextWatcher { + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} + + override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) { + title = s?.toString() ?: "" + } + + override fun afterTextChanged(s: Editable?) {} + } + + binding.recorderViewBinding.recorderView.isVisible = true + btnSave = binding.btnSave + ivRecord = binding.recorderViewBinding.ivRecord + ivPauseResume = binding.recorderViewBinding.ivPauseResume + tvDuration = binding.recorderViewBinding.tvDuration + waveView = binding.recorderViewBinding.waveView + + ivPauseResume.isEnabled = false + btnSave.isEnabled = false + + etTitle.hint = defaultTitle + etTitle.addTextChangedListener(etTitleWatcher) + + ivRecord.setOnClickListener { + arkRecorderViewModel.onStartStopClick() + } + + ivPauseResume.setOnClickListener { + arkRecorderViewModel.onPauseResumeClick() + } + + btnSave.setOnClickListener { + val note = VoiceNote( + title = title.ifEmpty { defaultTitle }, + path = arkRecorderViewModel.getRecordingPath() + ) + notesViewModel.onSaveClick(note) { show -> + activity.showProgressBar(show) + } + } + } + + private fun handleSideEffect(effect: RecorderSideEffect) { + when (effect) { + RecorderSideEffect.StartRecording -> { + val stopIcon = ResourcesCompat.getDrawable( + resources, + R.drawable.ic_stop, + null + ) + waveView.resetWave() + ivRecord.setImageDrawable(stopIcon) + ivPauseResume.isEnabled = true + btnSave.isEnabled = false + } + RecorderSideEffect.StopRecording -> { + val recordIcon = ResourcesCompat.getDrawable( + resources, + R.drawable.ic_record, + null + ) + ivRecord.setImageDrawable(recordIcon) + ivPauseResume.isEnabled = false + btnSave.isEnabled = true + showPauseIcon() + } + RecorderSideEffect.PauseRecording -> { + val resumeIcon = ResourcesCompat.getDrawable( + resources, + R.drawable.ic_play, + null + ) + ivPauseResume.setImageDrawable(resumeIcon) + } + RecorderSideEffect.ResumeRecording -> { + showPauseIcon() + } + } + } + + private fun showState(state: RecorderState) { + tvDuration.text = state.progress + waveView.invalidateWave(state.maxAmplitude) + } + + private fun showPauseIcon() { + val pauseIcon = ResourcesCompat.getDrawable( + resources, + R.drawable.ic_pause, + null + ) + ivPauseResume.setImageDrawable(pauseIcon) + } + + companion object { + + const val TAG = "voice-notes-fragment" + + fun newInstance() = ArkRecorderFragment() + } +} \ No newline at end of file diff --git a/app/src/main/java/dev/arkbuilders/arkmemo/ui/fragments/EditGraphicNotesFragment.kt b/app/src/main/java/dev/arkbuilders/arkmemo/ui/fragments/EditGraphicNotesFragment.kt index 031a313c..63dc5b06 100644 --- a/app/src/main/java/dev/arkbuilders/arkmemo/ui/fragments/EditGraphicNotesFragment.kt +++ b/app/src/main/java/dev/arkbuilders/arkmemo/ui/fragments/EditGraphicNotesFragment.kt @@ -60,7 +60,7 @@ class EditGraphicNotesFragment: Fragment(R.layout.fragment_edit_notes) { ) var title = note.title val notesCanvas = binding.notesCanvas - val saveButton = binding.saveNote + val btnSave = binding.btnSave val noteTitle = binding.noteTitle val noteTitleChangeListener = object: TextWatcher { override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} @@ -82,7 +82,7 @@ class EditGraphicNotesFragment: Fragment(R.layout.fragment_edit_notes) { noteTitle.addTextChangedListener(noteTitleChangeListener) notesCanvas.isVisible = true notesCanvas.setViewModel(graphicNotesViewModel) - saveButton.setOnClickListener { + btnSave.setOnClickListener { val svg = graphicNotesViewModel.svg() val note = GraphicNote( title = title.ifEmpty { defaultTitle }, diff --git a/app/src/main/java/dev/arkbuilders/arkmemo/ui/fragments/EditTextNotesFragment.kt b/app/src/main/java/dev/arkbuilders/arkmemo/ui/fragments/EditTextNotesFragment.kt index a58ab254..7696c270 100644 --- a/app/src/main/java/dev/arkbuilders/arkmemo/ui/fragments/EditTextNotesFragment.kt +++ b/app/src/main/java/dev/arkbuilders/arkmemo/ui/fragments/EditTextNotesFragment.kt @@ -68,7 +68,7 @@ class EditTextNotesFragment: Fragment(R.layout.fragment_edit_notes) { } val noteTitle = binding.noteTitle val editNote = binding.editNote - val saveNoteButton = binding.saveNote + val btnSave = binding.btnSave val noteTitleChangeListener = object: TextWatcher { override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} @@ -94,7 +94,7 @@ class EditTextNotesFragment: Fragment(R.layout.fragment_edit_notes) { if(noteStr != null) editNote.setText(noteStr) - saveNoteButton.setOnClickListener { + btnSave.setOnClickListener { val note = TextNote( title = title.ifEmpty { defaultTitle }, text = data, @@ -105,7 +105,6 @@ class EditTextNotesFragment: Fragment(R.layout.fragment_edit_notes) { } } } - companion object{ const val TAG = "Edit Text Notes" private const val NOTE_STRING_KEY = "note string" diff --git a/app/src/main/java/dev/arkbuilders/arkmemo/ui/fragments/NotesFragment.kt b/app/src/main/java/dev/arkbuilders/arkmemo/ui/fragments/NotesFragment.kt index 77ad6cd9..d0d97fdb 100644 --- a/app/src/main/java/dev/arkbuilders/arkmemo/ui/fragments/NotesFragment.kt +++ b/app/src/main/java/dev/arkbuilders/arkmemo/ui/fragments/NotesFragment.kt @@ -6,22 +6,22 @@ import android.widget.Button import android.widget.Toast import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels -import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.repeatOnLifecycle import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import by.kirich1409.viewbindingdelegate.viewBinding import com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton import com.google.android.material.floatingactionbutton.FloatingActionButton import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.launch import dev.arkbuilders.arkmemo.R import dev.arkbuilders.arkmemo.ui.viewmodels.NotesViewModel import dev.arkbuilders.arkmemo.databinding.FragmentNotesBinding import dev.arkbuilders.arkmemo.models.Note import dev.arkbuilders.arkmemo.ui.activities.MainActivity import dev.arkbuilders.arkmemo.ui.adapters.NotesListAdapter +import dev.arkbuilders.arkmemo.ui.viewmodels.ArkMediaPlayerSideEffect +import dev.arkbuilders.arkmemo.ui.viewmodels.ArkMediaPlayerState +import dev.arkbuilders.arkmemo.ui.viewmodels.ArkMediaPlayerViewModel import dev.arkbuilders.arkmemo.utils.getTextFromClipBoard import dev.arkbuilders.arkmemo.utils.replaceFragment @@ -35,6 +35,7 @@ class NotesFragment: Fragment(R.layout.fragment_notes) { } private val notesViewModel: NotesViewModel by activityViewModels() + private val arkMediaPlayerViewModel: ArkMediaPlayerViewModel by activityViewModels() private lateinit var newTextNoteButton: FloatingActionButton private lateinit var newGraphicNoteButton: FloatingActionButton @@ -71,6 +72,7 @@ class NotesFragment: Fragment(R.layout.fragment_notes) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + val fabNewVoiceNote = binding.fabNewVoiceNote recyclerView = binding.include.recyclerView activity.title = getString(R.string.app_name) activity.supportActionBar?.setDisplayHomeAsUpEnabled(false) @@ -79,25 +81,45 @@ class NotesFragment: Fragment(R.layout.fragment_notes) { newGraphicNoteButton = binding.newGraphicNote newNoteButton = binding.newNote newNoteButton.shrink() + showFabs = false newNoteButton.setOnClickListener { showFabs = if (!showFabs) { newNoteButton.extend() newTextNoteButton.show() newGraphicNoteButton.show() + fabNewVoiceNote.show() true } else { newNoteButton.shrink() newTextNoteButton.hide() newGraphicNoteButton.hide() + fabNewVoiceNote.hide() false } } newTextNoteButton.setOnClickListener(newTextNoteClickListener) newGraphicNoteButton.setOnClickListener(newGraphicNoteClickListener) pasteNoteButton.setOnClickListener(pasteNoteClickListener) + fabNewVoiceNote.setOnClickListener { + activity.fragment = ArkRecorderFragment.newInstance() + activity.replaceFragment(activity.fragment, ArkRecorderFragment.TAG) + } lifecycleScope.launchWhenStarted { notesViewModel.getNotes { - val adapter = NotesListAdapter(it) + val adapter = NotesListAdapter( + it, + onPlayPauseClick = { path -> + arkMediaPlayerViewModel.onPlayOrPauseClick(path) + }, + observeViewModel = { + showState: (ArkMediaPlayerState) -> Unit, + handleSideEffect: (ArkMediaPlayerSideEffect) -> Unit -> + arkMediaPlayerViewModel.collect( + stateToUI = { state -> showState(state) }, + handleSideEffect = { effect -> handleSideEffect(effect) } + ) + } + ) val layoutManager = LinearLayoutManager(requireContext()) adapter.setActivity(activity) adapter.setFragmentManager(childFragmentManager) diff --git a/app/src/main/java/dev/arkbuilders/arkmemo/ui/viewmodels/ArkMediaPlayerViewModel.kt b/app/src/main/java/dev/arkbuilders/arkmemo/ui/viewmodels/ArkMediaPlayerViewModel.kt new file mode 100644 index 00000000..c51d50e9 --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/arkmemo/ui/viewmodels/ArkMediaPlayerViewModel.kt @@ -0,0 +1,107 @@ +package dev.arkbuilders.arkmemo.ui.viewmodels + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import dev.arkbuilders.arkmemo.media.ArkMediaPlayer +import dev.arkbuilders.arkmemo.utils.millisToString +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import java.util.Timer +import javax.inject.Inject +import kotlin.concurrent.timer + +sealed class ArkMediaPlayerSideEffect { + object StartPlaying: ArkMediaPlayerSideEffect() + + object PausePlaying: ArkMediaPlayerSideEffect() + + object ResumePlaying: ArkMediaPlayerSideEffect() + + object StopPlaying: ArkMediaPlayerSideEffect() +} + +data class ArkMediaPlayerState( + val progress: Float, + val duration: String +) + +@HiltViewModel +class ArkMediaPlayerViewModel @Inject constructor( + private val arkMediaPlayer: ArkMediaPlayer +): ViewModel() { + + private var currentPlayingVoiceNotePath: String = "" + private val arkMediaPlayerSideEffect = MutableStateFlow(null) + private val arkMediaPlayerState = MutableStateFlow(null) + + private var timer: Timer? = null + + fun onPlayOrPauseClick(path: String) { + if (currentPlayingVoiceNotePath != path) { + currentPlayingVoiceNotePath = path + arkMediaPlayer.init(path) + } + if (arkMediaPlayer.isPlaying()) { + onPauseClick() + return + } + onPlayClick() + } + + fun onSeekTo(progress: Int) { + val point = (progress / 100) * arkMediaPlayer.duration() + arkMediaPlayer.seekTo(point) + } + + fun collect( + stateToUI: (ArkMediaPlayerState) -> Unit, + handleSideEffect: (ArkMediaPlayerSideEffect) -> Unit + ) { + viewModelScope.launch { + arkMediaPlayerState.collectLatest { + it?.let { + stateToUI(it) + } + } + } + viewModelScope.launch { + arkMediaPlayerSideEffect.collectLatest { + it?.let { + handleSideEffect(it) + } + } + } + } + + private fun startTimer() { + timer = timer(initialDelay = 0L, period = arkMediaPlayer.duration().toLong()) { + arkMediaPlayerState.value = ArkMediaPlayerState( + progress = arkMediaPlayer.currentPosition().toFloat() / + arkMediaPlayer.duration().toFloat(), + duration = millisToString(arkMediaPlayer.duration().toLong()) + ) + } + } + + private fun stopTimer() { + timer?.cancel() + timer = null + } + + private fun onPlayClick() { + arkMediaPlayer.play() + arkMediaPlayer.onCompletion = { + stopTimer() + arkMediaPlayerSideEffect.value = ArkMediaPlayerSideEffect.StopPlaying + } + startTimer() + arkMediaPlayerSideEffect.value = ArkMediaPlayerSideEffect.StartPlaying + } + + private fun onPauseClick() { + arkMediaPlayer.pause() + arkMediaPlayerSideEffect.value = ArkMediaPlayerSideEffect.PausePlaying + } +} \ No newline at end of file diff --git a/app/src/main/java/dev/arkbuilders/arkmemo/ui/viewmodels/ArkRecorderViewModel.kt b/app/src/main/java/dev/arkbuilders/arkmemo/ui/viewmodels/ArkRecorderViewModel.kt new file mode 100644 index 00000000..024b00fb --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/arkmemo/ui/viewmodels/ArkRecorderViewModel.kt @@ -0,0 +1,147 @@ +package dev.arkbuilders.arkmemo.ui.viewmodels + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import dev.arkbuilders.arkmemo.media.ArkAudioRecorder +import dev.arkbuilders.arkmemo.utils.tenthSecondsToString +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import java.nio.file.Path +import java.util.Timer +import javax.inject.Inject +import kotlin.concurrent.timer + +sealed class RecorderSideEffect { + object StartRecording: RecorderSideEffect() + + object StopRecording: RecorderSideEffect() + + object PauseRecording: RecorderSideEffect() + + object ResumeRecording: RecorderSideEffect() +} + +data class RecorderState( + val maxAmplitude: Int, + val progress: String +) + +@HiltViewModel +class ArkRecorderViewModel @Inject constructor( + private val arkAudioRecorder: ArkAudioRecorder +): ViewModel() { + + private val recorderSideEffect = MutableStateFlow(null) + private val recorderState = MutableStateFlow(null) + private val isRecording = MutableStateFlow(false) + private val isPaused = MutableStateFlow(false) + + // Duration is in milliseconds + private var duration = 0L + + private var timer: Timer? = null + + fun onStartStopClick() { + if (isRecording.value) { + onStopRecordingClick() + } else { + onStartRecordingClick() + } + } + + fun onPauseResumeClick() { + if (isPaused.value) { + onResumeRecordingClick() + } else { + onPauseRecordingClick() + } + } + + fun collect( + stateToUI: (RecorderState) -> Unit, + handleSideEffect:(RecorderSideEffect) -> Unit + ) { + viewModelScope.launch { + recorderState.collect { + it?.let { + stateToUI(it) + } + } + } + viewModelScope.launch { + recorderSideEffect.collectLatest { + it?.let { + handleSideEffect(it) + } + } + } + } + + fun getRecordingPath(): Path { + return arkAudioRecorder.getRecording() + } + + private fun onStartRecordingClick() { + viewModelScope.launch { + arkAudioRecorder.init() + arkAudioRecorder.start() + isRecording.value = true + startTimer() + recorderSideEffect.value = RecorderSideEffect.StartRecording + } + } + + private fun onStopRecordingClick() { + viewModelScope.launch { + arkAudioRecorder.stop() + isRecording.value = false + if (isPaused.value) isPaused.value = false + duration = 0 + stopTimer() + recorderSideEffect.value = RecorderSideEffect.StopRecording + } + } + + private fun onPauseRecordingClick() { + viewModelScope.launch { + if (isRecording.value) { + isPaused.value = true + arkAudioRecorder.pause() + stopTimer() + recorderSideEffect.value = RecorderSideEffect.PauseRecording + } + } + } + + private fun onResumeRecordingClick() { + viewModelScope.launch { + if (isRecording.value) { + arkAudioRecorder.resume() + isPaused.value = false + startTimer() + recorderSideEffect.value = RecorderSideEffect.ResumeRecording + } + } + } + + private fun startTimer() { + viewModelScope.launch { + timer = timer(initialDelay = 0L, period = 100L) { + if (isRecording.value && !isPaused.value) { + duration += 1 + recorderState.value = RecorderState( + arkAudioRecorder.maxAmplitude(), + tenthSecondsToString(duration) + ) + } + } + } + } + + private fun stopTimer() { + timer?.cancel() + timer = null + } +} diff --git a/app/src/main/java/dev/arkbuilders/arkmemo/ui/viewmodels/NotesViewModel.kt b/app/src/main/java/dev/arkbuilders/arkmemo/ui/viewmodels/NotesViewModel.kt index 090788e0..0d1bb0cf 100644 --- a/app/src/main/java/dev/arkbuilders/arkmemo/ui/viewmodels/NotesViewModel.kt +++ b/app/src/main/java/dev/arkbuilders/arkmemo/ui/viewmodels/NotesViewModel.kt @@ -13,9 +13,11 @@ import kotlinx.coroutines.withContext import dev.arkbuilders.arkmemo.models.SaveNoteResult import dev.arkbuilders.arkmemo.repo.NotesRepo import dev.arkbuilders.arkmemo.di.IO_DISPATCHER +import dev.arkbuilders.arkmemo.media.ArkMediaPlayer import dev.arkbuilders.arkmemo.models.GraphicNote import dev.arkbuilders.arkmemo.models.Note import dev.arkbuilders.arkmemo.models.TextNote +import dev.arkbuilders.arkmemo.models.VoiceNote import kotlinx.coroutines.flow.collectLatest import javax.inject.Inject import javax.inject.Named @@ -25,25 +27,28 @@ class NotesViewModel @Inject constructor( @Named(IO_DISPATCHER) private val iODispatcher: CoroutineDispatcher, private val textNotesRepo: NotesRepo, private val graphicNotesRepo: NotesRepo, + private val voiceNotesRepo: NotesRepo, + private val arkMediaPlayer: ArkMediaPlayer ) : ViewModel() { private val notes = MutableStateFlow(listOf()) private val mSaveNoteResultLiveData = MutableLiveData() - fun init(read: () -> Unit) { + fun init(extraBlock: () -> Unit) { val initJob = viewModelScope.launch(iODispatcher) { textNotesRepo.init() graphicNotesRepo.init() + voiceNotesRepo.init() } viewModelScope.launch { initJob.join() - read() + extraBlock() } } fun readAllNotes() { viewModelScope.launch(iODispatcher) { - notes.value = textNotesRepo.read() + graphicNotesRepo.read() + notes.value = textNotesRepo.read() + graphicNotesRepo.read() + voiceNotesRepo.read() } } @@ -69,6 +74,11 @@ class NotesViewModel @Inject constructor( handleResult(result) } } + is VoiceNote -> { + voiceNotesRepo.save(note) { result -> + handleResult(result) + } + } } withContext(Dispatchers.Main) { showProgress(false) @@ -82,6 +92,7 @@ class NotesViewModel @Inject constructor( when (note) { is TextNote -> textNotesRepo.delete(note) is GraphicNote -> graphicNotesRepo.delete(note) + is VoiceNote -> voiceNotesRepo.delete(note) } } } diff --git a/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/WaveView.kt b/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/WaveView.kt new file mode 100644 index 00000000..8020178a --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/arkmemo/ui/views/WaveView.kt @@ -0,0 +1,58 @@ +package dev.arkbuilders.arkmemo.ui.views + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.Rect +import android.util.AttributeSet +import android.view.View + +class WaveView(context: Context, attrs: AttributeSet): View(context, attrs) { + + private val paint = Paint().also { + it.color = Color.LTGRAY + it.style = Paint.Style.FILL + } + + private val bars = ArrayDeque() + + override fun onDraw(canvas: Canvas?) { + super.onDraw(canvas) + if (bars.isNotEmpty()) { + bars.forEach { + canvas?.drawRect(it, paint) + } + } + } + + fun invalidateWave(amplitude: Int) { + computeWave(amplitude) + invalidate() + } + + fun resetWave() { + if (bars.isNotEmpty()) bars.clear() + } + + private fun computeWave(amplitude: Int) { + if (bars.isNotEmpty()) { + bars.forEachIndexed { index, rect -> + val right = width - ((index + 1) * (BAR_WIDTH + BAR_INTERVAL)) + val left = right - BAR_WIDTH + rect.right = right + rect.left = left + } + } + val barHeight = ((amplitude.toFloat() / MAX_AMPLITUDE) * height).toInt() + val top = (height - MIN_BAR_HEIGHT - barHeight) / 2 + bars.addFirst(Rect(width - BAR_WIDTH, top, width, top + barHeight + MIN_BAR_HEIGHT)) + } + + companion object { + private const val MIN_BAR_HEIGHT = 4 + private const val BAR_WIDTH = 4 + private const val BAR_INTERVAL = 2 + private const val MAX_AMPLITUDE = 32762f / 10f + } +} diff --git a/app/src/main/java/dev/arkbuilders/arkmemo/utils/Utils.kt b/app/src/main/java/dev/arkbuilders/arkmemo/utils/Utils.kt index d3a3e51f..30c6769d 100644 --- a/app/src/main/java/dev/arkbuilders/arkmemo/utils/Utils.kt +++ b/app/src/main/java/dev/arkbuilders/arkmemo/utils/Utils.kt @@ -66,4 +66,27 @@ fun Path.readLines(useLines: (String) -> R): R { dataBuilder.appendLine(it) } return useLines(dataBuilder.removeSuffix("\n").toString()) -} \ No newline at end of file +} + +fun tenthSecondsToString(duration: Long): String { + val seconds = duration / 10 + val remainingMilliSeconds = duration % 10 + val minutes = seconds / 60 + val remainingSeconds = seconds % 60 + return "${ + if (minutes <= 9) "0$minutes" else minutes + }:${ + if (remainingSeconds <= 9) "0$remainingSeconds" else remainingSeconds + }:0$remainingMilliSeconds" +} + +fun millisToString(duration: Long): String { + val seconds = duration / 1000 + val minutes = seconds / 60 + val remainingSeconds = seconds % 60 + return "${ + if (minutes <= 9) "0$minutes" else minutes + }:${ + if (remainingSeconds <= 9) "0$remainingSeconds" else remainingSeconds + }" +} diff --git a/app/src/main/res/drawable/ic_mic.xml b/app/src/main/res/drawable/ic_mic.xml new file mode 100644 index 00000000..5eb92eb3 --- /dev/null +++ b/app/src/main/res/drawable/ic_mic.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_pause.xml b/app/src/main/res/drawable/ic_pause.xml new file mode 100644 index 00000000..938bd7f1 --- /dev/null +++ b/app/src/main/res/drawable/ic_pause.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_play.xml b/app/src/main/res/drawable/ic_play.xml new file mode 100644 index 00000000..e3fd2e9d --- /dev/null +++ b/app/src/main/res/drawable/ic_play.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_record.xml b/app/src/main/res/drawable/ic_record.xml new file mode 100644 index 00000000..57fc55c1 --- /dev/null +++ b/app/src/main/res/drawable/ic_record.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_stop.xml b/app/src/main/res/drawable/ic_stop.xml new file mode 100644 index 00000000..19bcbee7 --- /dev/null +++ b/app/src/main/res/drawable/ic_stop.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/layout/fragment_edit_notes.xml b/app/src/main/res/layout/fragment_edit_notes.xml index 0c6f431e..ecf13f3d 100644 --- a/app/src/main/res/layout/fragment_edit_notes.xml +++ b/app/src/main/res/layout/fragment_edit_notes.xml @@ -10,49 +10,75 @@ android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical"> - - - + + + + + + + + + +