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">
-
-
-
+
+
+
+
+
+
+
+
+
+