Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Full quality picture #41

Draft
wants to merge 28 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,8 @@ dependencies {
implementation "io.insert-koin:koin-androidx-compose:$koin_version"
testImplementation "io.insert-koin:koin-test:$koin_version"

implementation "io.coil-kt:coil-compose:2.2.2"

testImplementation 'junit:junit:4.13.2'
testImplementation 'org.robolectric:robolectric:4.9'
testImplementation "org.assertj:assertj-core:3.22.0"
Expand Down
15 changes: 15 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@
xmlns:tools="http://schemas.android.com/tools"
package="net.chmielowski.randomchoice">

<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
tools:ignore="ScopedStorage" />

<application
android:name=".RandomChoiceApplication"
android:dataExtractionRules="@xml/data_extraction_rules"
Expand Down Expand Up @@ -34,6 +39,16 @@
<!--For Robolectric-->
<activity android:name="androidx.activity.ComponentActivity" />

<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/provider_paths" />
</provider>

</application>

</manifest>
10 changes: 9 additions & 1 deletion app/src/main/kotlin/net/chmielowski/randomchoice/DiModule.kt
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
package net.chmielowski.randomchoice

import net.chmielowski.randomchoice.core.Choice
import net.chmielowski.randomchoice.file.CreateFileImpl
import net.chmielowski.randomchoice.core.MainExecutor
import net.chmielowski.randomchoice.core.RandomChoice
import net.chmielowski.randomchoice.file.CreateFile
import net.chmielowski.randomchoice.file.DeleteFile
import net.chmielowski.randomchoice.persistence.DeleteSavedDilemma
import net.chmielowski.randomchoice.persistence.DeleteSavedDilemmaImpl
import net.chmielowski.randomchoice.persistence.NonCancellableTask
Expand Down Expand Up @@ -31,5 +34,10 @@ internal val diModule = module {
factory<ObserveSavedDilemmas> { ObserveSavedDilemmasImpl(get()) }
factory<DeleteSavedDilemma> { DeleteSavedDilemmaImpl(get(), get()) }
factory<UndeleteSavedDilemma> { UndeleteSavedDilemmaImpl(get(), get()) }
factory { MainExecutor.Factory { MainExecutor(get(), get(), get(), get(), get()) } }

// File system
factory<CreateFile> { CreateFileImpl(get()) }
factory { DeleteFile() }

factory { MainExecutor.Factory { MainExecutor(get(), get(), get(), get(), get(), get(), get()) } }
}
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ internal data class Dilemma(private val options: List<Option> = listOf(Text(), T
val canResetOrSave get() = options.any(Option::hasValue)

fun update(id: OptionId, option: Option): Dilemma {
if (option is Image && option.bitmap == null) {
if (option is Image && option.file == null) {
return this
}
return Dilemma(options.replace(id.value, option))
Expand Down
12 changes: 3 additions & 9 deletions app/src/main/kotlin/net/chmielowski/randomchoice/core/Option.kt
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
package net.chmielowski.randomchoice.core

import android.graphics.Bitmap
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
import net.chmielowski.randomchoice.utils.toBitmap
import net.chmielowski.randomchoice.utils.toByteArray
import java.io.File

sealed interface Option : Parcelable {

Expand All @@ -19,12 +17,8 @@ sealed interface Option : Parcelable {

@JvmInline
@Parcelize
value class Image(private val array: ByteArray? = null) : Option {
value class Image(val file: File? = null) : Option {

constructor(bitmap: Bitmap?) : this(bitmap?.toByteArray())

val bitmap get() = array?.toBitmap()

override val hasValue get() = array != null
override val hasValue get() = file != null
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
package net.chmielowski.randomchoice.core

import android.os.Parcelable
import kotlinx.parcelize.Parcelize

@JvmInline
value class OptionId(val value: Int)
@Parcelize
value class OptionId(val value: Int) : Parcelable
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package net.chmielowski.randomchoice.core

import android.net.Uri
import android.os.Parcelable
import com.arkivanov.mvikotlin.extensions.coroutines.CoroutineExecutor
import com.arkivanov.mvikotlin.main.store.DefaultStoreFactory
Expand All @@ -10,16 +11,21 @@ import net.chmielowski.randomchoice.core.Intent.EnterOptionsIntent
import net.chmielowski.randomchoice.core.Intent.EnterOptionsIntent.Add
import net.chmielowski.randomchoice.core.Intent.EnterOptionsIntent.AddNew
import net.chmielowski.randomchoice.core.Intent.EnterOptionsIntent.ChangeOption
import net.chmielowski.randomchoice.core.Intent.EnterOptionsIntent.ClickOption
import net.chmielowski.randomchoice.core.Intent.EnterOptionsIntent.OnCameraResult
import net.chmielowski.randomchoice.core.Intent.EnterOptionsIntent.Remove
import net.chmielowski.randomchoice.core.Intent.EnterOptionsIntent.ResetAll
import net.chmielowski.randomchoice.core.Intent.EnterOptionsIntent.SelectMode
import net.chmielowski.randomchoice.core.Intent.MakeChoice
import net.chmielowski.randomchoice.core.Intent.SetTheme
import net.chmielowski.randomchoice.file.CreateFile
import net.chmielowski.randomchoice.file.DeleteFile
import net.chmielowski.randomchoice.persistence.DeleteSavedDilemma
import net.chmielowski.randomchoice.persistence.SaveDilemma
import net.chmielowski.randomchoice.persistence.UndeleteSavedDilemma
import net.chmielowski.randomchoice.ui.theme.Theme
import net.chmielowski.randomchoice.ui.theme.ThemePreference
import java.io.File

internal fun createStateStore(
factory: MainExecutor.Factory,
Expand All @@ -45,6 +51,10 @@ internal sealed interface Intent {
object ResetAll : EnterOptionsIntent

data class SelectMode(val mode: Mode) : EnterOptionsIntent

data class OnCameraResult(val success: Boolean) : EnterOptionsIntent

data class ClickOption(val option: OptionId) : EnterOptionsIntent
}

data class SetTheme(val theme: Theme) : Intent
Expand All @@ -68,6 +78,7 @@ internal data class State(
val dilemma: Dilemma = Dilemma(),
private val lastSaved: Dilemma? = null,
val lastDeleted: DilemmaId? = null,
val pendingPhotoRequest: PhotoRequest? = null,
) : Parcelable {

val showResetButton get() = dilemma.canResetOrSave
Expand All @@ -80,6 +91,12 @@ internal data class State(
val showSavedMessage get() = dilemma == lastSaved

val mode get() = dilemma.mode

@Parcelize
data class PhotoRequest(
val option: OptionId,
val file: File,
) : Parcelable
}

internal sealed interface Label {
Expand All @@ -89,6 +106,8 @@ internal sealed interface Label {
data class ShowResult(val result: Result) : Label

object ShowDilemmaDeleted : Label

data class TakePicture(val option: OptionId, val uri: Uri) : Label
}

internal class MainExecutor(
Expand All @@ -97,6 +116,8 @@ internal class MainExecutor(
private val saveDilemma: SaveDilemma,
private val deleteDilemma: DeleteSavedDilemma,
private val undeleteDilemma: UndeleteSavedDilemma,
private val createFile: CreateFile,
private val deleteFile: DeleteFile,
) : CoroutineExecutor<Intent, Nothing, State, State, Label>() {

@Suppress("ComplexMethod")
Expand All @@ -119,6 +140,33 @@ internal class MainExecutor(
}
}
is SelectMode -> dispatchState { copy(dilemma = dilemma.selectMode(intent.mode)) }
is ClickOption -> {
val (file, uri) = createFile()
dispatchState {
copy(pendingPhotoRequest = State.PhotoRequest(intent.option, file))
}
publish(Label.TakePicture(intent.option, uri))
}
is OnCameraResult -> {
if (intent.success) {
dispatchState {
val request = pendingPhotoRequest!!
copy(
dilemma = dilemma.update(
request.option,
Option.Image(request.file)
),
pendingPhotoRequest = null
)
}
} else {
deleteFile(getState().pendingPhotoRequest!!.file)
dispatchState {
copy(pendingPhotoRequest = null)
}
// TODO@ Show error
}
}
}
MakeChoice -> {
val result = getState().dilemma.choose(choice)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package net.chmielowski.randomchoice.file

import android.content.Context
import android.net.Uri
import android.os.Environment
import androidx.core.content.FileProvider
import net.chmielowski.randomchoice.BuildConfig
import java.io.File

interface CreateFile {

operator fun invoke(): Pair<File, Uri>
}

internal class CreateFileImpl(private val context: Context) : CreateFile {

override operator fun invoke(): Pair<File, Uri> {
val file = createTempFile()
val uri = getUri(file)
return file to uri
}

private fun createTempFile(): File {
val directory = context
.getExternalFilesDir(Environment.DIRECTORY_PICTURES) ?: error("TODO@")
return File.createTempFile("Random Choice", ".jpg", directory)
}

private fun getUri(file: File) =
FileProvider.getUriForFile(context, BuildConfig.APPLICATION_ID + ".provider", file)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package net.chmielowski.randomchoice.file

import java.io.File

internal class DeleteFile {

operator fun invoke(file: File) {
file.delete()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,13 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.layout.Placeable
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import coil.compose.rememberAsyncImagePainter
import net.chmielowski.randomchoice.core.Option
import net.chmielowski.randomchoice.core.Result

Expand All @@ -52,7 +52,7 @@ internal fun AnimatedResult(
)
is Option.Image -> Card(Modifier.padding(top = 8.dp)) {
Image(
bitmap = item.bitmap!!.asImageBitmap(),
painter = rememberAsyncImagePainter(item.file),
contentDescription = null,
contentScale = ContentScale.FillWidth,
modifier = Modifier
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
Expand Down Expand Up @@ -57,7 +58,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.layout.ContentScale
Expand All @@ -66,6 +66,8 @@ import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImagePainter
import coil.compose.rememberAsyncImagePainter
import com.arkivanov.mvikotlin.core.store.Store
import com.arkivanov.mvikotlin.extensions.coroutines.labels
import com.arkivanov.mvikotlin.extensions.coroutines.states
Expand All @@ -82,6 +84,7 @@ import net.chmielowski.randomchoice.core.Label
import net.chmielowski.randomchoice.core.Label.FocusFirstOptionInput
import net.chmielowski.randomchoice.core.Label.ShowDilemmaDeleted
import net.chmielowski.randomchoice.core.Label.ShowResult
import net.chmielowski.randomchoice.core.Label.TakePicture
import net.chmielowski.randomchoice.core.Mode
import net.chmielowski.randomchoice.core.Option
import net.chmielowski.randomchoice.core.State
Expand All @@ -96,7 +99,7 @@ import net.chmielowski.randomchoice.ui.theme.Theme
import net.chmielowski.randomchoice.ui.widgets.Scaffold
import net.chmielowski.randomchoice.ui.widgets.rememberScrollBehavior
import net.chmielowski.randomchoice.utils.Observe
import net.chmielowski.randomchoice.utils.createLaunchCamera
import net.chmielowski.randomchoice.utils.rememberTakePictureLauncher

@OptIn(ExperimentalCoroutinesApi::class)
@Destination(start = true)
Expand All @@ -108,10 +111,12 @@ internal fun InputScreen(
) {
val state by store.states.collectAsState(State())
val focusRequester = remember { FocusRequester() }
val launcher = rememberTakePictureLauncher(store::accept)
store.labels.Observe { label ->
when (label) {
is ShowResult -> navigator.navigate(ResultScreenDestination(label.result))
FocusFirstOptionInput -> focusRequester.requestFocus()
is TakePicture -> launcher.launch(label.uri) // _: ActivityNotFoundException TODO@
ShowDilemmaDeleted -> {}
}
}
Expand Down Expand Up @@ -431,8 +436,8 @@ private fun Field(
)
is Dilemma.ImageField -> ImageField(
field = field,
onIntent = onIntent,
dilemma = dilemma,
onIntent = onIntent,
)
}
}
Expand Down Expand Up @@ -471,25 +476,31 @@ private fun ImageField(
dilemma: Dilemma,
onIntent: (Intent) -> Unit,
) {
val launchCamera = createLaunchCamera(onResult = { bitmap ->
onIntent(EnterOptionsIntent.ChangeOption(Option.Image(bitmap), field.id))
})
Card(
modifier = Modifier.clickable(onClick = launchCamera)
modifier = Modifier.clickable(onClick = { onIntent(EnterOptionsIntent.ClickOption(option = field.id)) })
) {
RemoveOptionButton(
onClick = { onIntent(EnterOptionsIntent.Remove(field.id)) },
index = field.humanIndex,
modifier = Modifier.align(Alignment.End),
canRemove = dilemma.canRemove,
)
val bitmap = field.value.bitmap
if (bitmap != null) {
val readFile = field.value.file
if (readFile != null) {
val painter =
rememberAsyncImagePainter(readFile, contentScale = ContentScale.FillWidth)
val state = painter.state
@Suppress("ControlFlowWithEmptyBody")
if (state is AsyncImagePainter.State.Error) {
// TODO@
}
Image(
bitmap = bitmap.asImageBitmap(),
painter = painter,
contentDescription = null,
contentScale = ContentScale.FillWidth,
modifier = Modifier.fillMaxWidth(),
modifier = Modifier
.defaultMinSize(minHeight = 80.dp)
.fillMaxWidth()
)
} else {
Image(
Expand Down
Loading