diff --git a/core/arch/src/main/kotlin/com/f0x1d/logfox/arch/presentation/ui/fragment/BaseFragment.kt b/core/arch/src/main/kotlin/com/f0x1d/logfox/arch/presentation/ui/fragment/BaseFragment.kt index b29c989b..4dcc4ea1 100644 --- a/core/arch/src/main/kotlin/com/f0x1d/logfox/arch/presentation/ui/fragment/BaseFragment.kt +++ b/core/arch/src/main/kotlin/com/f0x1d/logfox/arch/presentation/ui/fragment/BaseFragment.kt @@ -11,7 +11,7 @@ import com.f0x1d.logfox.arch.presentation.ui.base.SimpleFragmentLifecycleOwner import com.f0x1d.logfox.arch.presentation.ui.snackbar import dev.chrisbanes.insetter.applyInsetter -abstract class BaseFragment: Fragment(), SimpleFragmentLifecycleOwner { +abstract class BaseFragment : Fragment(), SimpleFragmentLifecycleOwner { private var mutableBinding: T? = null protected val binding: T get() = mutableBinding!! diff --git a/core/database/build.gradle.kts b/core/database/build.gradle.kts index 83c12ac8..f93c4570 100644 --- a/core/database/build.gradle.kts +++ b/core/database/build.gradle.kts @@ -16,6 +16,8 @@ android { dependencies { implementation(projects.core.arch) + compileOnly(libs.androidx.compose.runtime) + implementation(libs.androidx.room) implementation(libs.androidx.room.runtime) ksp(libs.androidx.room.compiler) diff --git a/core/database/src/main/kotlin/com/f0x1d/logfox/database/entity/LogRecording.kt b/core/database/src/main/kotlin/com/f0x1d/logfox/database/entity/LogRecording.kt index f8994b50..d3045025 100644 --- a/core/database/src/main/kotlin/com/f0x1d/logfox/database/entity/LogRecording.kt +++ b/core/database/src/main/kotlin/com/f0x1d/logfox/database/entity/LogRecording.kt @@ -1,5 +1,6 @@ package com.f0x1d.logfox.database.entity +import androidx.compose.runtime.Immutable import androidx.room.ColumnInfo import androidx.room.Dao import androidx.room.Delete @@ -12,6 +13,7 @@ import com.f0x1d.logfox.model.Identifiable import kotlinx.coroutines.flow.Flow import java.io.File +@Immutable @Entity data class LogRecording( @ColumnInfo(name = "title") val title: String, diff --git a/core/navigation/src/main/res/navigation/recordings.xml b/core/navigation/src/main/res/navigation/recordings.xml index cec33f06..d244ca42 100644 --- a/core/navigation/src/main/res/navigation/recordings.xml +++ b/core/navigation/src/main/res/navigation/recordings.xml @@ -6,7 +6,7 @@ Unit, + text: @Composable () -> Unit, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + shape: Shape = CardDefaults.shape, +) { + Card( + modifier = modifier, + onClick = onClick, + enabled = enabled, + shape = shape, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(20.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + icon() + + ProvideTextStyle( + value = MaterialTheme.typography.bodyLarge.copy( + fontWeight = FontWeight.Medium, + ), + ) { + text() + } + } + } +} + +@DayNightPreview +@Composable +private fun Preview() = LogFoxTheme { + VerticalButton( + icon = { + Icon( + painter = painterResource(Icons.ic_menu_overflow), + contentDescription = null, + ) + }, + text = { + Text(text = stringResource(Strings.root)) + }, + onClick = { }, + ) +} diff --git a/core/ui-compose/src/main/kotlin/com/f0x1d/logfox/ui/compose/component/placeholder/ListPlaceholder.kt b/core/ui-compose/src/main/kotlin/com/f0x1d/logfox/ui/compose/component/placeholder/ListPlaceholder.kt new file mode 100644 index 00000000..0035ca84 --- /dev/null +++ b/core/ui-compose/src/main/kotlin/com/f0x1d/logfox/ui/compose/component/placeholder/ListPlaceholder.kt @@ -0,0 +1,68 @@ +package com.f0x1d.logfox.ui.compose.component.placeholder + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ProvideTextStyle +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.f0x1d.logfox.strings.Strings +import com.f0x1d.logfox.ui.Icons +import com.f0x1d.logfox.ui.compose.preview.DayNightPreview +import com.f0x1d.logfox.ui.compose.theme.LogFoxTheme + +@Composable +fun ListPlaceholder( + @DrawableRes iconResId: Int, + text: @Composable () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.padding(horizontal = 10.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Box( + modifier = Modifier + .size(120.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceContainerHighest) + .padding(26.dp), + ) { + Icon( + modifier = Modifier.fillMaxSize(), + painter = painterResource(iconResId), + contentDescription = null, + ) + } + + ProvideTextStyle(MaterialTheme.typography.bodyLarge) { + text() + } + } +} + +@DayNightPreview +@Composable +private fun Preview() = LogFoxTheme { + ListPlaceholder( + iconResId = Icons.ic_recording, + text = { + Text(text = stringResource(Strings.no_crashes)) + }, + ) +} diff --git a/core/ui/src/main/kotlin/com/f0x1d/logfox/ui/view/EditTextExt.kt b/core/ui/src/main/kotlin/com/f0x1d/logfox/ui/view/EditTextExt.kt new file mode 100644 index 00000000..0107a95b --- /dev/null +++ b/core/ui/src/main/kotlin/com/f0x1d/logfox/ui/view/EditTextExt.kt @@ -0,0 +1,37 @@ +package com.f0x1d.logfox.ui.view + +import android.text.Editable +import android.text.TextWatcher +import android.widget.EditText + +class ExtendedTextWatcher( + val editText: EditText, + var enabled: Boolean = true, + private val doAfterTextChanged: (e: Editable?) -> Unit, +) : TextWatcher { + + init { + editText.addTextChangedListener(this) + } + + override fun beforeTextChanged(p0: CharSequence?, p1: Int, p2: Int, p3: Int) = Unit + + override fun onTextChanged(p0: CharSequence?, p1: Int, p2: Int, p3: Int) = Unit + + override fun afterTextChanged(e: Editable?) { + if (enabled) { + doAfterTextChanged(e) + } + } + + fun setText(text: String?) { + enabled = false + editText.setText(text) + enabled = true + } +} + +fun EditText.applyExtendedTextWatcher(doAfterTextChanged: (e: Editable?) -> Unit): ExtendedTextWatcher = ExtendedTextWatcher( + editText = this, + doAfterTextChanged = doAfterTextChanged, +) diff --git a/core/ui/src/main/res/drawable/ic_menu_overflow.xml b/core/ui/src/main/res/drawable/ic_menu_overflow.xml new file mode 100644 index 00000000..7a4fbe64 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_menu_overflow.xml @@ -0,0 +1,9 @@ + + + diff --git a/feature/logging/list/src/main/kotlin/com/f0x1d/logfox/feature/logging/list/presentation/LogsViewModel.kt b/feature/logging/list/src/main/kotlin/com/f0x1d/logfox/feature/logging/list/presentation/LogsViewModel.kt index 5879b757..c7c06003 100644 --- a/feature/logging/list/src/main/kotlin/com/f0x1d/logfox/feature/logging/list/presentation/LogsViewModel.kt +++ b/feature/logging/list/src/main/kotlin/com/f0x1d/logfox/feature/logging/list/presentation/LogsViewModel.kt @@ -46,7 +46,7 @@ class LogsViewModel @Inject constructor( @IODispatcher private val ioDispatcher: CoroutineDispatcher, dateTimeFormatter: DateTimeFormatter, application: Application, -): BaseViewModel( +) : BaseViewModel( initialStateProvider = { LogsState() }, application = application, ), DateTimeFormatter by dateTimeFormatter { diff --git a/feature/recordings/details/src/main/kotlin/com/f0x1d/logfox/feature/recordings/details/presentation/RecordingDetailsViewModel.kt b/feature/recordings/details/src/main/kotlin/com/f0x1d/logfox/feature/recordings/details/presentation/RecordingDetailsViewModel.kt index fa7713e3..65b598d5 100644 --- a/feature/recordings/details/src/main/kotlin/com/f0x1d/logfox/feature/recordings/details/presentation/RecordingDetailsViewModel.kt +++ b/feature/recordings/details/src/main/kotlin/com/f0x1d/logfox/feature/recordings/details/presentation/RecordingDetailsViewModel.kt @@ -34,6 +34,7 @@ class RecordingDetailsViewModel @Inject constructor( application = application, ), DateTimeFormatter by dateTimeFormatter { var currentTitle: String? = null + private set private val titleUpdateMutex = Mutex() @@ -83,6 +84,8 @@ class RecordingDetailsViewModel @Inject constructor( fun updateTitle(title: String) = launchCatching { titleUpdateMutex.withLock { + currentTitle = title + currentState.recording?.let { recordingsRepository.updateTitle(it, title) } diff --git a/feature/recordings/details/src/main/kotlin/com/f0x1d/logfox/feature/recordings/details/presentation/ui/RecordingDetailsBottomSheetFragment.kt b/feature/recordings/details/src/main/kotlin/com/f0x1d/logfox/feature/recordings/details/presentation/ui/RecordingDetailsBottomSheetFragment.kt index 417dc4c7..bc9dd70a 100644 --- a/feature/recordings/details/src/main/kotlin/com/f0x1d/logfox/feature/recordings/details/presentation/ui/RecordingDetailsBottomSheetFragment.kt +++ b/feature/recordings/details/src/main/kotlin/com/f0x1d/logfox/feature/recordings/details/presentation/ui/RecordingDetailsBottomSheetFragment.kt @@ -6,7 +6,6 @@ import android.view.View import android.view.ViewGroup import androidx.activity.result.contract.ActivityResultContracts import androidx.core.os.bundleOf -import androidx.core.widget.doAfterTextChanged import androidx.fragment.app.viewModels import androidx.navigation.fragment.findNavController import com.f0x1d.logfox.arch.asUri @@ -15,11 +14,12 @@ import com.f0x1d.logfox.arch.shareFileIntent import com.f0x1d.logfox.feature.recordings.details.databinding.SheetRecordingDetailsBinding import com.f0x1d.logfox.feature.recordings.details.presentation.RecordingDetailsViewModel import com.f0x1d.logfox.navigation.Directions +import com.f0x1d.logfox.ui.view.applyExtendedTextWatcher import dagger.hilt.android.AndroidEntryPoint import java.util.Date @AndroidEntryPoint -class RecordingDetailsBottomSheetFragment: BaseBottomSheetFragment() { +class RecordingDetailsBottomSheetFragment : BaseBottomSheetFragment() { private val viewModel by viewModels() @@ -67,10 +67,12 @@ class RecordingDetailsBottomSheetFragment: BaseBottomSheetFragment - title.setText(viewModel.currentTitle.orEmpty()) + textWatcher.setText(viewModel.currentTitle.orEmpty()) val logRecording = state.recording ?: return@collectWithLifecycle diff --git a/feature/recordings/list/build.gradle.kts b/feature/recordings/list/build.gradle.kts index df914d1a..0781ee02 100644 --- a/feature/recordings/list/build.gradle.kts +++ b/feature/recordings/list/build.gradle.kts @@ -1,5 +1,5 @@ plugins { - id("logfox.android.feature") + id("logfox.android.feature.compose") } android.namespace = "com.f0x1d.logfox.feature.recordings.list" diff --git a/feature/recordings/list/src/main/kotlin/com/f0x1d/logfox/feature/recordings/list/presentation/RecordingsState.kt b/feature/recordings/list/src/main/kotlin/com/f0x1d/logfox/feature/recordings/list/presentation/RecordingsState.kt index b097019f..e13514ec 100644 --- a/feature/recordings/list/src/main/kotlin/com/f0x1d/logfox/feature/recordings/list/presentation/RecordingsState.kt +++ b/feature/recordings/list/src/main/kotlin/com/f0x1d/logfox/feature/recordings/list/presentation/RecordingsState.kt @@ -1,8 +1,10 @@ package com.f0x1d.logfox.feature.recordings.list.presentation +import androidx.compose.runtime.Immutable import com.f0x1d.logfox.database.entity.LogRecording import com.f0x1d.logfox.feature.recordings.api.data.RecordingState +@Immutable data class RecordingsState( val recordings: List = emptyList(), val recordingState: RecordingState = RecordingState.IDLE, diff --git a/feature/recordings/list/src/main/kotlin/com/f0x1d/logfox/feature/recordings/list/presentation/adapter/RecordingsAdapter.kt b/feature/recordings/list/src/main/kotlin/com/f0x1d/logfox/feature/recordings/list/presentation/adapter/RecordingsAdapter.kt deleted file mode 100644 index e6ce5c6f..00000000 --- a/feature/recordings/list/src/main/kotlin/com/f0x1d/logfox/feature/recordings/list/presentation/adapter/RecordingsAdapter.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.f0x1d.logfox.feature.recordings.list.presentation.adapter - -import android.view.LayoutInflater -import android.view.ViewGroup -import com.f0x1d.logfox.arch.presentation.adapter.BaseListAdapter -import com.f0x1d.logfox.database.entity.LogRecording -import com.f0x1d.logfox.feature.recordings.list.databinding.ItemRecordingBinding -import com.f0x1d.logfox.feature.recordings.list.presentation.ui.viewholder.RecordingViewHolder -import com.f0x1d.logfox.model.diffCallback - -class RecordingsAdapter( - private val click: (LogRecording) -> Unit, - private val delete: (LogRecording) -> Unit -): BaseListAdapter(diffCallback()) { - - override fun createHolder(layoutInflater: LayoutInflater, parent: ViewGroup) = - RecordingViewHolder( - binding = ItemRecordingBinding.inflate(layoutInflater, parent, false), - click = click, - delete = delete, - ) -} diff --git a/feature/recordings/list/src/main/kotlin/com/f0x1d/logfox/feature/recordings/list/presentation/ui/RecordingsFragment.kt b/feature/recordings/list/src/main/kotlin/com/f0x1d/logfox/feature/recordings/list/presentation/ui/RecordingsFragment.kt new file mode 100644 index 00000000..92be933c --- /dev/null +++ b/feature/recordings/list/src/main/kotlin/com/f0x1d/logfox/feature/recordings/list/presentation/ui/RecordingsFragment.kt @@ -0,0 +1,83 @@ +package com.f0x1d.logfox.feature.recordings.list.presentation.ui + +import android.os.Bundle +import android.view.View +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.core.os.bundleOf +import androidx.fragment.app.viewModels +import androidx.lifecycle.lifecycleScope +import androidx.navigation.fragment.findNavController +import com.f0x1d.logfox.arch.presentation.ui.fragment.compose.BaseComposeFragment +import com.f0x1d.logfox.database.entity.LogRecording +import com.f0x1d.logfox.feature.recordings.list.presentation.RecordingsAction +import com.f0x1d.logfox.feature.recordings.list.presentation.RecordingsViewModel +import com.f0x1d.logfox.feature.recordings.list.presentation.ui.compose.RecordingsScreenContent +import com.f0x1d.logfox.navigation.Directions +import com.f0x1d.logfox.ui.compose.theme.LogFoxTheme +import com.f0x1d.logfox.ui.dialog.showAreYouSureClearDialog +import com.f0x1d.logfox.ui.dialog.showAreYouSureDeleteDialog +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch + +@AndroidEntryPoint +class RecordingsFragment : BaseComposeFragment() { + + private val viewModel by viewModels() + + private val listener: RecordingsScreenListener by lazy { + RecordingsScreenListener( + onRecordingClick = { openDetails(it) }, + onRecordingDeleteClick = { + showAreYouSureDeleteDialog { + viewModel.delete(it) + } + }, + onStartStopClick = { viewModel.toggleStartStop() }, + onPauseResumeClick = { viewModel.togglePauseResume() }, + onClearClick = { + showAreYouSureClearDialog { + viewModel.clearRecordings() + } + }, + onSaveAllClick = { viewModel.saveAll() }, + ) + } + + private val snackbarHostState = SnackbarHostState() + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + viewModel.actions.collectWithLifecycle { action -> + when (action) { + is RecordingsAction.ShowSnackbar -> lifecycleScope.launch { + snackbarHostState.showSnackbar(action.text) + } + is RecordingsAction.OpenRecording -> openDetails(action.recording) + } + } + } + + @Composable + override fun Content() { + LogFoxTheme { + val state by viewModel.state.collectAsState() + + RecordingsScreenContent( + state = state, + listener = listener, + snackbarHostState = snackbarHostState, + ) + } + } + + private fun openDetails(recording: LogRecording?) = recording?.id?.also { + findNavController().navigate( + resId = Directions.action_recordingsFragment_to_recordingBottomSheet, + args = bundleOf("recording_id" to it), + ) + } +} diff --git a/feature/recordings/list/src/main/kotlin/com/f0x1d/logfox/feature/recordings/list/presentation/ui/RecordingsScreenListener.kt b/feature/recordings/list/src/main/kotlin/com/f0x1d/logfox/feature/recordings/list/presentation/ui/RecordingsScreenListener.kt new file mode 100644 index 00000000..8176c4f7 --- /dev/null +++ b/feature/recordings/list/src/main/kotlin/com/f0x1d/logfox/feature/recordings/list/presentation/ui/RecordingsScreenListener.kt @@ -0,0 +1,23 @@ +package com.f0x1d.logfox.feature.recordings.list.presentation.ui + +import androidx.compose.runtime.Immutable +import com.f0x1d.logfox.database.entity.LogRecording + +@Immutable +internal data class RecordingsScreenListener( + val onRecordingClick: (LogRecording) -> Unit, + val onRecordingDeleteClick: (LogRecording) -> Unit, + val onStartStopClick: () -> Unit, + val onPauseResumeClick: () -> Unit, + val onClearClick: () -> Unit, + val onSaveAllClick: () -> Unit, +) + +internal val MockRecordingsScreenListener = RecordingsScreenListener( + onRecordingClick = { }, + onRecordingDeleteClick = { }, + onStartStopClick = { }, + onPauseResumeClick = { }, + onClearClick = { }, + onSaveAllClick = { }, +) diff --git a/feature/recordings/list/src/main/kotlin/com/f0x1d/logfox/feature/recordings/list/presentation/ui/compose/RecordingsControlItem.kt b/feature/recordings/list/src/main/kotlin/com/f0x1d/logfox/feature/recordings/list/presentation/ui/compose/RecordingsControlItem.kt new file mode 100644 index 00000000..f36819fa --- /dev/null +++ b/feature/recordings/list/src/main/kotlin/com/f0x1d/logfox/feature/recordings/list/presentation/ui/compose/RecordingsControlItem.kt @@ -0,0 +1,171 @@ +package com.f0x1d.logfox.feature.recordings.list.presentation.ui.compose + +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.layout.layoutId +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.f0x1d.logfox.feature.recordings.api.data.RecordingState +import com.f0x1d.logfox.strings.Strings +import com.f0x1d.logfox.ui.Icons +import com.f0x1d.logfox.ui.compose.component.button.VerticalButton +import com.f0x1d.logfox.ui.compose.preview.DayNightPreview +import com.f0x1d.logfox.ui.compose.theme.LogFoxTheme + +@Composable +internal fun RecordingControlsItem( + recordingState: RecordingState, + modifier: Modifier = Modifier, + onStartStopClick: () -> Unit = { }, + onPauseResumeClick: () -> Unit = { }, +) { + val pauseVisible = recordingState == RecordingState.RECORDING || recordingState == RecordingState.PAUSED + val pauseShownFraction by animateFloatAsState( + targetValue = if (pauseVisible) { + 1f + } else { + 0f + }, + label = "pause shown fraction animation", + ) + val gapWidthDp by animateDpAsState( + targetValue = if (pauseVisible) { + 20.dp + } else { + 0.dp + }, + label = "gap width animation", + ) + + Layout( + modifier = modifier + .fillMaxWidth() + .padding( + vertical = 10.dp, + horizontal = 20.dp, + ), + content = { + val (startStopIconResId, startStopTextResId) = remember(recordingState) { + when (recordingState) { + RecordingState.IDLE, + RecordingState.SAVING, + -> Icons.ic_recording to Strings.record + + RecordingState.RECORDING, + RecordingState.PAUSED, + -> Icons.ic_stop to Strings.stop + } + } + + VerticalButton( + modifier = Modifier.layoutId(RecordingsControlItem.RECORD), + icon = { + Icon( + painter = painterResource(startStopIconResId), + contentDescription = null, + ) + }, + text = { + Text(text = stringResource(startStopTextResId)) + }, + onClick = onStartStopClick, + enabled = recordingState != RecordingState.SAVING, + ) + + val (pauseResumeIconResId, pauseResumeTextResId) = remember(recordingState) { + when (recordingState) { + RecordingState.IDLE, + RecordingState.SAVING, + RecordingState.RECORDING, + -> Icons.ic_pause to Strings.pause + + RecordingState.PAUSED, + -> Icons.ic_play to Strings.resume + } + } + + VerticalButton( + modifier = Modifier + .graphicsLayer { alpha = pauseShownFraction } + .layoutId(RecordingsControlItem.PAUSE), + icon = { + Icon( + painter = painterResource(pauseResumeIconResId), + contentDescription = null, + ) + }, + text = { + Text(text = stringResource(pauseResumeTextResId)) + }, + onClick = onPauseResumeClick, + ) + }, + ) { measurables, constraints -> + val gapWidth = gapWidthDp.roundToPx() + val halfGapWidth = gapWidth / 2f + + val maxWidth = constraints.maxWidth + val halfWidth = maxWidth / 2f + val pauseWidth = halfWidth - halfGapWidth + val recordWidth = (maxWidth - (pauseWidth + halfGapWidth) * pauseShownFraction).toInt() + + val recordPlaceable = measurables + .first { it.layoutId == RecordingsControlItem.RECORD } + .measure( + constraints = constraints.copy( + maxWidth = recordWidth, + minWidth = recordWidth, + ), + ) + + val pausePlaceable = if (pauseShownFraction > 0) { + measurables + .first { it.layoutId == RecordingsControlItem.PAUSE } + .measure( + constraints = constraints.copy( + maxWidth = pauseWidth.toInt(), + minWidth = pauseWidth.toInt(), + ), + ) + } else { + null + } + + layout(constraints.maxWidth, recordPlaceable.height) { + recordPlaceable.placeRelative(x = 0, y = 0) + + pausePlaceable?.placeRelative( + x = (constraints.maxWidth - (recordWidth - halfGapWidth) * pauseShownFraction).toInt(), + y = 0, + ) + } + } +} + +private enum class RecordingsControlItem { + RECORD, + PAUSE, +} + +@DayNightPreview +@Composable +private fun IdlePreview() = LogFoxTheme { + RecordingControlsItem(recordingState = RecordingState.IDLE) +} + +@DayNightPreview +@Composable +private fun RecordingPreview() = LogFoxTheme { + RecordingControlsItem(recordingState = RecordingState.RECORDING) +} diff --git a/feature/recordings/list/src/main/kotlin/com/f0x1d/logfox/feature/recordings/list/presentation/ui/compose/RecordingsScreenContent.kt b/feature/recordings/list/src/main/kotlin/com/f0x1d/logfox/feature/recordings/list/presentation/ui/compose/RecordingsScreenContent.kt new file mode 100644 index 00000000..0a4f0ae8 --- /dev/null +++ b/feature/recordings/list/src/main/kotlin/com/f0x1d/logfox/feature/recordings/list/presentation/ui/compose/RecordingsScreenContent.kt @@ -0,0 +1,276 @@ +package com.f0x1d.logfox.feature.recordings.list.presentation.ui.compose + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.material3.rememberTopAppBarState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.f0x1d.logfox.database.entity.LogRecording +import com.f0x1d.logfox.feature.recordings.api.data.RecordingState +import com.f0x1d.logfox.feature.recordings.list.presentation.RecordingsState +import com.f0x1d.logfox.feature.recordings.list.presentation.ui.MockRecordingsScreenListener +import com.f0x1d.logfox.feature.recordings.list.presentation.ui.RecordingsScreenListener +import com.f0x1d.logfox.strings.Strings +import com.f0x1d.logfox.ui.Icons +import com.f0x1d.logfox.ui.compose.component.placeholder.ListPlaceholder +import com.f0x1d.logfox.ui.compose.preview.DayNightPreview +import com.f0x1d.logfox.ui.compose.theme.LogFoxTheme +import java.io.File +import java.util.Date + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun RecordingsScreenContent( + modifier: Modifier = Modifier, + state: RecordingsState = RecordingsState(), + listener: RecordingsScreenListener = MockRecordingsScreenListener, + snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, +) { + val topAppBarState = rememberTopAppBarState() + val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(state = topAppBarState) + + Scaffold( + modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + TopRecordingsBar( + modifier = modifier, + onClearClick = listener.onClearClick, + onSaveAllClick = listener.onSaveAllClick, + scrollBehavior = scrollBehavior, + ) + }, + snackbarHost = { + SnackbarHost(hostState = snackbarHostState) + }, + contentWindowInsets = WindowInsets.statusBars, + ) { contentPadding -> + RecordingsItems( + state = state, + listener = listener, + contentPadding = contentPadding, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun TopRecordingsBar( + modifier: Modifier = Modifier, + onClearClick: () -> Unit = { }, + onSaveAllClick: () -> Unit = { }, + scrollBehavior: TopAppBarScrollBehavior? = null, +) { + CenterAlignedTopAppBar( + modifier = modifier, + title = { Text(text = stringResource(Strings.recordings)) }, + actions = { + IconButton(onClick = onClearClick) { + Icon( + painter = painterResource(Icons.ic_clear_all), + contentDescription = null, + ) + } + + var showMenu by remember { mutableStateOf(false) } + IconButton(onClick = { showMenu = !showMenu }) { + Icon( + painter = painterResource(Icons.ic_menu_overflow), + contentDescription = null, + ) + } + DropdownMenu( + expanded = showMenu, + onDismissRequest = { showMenu = false }, + ) { + DropdownMenuItem( + text = { Text(text = stringResource(Strings.save_all_logs)) }, + onClick = onSaveAllClick, + ) + } + }, + scrollBehavior = scrollBehavior, + ) +} + +@Composable +private fun RecordingsItems( + state: RecordingsState, + listener: RecordingsScreenListener, + contentPadding: PaddingValues, + modifier: Modifier = Modifier, +) { + LazyColumn( + modifier = modifier, + contentPadding = contentPadding, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + item { + RecordingControlsItem( + recordingState = state.recordingState, + onStartStopClick = listener.onStartStopClick, + onPauseResumeClick = listener.onPauseResumeClick, + ) + } + + if (state.recordings.isEmpty()) { + item { + ListPlaceholder( + modifier = Modifier + .animateItem(placementSpec = null) + .padding(vertical = 20.dp), + iconResId = Icons.ic_recording, + text = { Text(text = stringResource(Strings.no_recordings)) }, + ) + } + } + + itemsIndexed( + items = state.recordings, + key = { _, item -> item.id }, + ) { index, item -> + RecordingItem( + modifier = Modifier.animateItem(), + logRecording = item, + onRecordingClick = listener.onRecordingClick, + onRecordingDeleteClick = listener.onRecordingDeleteClick, + ) + + if (index != state.recordings.lastIndex) { + HorizontalDivider( + modifier = Modifier.padding( + start = 80.dp, + end = 10.dp, + ), + ) + } + } + } +} + +@Composable +private fun RecordingItem( + logRecording: LogRecording, + modifier: Modifier = Modifier, + onRecordingClick: (LogRecording) -> Unit = { }, + onRecordingDeleteClick: (LogRecording) -> Unit = { }, +) { + Row( + modifier = modifier + .fillMaxWidth() + .height(IntrinsicSize.Min) + .clickable { onRecordingClick(logRecording) } + .padding( + vertical = 10.dp, + horizontal = 10.dp, + ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Icon( + modifier = Modifier + .size(60.dp) + .padding(15.dp), + painter = painterResource(Icons.ic_recording), + contentDescription = null, + ) + + Column( + modifier = Modifier + .fillMaxHeight() + .weight(1f), + verticalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterVertically), + ) { + Text( + text = logRecording.title, + color = MaterialTheme.colorScheme.onSurface, + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + val dateText = remember(logRecording.dateAndTime) { + Date(logRecording.dateAndTime).toLocaleString() + } + Text( + text = dateText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + IconButton( + onClick = { onRecordingDeleteClick(logRecording) }, + ) { + Icon( + painter = painterResource(Icons.ic_delete), + tint = MaterialTheme.colorScheme.error, + contentDescription = null, + ) + } + } +} + +internal val MockRecordingsState = RecordingsState( + recordings = listOf( + LogRecording( + title = "Cool", + dateAndTime = 0L, + file = File(""), + ), + LogRecording( + title = "Cool", + dateAndTime = 0L, + file = File(""), + id = 1, + ), + ), + recordingState = RecordingState.RECORDING, +) + +@DayNightPreview +@Composable +private fun ScreenContentPreview() = LogFoxTheme { + RecordingsScreenContent( + state = MockRecordingsState, + ) +} diff --git a/feature/recordings/list/src/main/kotlin/com/f0x1d/logfox/feature/recordings/list/presentation/ui/fragment/RecordingsFragment.kt b/feature/recordings/list/src/main/kotlin/com/f0x1d/logfox/feature/recordings/list/presentation/ui/fragment/RecordingsFragment.kt deleted file mode 100644 index 31973933..00000000 --- a/feature/recordings/list/src/main/kotlin/com/f0x1d/logfox/feature/recordings/list/presentation/ui/fragment/RecordingsFragment.kt +++ /dev/null @@ -1,161 +0,0 @@ -package com.f0x1d.logfox.feature.recordings.list.presentation.ui.fragment - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.core.os.bundleOf -import androidx.core.view.isVisible -import androidx.fragment.app.viewModels -import androidx.navigation.fragment.findNavController -import androidx.recyclerview.widget.LinearLayoutManager -import com.f0x1d.logfox.arch.isHorizontalOrientation -import com.f0x1d.logfox.arch.presentation.ui.fragment.BaseFragment -import com.f0x1d.logfox.database.entity.LogRecording -import com.f0x1d.logfox.feature.recordings.api.data.RecordingState -import com.f0x1d.logfox.feature.recordings.list.R -import com.f0x1d.logfox.feature.recordings.list.databinding.FragmentRecordingsBinding -import com.f0x1d.logfox.feature.recordings.list.presentation.RecordingsAction -import com.f0x1d.logfox.feature.recordings.list.presentation.RecordingsViewModel -import com.f0x1d.logfox.feature.recordings.list.presentation.adapter.RecordingsAdapter -import com.f0x1d.logfox.navigation.Directions -import com.f0x1d.logfox.strings.Strings -import com.f0x1d.logfox.ui.Icons -import com.f0x1d.logfox.ui.density.dpToPx -import com.f0x1d.logfox.ui.dialog.showAreYouSureClearDialog -import com.f0x1d.logfox.ui.dialog.showAreYouSureDeleteDialog -import com.f0x1d.logfox.ui.view.setClickListenerOn -import com.f0x1d.logfox.ui.view.setDescription -import com.google.android.material.divider.MaterialDividerItemDecoration -import dagger.hilt.android.AndroidEntryPoint -import dev.chrisbanes.insetter.applyInsetter - -@AndroidEntryPoint -class RecordingsFragment: BaseFragment() { - - private val viewModel by viewModels() - - private val adapter = RecordingsAdapter( - click = { - openDetails(it) - }, - delete = { - showAreYouSureDeleteDialog { - viewModel.delete(it) - } - }, - ) - - override fun inflateBinding( - inflater: LayoutInflater, - container: ViewGroup?, - ) = FragmentRecordingsBinding.inflate(inflater, container, false) - - override fun FragmentRecordingsBinding.onViewCreated(view: View, savedInstanceState: Bundle?) { - requireContext().isHorizontalOrientation.also { horizontalOrientation -> - recordingsRecycler.applyInsetter { - type(navigationBars = true) { - padding(vertical = horizontalOrientation) - } - } - - pauseFab.applyInsetter { - type(navigationBars = true) { - margin(vertical = horizontalOrientation) - } - } - recordFab.applyInsetter { - type(navigationBars = true) { - margin(vertical = horizontalOrientation) - } - } - } - - toolbar.menu.apply { - setClickListenerOn(R.id.save_all_logs_item) { - viewModel.saveAll() - } - setClickListenerOn(R.id.clear_item) { - showAreYouSureClearDialog { - viewModel.clearRecordings() - } - } - } - - recordFab.setOnClickListener { - viewModel.toggleStartStop() - } - pauseFab.setOnClickListener { viewModel.togglePauseResume() } - - recordingsRecycler.layoutManager = LinearLayoutManager(requireContext()) - recordingsRecycler.addItemDecoration( - MaterialDividerItemDecoration( - requireContext(), - LinearLayoutManager.VERTICAL - ).apply { - dividerInsetStart = 80.dpToPx.toInt() - dividerInsetEnd = 10.dpToPx.toInt() - isLastItemDecorated = false - } - ) - recordingsRecycler.adapter = adapter - - viewModel.state.collectWithLifecycle { state -> - placeholderLayout.root.isVisible = state.recordings.isEmpty() - - adapter.submitList(state.recordings) - - recordFab.apply { - when (val recordingState = state.recordingState) { - RecordingState.IDLE, RecordingState.SAVING -> { - setImageResource(Icons.ic_recording) - setDescription(Strings.record) - isEnabled = recordingState == RecordingState.IDLE - } - - RecordingState.RECORDING, RecordingState.PAUSED -> { - setImageResource(Icons.ic_stop) - setDescription(Strings.stop) - isEnabled = true - } - - else -> Unit - } - } - - pauseFab.apply { - when (state.recordingState) { - RecordingState.IDLE, RecordingState.SAVING -> { - hide() - } - - RecordingState.RECORDING -> { - setImageResource(Icons.ic_pause) - setDescription(Strings.pause) - show() - } - - RecordingState.PAUSED -> { - setImageResource(Icons.ic_play) - setDescription(Strings.resume) - show() - } - } - } - } - - viewModel.actions.collectWithLifecycle { action -> - when (action) { - is RecordingsAction.ShowSnackbar -> snackbar(action.text) - is RecordingsAction.OpenRecording -> openDetails(action.recording) - } - } - } - - private fun openDetails(recording: LogRecording?) = recording?.id?.also { - findNavController().navigate( - resId = Directions.action_recordingsFragment_to_recordingBottomSheet, - args = bundleOf("recording_id" to it), - ) - } -} diff --git a/feature/recordings/list/src/main/kotlin/com/f0x1d/logfox/feature/recordings/list/presentation/ui/viewholder/RecordingViewHolder.kt b/feature/recordings/list/src/main/kotlin/com/f0x1d/logfox/feature/recordings/list/presentation/ui/viewholder/RecordingViewHolder.kt deleted file mode 100644 index f710457c..00000000 --- a/feature/recordings/list/src/main/kotlin/com/f0x1d/logfox/feature/recordings/list/presentation/ui/viewholder/RecordingViewHolder.kt +++ /dev/null @@ -1,29 +0,0 @@ -package com.f0x1d.logfox.feature.recordings.list.presentation.ui.viewholder - -import com.f0x1d.logfox.arch.presentation.ui.viewholder.BaseViewHolder -import com.f0x1d.logfox.database.entity.LogRecording -import com.f0x1d.logfox.feature.recordings.list.databinding.ItemRecordingBinding -import java.util.Date - -class RecordingViewHolder( - binding: ItemRecordingBinding, - click: (LogRecording) -> Unit, - delete: (LogRecording) -> Unit -): BaseViewHolder(binding) { - - init { - binding.apply { - root.setOnClickListener { - click(currentItem ?: return@setOnClickListener) - } - deleteButton.setOnClickListener { - delete(currentItem ?: return@setOnClickListener) - } - } - } - - override fun ItemRecordingBinding.bindTo(data: LogRecording) { - title.text = data.title - dateText.text = Date(data.dateAndTime).toLocaleString() - } -} diff --git a/feature/recordings/list/src/main/res/layout/fragment_recordings.xml b/feature/recordings/list/src/main/res/layout/fragment_recordings.xml deleted file mode 100644 index fb1e72b9..00000000 --- a/feature/recordings/list/src/main/res/layout/fragment_recordings.xml +++ /dev/null @@ -1,67 +0,0 @@ - - - - - - - - - - - - - - - - - diff --git a/feature/recordings/list/src/main/res/layout/item_recording.xml b/feature/recordings/list/src/main/res/layout/item_recording.xml deleted file mode 100644 index 8f829d2f..00000000 --- a/feature/recordings/list/src/main/res/layout/item_recording.xml +++ /dev/null @@ -1,68 +0,0 @@ - - - - - - - - - - - - diff --git a/feature/recordings/list/src/main/res/layout/placeholder_recordings.xml b/feature/recordings/list/src/main/res/layout/placeholder_recordings.xml deleted file mode 100644 index f9ff92fe..00000000 --- a/feature/recordings/list/src/main/res/layout/placeholder_recordings.xml +++ /dev/null @@ -1,34 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/feature/recordings/list/src/main/res/menu/recordings_menu.xml b/feature/recordings/list/src/main/res/menu/recordings_menu.xml deleted file mode 100644 index ce99b46b..00000000 --- a/feature/recordings/list/src/main/res/menu/recordings_menu.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - - - diff --git a/feature/setup/src/main/kotlin/com/f0x1d/logfox/feature/setup/presentation/ui/SetupFragment.kt b/feature/setup/src/main/kotlin/com/f0x1d/logfox/feature/setup/presentation/ui/SetupFragment.kt index 0f64b24a..a099fc4c 100644 --- a/feature/setup/src/main/kotlin/com/f0x1d/logfox/feature/setup/presentation/ui/SetupFragment.kt +++ b/feature/setup/src/main/kotlin/com/f0x1d/logfox/feature/setup/presentation/ui/SetupFragment.kt @@ -2,17 +2,19 @@ package com.f0x1d.logfox.feature.setup.presentation.ui import android.os.Bundle import android.view.View +import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.fragment.app.viewModels +import androidx.lifecycle.lifecycleScope import com.f0x1d.logfox.arch.presentation.ui.fragment.compose.BaseComposeFragment import com.f0x1d.logfox.feature.setup.presentation.SetupAction import com.f0x1d.logfox.feature.setup.presentation.SetupViewModel import com.f0x1d.logfox.feature.setup.presentation.ui.compose.SetupScreenContent import com.f0x1d.logfox.ui.compose.theme.LogFoxTheme import dagger.hilt.android.AndroidEntryPoint -import dev.chrisbanes.insetter.applyInsetter +import kotlinx.coroutines.launch @AndroidEntryPoint class SetupFragment : BaseComposeFragment() { @@ -30,15 +32,15 @@ class SetupFragment : BaseComposeFragment() { ) } + private val snackbarHostState = SnackbarHostState() + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) viewModel.actions.collectWithLifecycle { action -> when (action) { - is SetupAction.ShowSnackbar -> snackbar(action.textResId).apply { - this.view.applyInsetter { - type(navigationBars = true) { margin() } - } + is SetupAction.ShowSnackbar -> lifecycleScope.launch { + snackbarHostState.showSnackbar(getString(action.textResId)) } } } @@ -52,6 +54,7 @@ class SetupFragment : BaseComposeFragment() { SetupScreenContent( state = state, listener = listener, + snackbarHostState = snackbarHostState, ) } } diff --git a/feature/setup/src/main/kotlin/com/f0x1d/logfox/feature/setup/presentation/ui/SetupScreenListener.kt b/feature/setup/src/main/kotlin/com/f0x1d/logfox/feature/setup/presentation/ui/SetupScreenListener.kt index 61b313cf..373d4739 100644 --- a/feature/setup/src/main/kotlin/com/f0x1d/logfox/feature/setup/presentation/ui/SetupScreenListener.kt +++ b/feature/setup/src/main/kotlin/com/f0x1d/logfox/feature/setup/presentation/ui/SetupScreenListener.kt @@ -3,7 +3,7 @@ package com.f0x1d.logfox.feature.setup.presentation.ui import androidx.compose.runtime.Immutable @Immutable -data class SetupScreenListener( +internal data class SetupScreenListener( val onRootClick: () -> Unit, val onAdbClick: () -> Unit, val onShizukuClick: () -> Unit, diff --git a/feature/setup/src/main/kotlin/com/f0x1d/logfox/feature/setup/presentation/ui/compose/SetupScreenContent.kt b/feature/setup/src/main/kotlin/com/f0x1d/logfox/feature/setup/presentation/ui/compose/SetupScreenContent.kt index 34107fbc..d40ff8d0 100644 --- a/feature/setup/src/main/kotlin/com/f0x1d/logfox/feature/setup/presentation/ui/compose/SetupScreenContent.kt +++ b/feature/setup/src/main/kotlin/com/f0x1d/logfox/feature/setup/presentation/ui/compose/SetupScreenContent.kt @@ -12,9 +12,12 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag @@ -35,6 +38,7 @@ import com.f0x1d.logfox.ui.compose.theme.LogFoxTheme internal fun SetupScreenContent( state: SetupState = SetupState(), listener: SetupScreenListener = MockSetupScreenListener, + snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, ) { Scaffold( topBar = { @@ -42,6 +46,9 @@ internal fun SetupScreenContent( title = { Text(text = stringResource(id = Strings.setup)) }, ) }, + snackbarHost = { + SnackbarHost(hostState = snackbarHostState) + }, ) { paddingValues -> Box( modifier = Modifier