diff --git a/aeroedge/src/main/kotlin/app/sarama/aeroedge/http/ModelDownloaderClient.kt b/aeroedge/src/main/kotlin/app/sarama/aeroedge/http/ModelDownloaderClient.kt index b9c5694..dd3a37f 100644 --- a/aeroedge/src/main/kotlin/app/sarama/aeroedge/http/ModelDownloaderClient.kt +++ b/aeroedge/src/main/kotlin/app/sarama/aeroedge/http/ModelDownloaderClient.kt @@ -35,11 +35,15 @@ internal class DefaultModelDownloaderClient( downloadZipFile(url, zipFile).getOrElse { return@flow emit(FileDownloadState.OnFailed(it)) } + println("[AEROEDGE] downloaded ${zipFile.path}") + unzipModelFile( zipFile, destinationFile ).getOrElse { return@flow emit(FileDownloadState.OnFailed(it)) } + println("[AEROEDGE] unzipped into ${destinationFile.path}") + emit(FileDownloadState.OnSuccess) } @@ -78,6 +82,7 @@ internal class DefaultModelDownloaderClient( zipFile: File, destinationFile: File, ): Result = runCatching { + var found = false ZipFile(zipFile).use { zip -> zip.entries().asSequence().forEach { entry -> if (entry.name != destinationFile.name) return@forEach @@ -90,10 +95,15 @@ internal class DefaultModelDownloaderClient( bos.write(bytesIn, 0, read) } bos.close() + found = true } } } zipFile.delete() + + if(!found) { + throw IllegalStateException("No entry found with the name ${destinationFile.name} into ${zipFile.name}") + } } } \ No newline at end of file diff --git a/aeroedge/src/main/kotlin/app/sarama/aeroedge/worker/ModelDownloadWorker.kt b/aeroedge/src/main/kotlin/app/sarama/aeroedge/worker/ModelDownloadWorker.kt index a59f04c..60a538d 100644 --- a/aeroedge/src/main/kotlin/app/sarama/aeroedge/worker/ModelDownloadWorker.kt +++ b/aeroedge/src/main/kotlin/app/sarama/aeroedge/worker/ModelDownloadWorker.kt @@ -50,8 +50,6 @@ internal class ModelDownloadWorker( return Result.failure() } - println("[AEROEDGE] POSTING ${destinationFile.path}") - return Result.success(workDataOf(WORKER_LOCAL_MODEL_FILE_PATH_KEY to destinationFile.path)) } diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2b50a11..9cd4873 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -50,21 +50,33 @@ android { } dependencies { + implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.aar")))) + implementation(project(":aeroedge")) - implementation("androidx.core:core-ktx:1.9.0") + implementation("androidx.core:core-ktx:1.12.0") implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.2") implementation("androidx.activity:activity-compose:1.8.0") + implementation("androidx.lifecycle:lifecycle-runtime-compose:2.6.2") implementation(platform("androidx.compose:compose-bom:2023.03.00")) implementation("androidx.compose.ui:ui") implementation("androidx.compose.ui:ui-graphics") implementation("androidx.compose.ui:ui-tooling-preview") implementation("androidx.compose.material3:material3") + implementation("androidx.compose.material:material-icons-extended") + implementation("com.google.accompanist:accompanist-systemuicontroller:0.30.0") + implementation("org.tensorflow:tensorflow-lite:2.14.0") + implementation("io.insert-koin:koin-core:3.5.0") + implementation("io.insert-koin:koin-android:3.5.0") + implementation("io.insert-koin:koin-androidx-compose:3.5.0") + testImplementation("junit:junit:4.13.2") + androidTestImplementation("androidx.test.ext:junit:1.1.5") androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") androidTestImplementation(platform("androidx.compose:compose-bom:2023.03.00")) androidTestImplementation("androidx.compose.ui:ui-test-junit4") + debugImplementation("androidx.compose.ui:ui-tooling") debugImplementation("androidx.compose.ui:ui-test-manifest") diff --git a/app/libs/tensorflow-lite-select-tf-ops.aar b/app/libs/tensorflow-lite-select-tf-ops.aar new file mode 100644 index 0000000..45bb95e Binary files /dev/null and b/app/libs/tensorflow-lite-select-tf-ops.aar differ diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index dbc6461..829ac02 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,7 +2,10 @@ + + + android:name=".ui.MainActivity" + android:exported="true"> diff --git a/app/src/main/java/app/sarama/aeroedge/DemoApplication.kt b/app/src/main/java/app/sarama/aeroedge/DemoApplication.kt new file mode 100644 index 0000000..cbdb0f6 --- /dev/null +++ b/app/src/main/java/app/sarama/aeroedge/DemoApplication.kt @@ -0,0 +1,21 @@ +package app.sarama.aeroedge + +import android.app.Application +import app.sarama.aeroedge.di.appModule +import app.sarama.aeroedge.di.viewModelModule +import org.koin.android.ext.koin.androidContext +import org.koin.android.ext.koin.androidLogger +import org.koin.core.context.startKoin + +class DemoApplication: Application() { + + override fun onCreate() { + super.onCreate() + startKoin { + androidLogger() + androidContext(this@DemoApplication) + + modules(appModule, viewModelModule) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/app/sarama/aeroedge/MainActivity.kt b/app/src/main/java/app/sarama/aeroedge/MainActivity.kt deleted file mode 100644 index 7859351..0000000 --- a/app/src/main/java/app/sarama/aeroedge/MainActivity.kt +++ /dev/null @@ -1,46 +0,0 @@ -package app.sarama.aeroedge - -import android.os.Bundle -import androidx.activity.ComponentActivity -import androidx.activity.compose.setContent -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.tooling.preview.Preview -import app.sarama.aeroedge.ui.theme.AeroEdgeTheme - -class MainActivity : ComponentActivity() { - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContent { - AeroEdgeTheme { - // A surface container using the 'background' color from the theme - Surface( - modifier = Modifier.fillMaxSize(), - color = MaterialTheme.colorScheme.background - ) { - Greeting("Android") - } - } - } - } -} - -@Composable -fun Greeting(name: String, modifier: Modifier = Modifier) { - Text( - text = "Hello $name!", - modifier = modifier - ) -} - -@Preview(showBackground = true) -@Composable -fun GreetingPreview() { - AeroEdgeTheme { - Greeting("Android") - } -} \ No newline at end of file diff --git a/app/src/main/java/app/sarama/aeroedge/di/appModule.kt b/app/src/main/java/app/sarama/aeroedge/di/appModule.kt new file mode 100644 index 0000000..1851180 --- /dev/null +++ b/app/src/main/java/app/sarama/aeroedge/di/appModule.kt @@ -0,0 +1,34 @@ +package app.sarama.aeroedge.di + +import app.sarama.aeroedge.AeroEdge +import app.sarama.aeroedge.ModelEntity +import app.sarama.aeroedge.ModelServerClient +import app.sarama.aeroedge.service.autocomplete.AutoCompleteService +import app.sarama.aeroedge.service.autocomplete.AutoCompleteServiceImpl +import org.koin.dsl.module +import org.koin.android.ext.koin.androidContext + + +val appModule = module { + single { + AutoCompleteServiceImpl( + aeroEdge = get(), + ) + } + + single { + AeroEdge( + context = androidContext(), + client = object : ModelServerClient { + override suspend fun fetchRemoteModelInfo(modelName: String) = Result.success( + ModelEntity( + name = modelName, + version = 1, + url = "https://gitlab.com/melvin.biamont/test-aeroedge/-/raw/main/autocomplete_1.tflite.zip?ref_type=heads&inline=false", + fileExtension = "tflite" + ) + ) + } + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/app/sarama/aeroedge/di/viewModelModule.kt b/app/src/main/java/app/sarama/aeroedge/di/viewModelModule.kt new file mode 100644 index 0000000..6ab8f11 --- /dev/null +++ b/app/src/main/java/app/sarama/aeroedge/di/viewModelModule.kt @@ -0,0 +1,9 @@ +package app.sarama.aeroedge.di + +import app.sarama.aeroedge.ui.screen.autocomplete.AutoCompleteViewModel +import org.koin.androidx.viewmodel.dsl.viewModel +import org.koin.dsl.module + +val viewModelModule = module { + viewModel { AutoCompleteViewModel(get()) } +} \ No newline at end of file diff --git a/app/src/main/java/app/sarama/aeroedge/service/autocomplete/AutoCompleteInputConfiguration.kt b/app/src/main/java/app/sarama/aeroedge/service/autocomplete/AutoCompleteInputConfiguration.kt new file mode 100644 index 0000000..8ffaff3 --- /dev/null +++ b/app/src/main/java/app/sarama/aeroedge/service/autocomplete/AutoCompleteInputConfiguration.kt @@ -0,0 +1,10 @@ +package app.sarama.aeroedge.service.autocomplete + +data class AutoCompleteInputConfiguration( + // Minimum number of words to be taken from the end of the input text + val minWordCount: Int = 5, + // Maximum number of words to be taken from the end of the input text + val maxWordCount: Int = 50, + // Initially selected value for number of words to be taken from the end of the input text + val initialWordCount: Int = 20, +) \ No newline at end of file diff --git a/app/src/main/java/app/sarama/aeroedge/service/autocomplete/AutoCompleteService.kt b/app/src/main/java/app/sarama/aeroedge/service/autocomplete/AutoCompleteService.kt new file mode 100644 index 0000000..41ecf97 --- /dev/null +++ b/app/src/main/java/app/sarama/aeroedge/service/autocomplete/AutoCompleteService.kt @@ -0,0 +1,15 @@ +package app.sarama.aeroedge.service.autocomplete + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.StateFlow + +interface AutoCompleteService { + + val initializationStatus: InitializationStatus + + val inputConfiguration: AutoCompleteInputConfiguration + + suspend fun loadModel(scope: CoroutineScope): StateFlow + + suspend fun autocomplete(input: String, applyWindow: Boolean = false, windowSize: Int = 50): Result> +} \ No newline at end of file diff --git a/app/src/main/java/app/sarama/aeroedge/service/autocomplete/AutoCompleteServiceError.kt b/app/src/main/java/app/sarama/aeroedge/service/autocomplete/AutoCompleteServiceError.kt new file mode 100644 index 0000000..7a29a78 --- /dev/null +++ b/app/src/main/java/app/sarama/aeroedge/service/autocomplete/AutoCompleteServiceError.kt @@ -0,0 +1,7 @@ +package app.sarama.aeroedge.service.autocomplete + +sealed class AutoCompleteServiceError(message: String): Throwable(message) { + + data object ModelAlreadyLoading: AutoCompleteServiceError("Model already loading, no need to load it again.") + data object NoSuggestion: AutoCompleteServiceError("No autocomplete suggestion found.") +} \ No newline at end of file diff --git a/app/src/main/java/app/sarama/aeroedge/service/autocomplete/AutoCompleteServiceImpl.kt b/app/src/main/java/app/sarama/aeroedge/service/autocomplete/AutoCompleteServiceImpl.kt new file mode 100644 index 0000000..f9ba541 --- /dev/null +++ b/app/src/main/java/app/sarama/aeroedge/service/autocomplete/AutoCompleteServiceImpl.kt @@ -0,0 +1,116 @@ +package app.sarama.aeroedge.service.autocomplete + +import androidx.annotation.WorkerThread +import app.sarama.aeroedge.AeroEdge +import app.sarama.aeroedge.ModelStatusUpdate +import app.sarama.aeroedge.ModelType +import app.sarama.aeroedge.util.splitToWords +import app.sarama.aeroedge.util.trimToMaxWordCount +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.withContext +import org.tensorflow.lite.Interpreter +import java.io.File +import java.io.FileInputStream +import java.nio.ByteBuffer +import java.nio.MappedByteBuffer +import java.nio.channels.FileChannel +import kotlin.math.min + + +class AutoCompleteServiceImpl( + private val aeroEdge: AeroEdge, + private val dispatcher: CoroutineDispatcher = Dispatchers.IO, +) : AutoCompleteService { + + private val modelStatusFlow = + MutableStateFlow(InitializationStatus.NotInitialized) + private var interpreter: Interpreter? = null + private val outputBuffer = ByteBuffer.allocateDirect(OutputBufferSize) + override val initializationStatus: InitializationStatus + get() = modelStatusFlow.value + + override val inputConfiguration = AutoCompleteInputConfiguration( + // Minimum number of words to be taken from the end of the input text + minWordCount = 5, + // Maximum number of words to be taken from the end of the input text, limited by what the model allows + maxWordCount = min(50, MaxInputWordCount), + // Initially selected value for number of words to be taken from the end of the input text + initialWordCount = 20 + ) + + override suspend fun loadModel(scope: CoroutineScope) = aeroEdge + .getModel(ModelName, ModelType.TensorFlowLite) + .onEach { + if(it is ModelStatusUpdate.OnCompleted) { + this.interpreter = Interpreter(it.model.localFile.fileChannel) + println("[AEROEDGE] Interpreter loaded!") + } + } + .map { + when (it) { + is ModelStatusUpdate.OnCompleted -> InitializationStatus.Initialized + is ModelStatusUpdate.InProgress -> InitializationStatus.Initializing(it.progress) + is ModelStatusUpdate.OnFailed -> InitializationStatus.Error(it.exception) + } + } + .stateIn(scope) + + + private val File.fileChannel: MappedByteBuffer + get() = FileInputStream(this).channel.map(FileChannel.MapMode.READ_ONLY, 0, length()) + + override suspend fun autocomplete( + input: String, + applyWindow: Boolean, + windowSize: Int, + ): Result> = withContext(dispatcher) { + val maxInputWordCount = if (applyWindow) windowSize else MaxInputWordCount + val trimmedInput = input.trimToMaxWordCount(maxInputWordCount) + + val output = runInterpreterOn(trimmedInput) + + if (output.length < trimmedInput.length) { + return@withContext Result.failure(AutoCompleteServiceError.NoSuggestion) + } + + val newText = output.substring(output.indexOf(trimmedInput) + trimmedInput.length) + val words = newText.splitToWords() + if (words.isEmpty()) { + return@withContext Result.failure(AutoCompleteServiceError.NoSuggestion) + } + + Result.success(words) + } + + @WorkerThread + private fun runInterpreterOn(input: String): String { + outputBuffer.clear() + + // Run interpreter, which will generate text into outputBuffer + interpreter?.run(input, outputBuffer) + + // Set output buffer limit to current position & position to 0 + outputBuffer.flip() + + // Get bytes from output buffer + val bytes = ByteArray(outputBuffer.remaining()) + outputBuffer.get(bytes) + + outputBuffer.clear() + + // Return bytes converted to String + return String(bytes, Charsets.UTF_8) + } + + private companion object { + const val ModelName = "autocomplete" + const val MaxInputWordCount = 1024 + const val OutputBufferSize = 800 + } +} \ No newline at end of file diff --git a/app/src/main/java/app/sarama/aeroedge/service/autocomplete/InitializationStatus.kt b/app/src/main/java/app/sarama/aeroedge/service/autocomplete/InitializationStatus.kt new file mode 100644 index 0000000..65457dd --- /dev/null +++ b/app/src/main/java/app/sarama/aeroedge/service/autocomplete/InitializationStatus.kt @@ -0,0 +1,9 @@ +package app.sarama.aeroedge.service.autocomplete + +sealed class InitializationStatus { + + data object NotInitialized: InitializationStatus() + data class Initializing(val progress: Float): InitializationStatus() + data object Initialized: InitializationStatus() + data class Error(val exception: Throwable): InitializationStatus() +} diff --git a/app/src/main/java/app/sarama/aeroedge/ui/MainActivity.kt b/app/src/main/java/app/sarama/aeroedge/ui/MainActivity.kt new file mode 100644 index 0000000..f247fb5 --- /dev/null +++ b/app/src/main/java/app/sarama/aeroedge/ui/MainActivity.kt @@ -0,0 +1,88 @@ +package app.sarama.aeroedge.ui + +import android.annotation.SuppressLint +import android.os.Bundle +import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBars +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.core.view.WindowCompat +import app.sarama.aeroedge.R +import app.sarama.aeroedge.ui.component.HeaderBar +import app.sarama.aeroedge.ui.screen.autocomplete.AutoCompleteScreen +import app.sarama.aeroedge.ui.theme.AeroEdgeTheme +import com.google.accompanist.systemuicontroller.rememberSystemUiController + + +@OptIn(ExperimentalMaterial3Api::class) +class MainActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + WindowCompat.setDecorFitsSystemWindows(window, false) + + setContent { + + val systemUiController = rememberSystemUiController() + DisposableEffect(systemUiController) { + // Update all of the system bar colors to be transparent, and use + // dark icons if we're in light theme + systemUiController.setSystemBarsColor( + color = Color.Transparent, + darkIcons = true + ) + systemUiController.setNavigationBarColor(Color.White) + onDispose {} + } + + AeroEdgeTheme { + val insets = WindowInsets.statusBars.asPaddingValues() + val barHeight = 66.dp + + Scaffold( + topBar = { + HeaderBar( + label = stringResource(R.string.header_autocomplete), + textOffset = (insets.calculateTopPadding() / 4), + modifier = Modifier.height(barHeight + insets.calculateTopPadding() / 2) + ) + } + ) { paddings -> + AutoCompleteScreen( + onShowToast = { id -> Toast.makeText(this, id, Toast.LENGTH_SHORT).show() }, + modifier = Modifier.padding( + top = barHeight - 20.dp, + bottom = paddings.calculateBottomPadding() + ) + ) + } + } + } + } +} + +@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter") +@OptIn(ExperimentalMaterial3Api::class) +@Preview +@Composable +fun PreviewMain() { + AeroEdgeTheme { + Scaffold { + AutoCompleteScreen(onShowToast = {}, modifier = Modifier.padding(top = 50.dp)) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/app/sarama/aeroedge/ui/component/HeaderBar.kt b/app/src/main/java/app/sarama/aeroedge/ui/component/HeaderBar.kt new file mode 100644 index 0000000..237972a --- /dev/null +++ b/app/src/main/java/app/sarama/aeroedge/ui/component/HeaderBar.kt @@ -0,0 +1,77 @@ +package app.sarama.aeroedge.ui.component + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.material3.Divider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.FixedScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import app.sarama.aeroedge.R +import app.sarama.aeroedge.ui.theme.AeroEdgeTheme + +@Composable +fun HeaderBar( + label: String, + textOffset: Dp, + modifier:Modifier = Modifier +) { + Box( + modifier = modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.background) + ) { + Image( + painter = painterResource(id = R.drawable.header_background), + contentDescription = "", + contentScale = FixedScale(.47f), + ) + Text( + text = label, + color = MaterialTheme.colorScheme.secondary, + style = MaterialTheme.typography.displayLarge, + modifier = Modifier + .align(Alignment.Center) + .offset(y = textOffset) + ) + Divider( + modifier = Modifier + .fillMaxWidth() + .height(1.dp) + .align(Alignment.BottomStart) + ) + + } +} + +@Preview +@Composable +fun PreviewHeaderBarDark() { + AeroEdgeTheme(darkTheme = true,) { + HeaderBar( + label = "Autocomplete", + textOffset = 20.dp + ) + } +} + +@Preview +@Composable +fun PreviewHeaderBarLight() { + AeroEdgeTheme(darkTheme = false,) { + HeaderBar( + label = "Autocomplete", + textOffset = 20.dp + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/app/sarama/aeroedge/ui/screen/autocomplete/AutoCompleteScreen.kt b/app/src/main/java/app/sarama/aeroedge/ui/screen/autocomplete/AutoCompleteScreen.kt new file mode 100644 index 0000000..43fee2e --- /dev/null +++ b/app/src/main/java/app/sarama/aeroedge/ui/screen/autocomplete/AutoCompleteScreen.kt @@ -0,0 +1,313 @@ +package app.sarama.aeroedge.ui.screen.autocomplete + +import android.content.res.Configuration +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.Spacer +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.material3.ColorScheme +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ClipboardManager +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.repeatOnLifecycle +import app.sarama.aeroedge.R +import app.sarama.aeroedge.service.autocomplete.AutoCompleteInputConfiguration +import app.sarama.aeroedge.service.autocomplete.InitializationStatus +import app.sarama.aeroedge.ui.screen.autocomplete.components.AutoCompleteInfo +import app.sarama.aeroedge.ui.screen.autocomplete.components.AutoCompleteTextField +import app.sarama.aeroedge.ui.screen.autocomplete.components.TextControlBar +import app.sarama.aeroedge.ui.screen.autocomplete.components.WindowSizeSelection +import app.sarama.aeroedge.ui.theme.AeroEdgeTheme +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import org.koin.androidx.compose.getViewModel + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun AutoCompleteScreen( + onShowToast: (String) -> Unit, + modifier: Modifier = Modifier +) { + val viewmodel = getViewModel() + val modelStatus = viewmodel.modelStatusUpdate.collectAsState() + + when (val status = modelStatus.value) { + InitializationStatus.NotInitialized -> return ModelLoadingScreen( + loadingProgress = 0.0F, + modifier = modifier, + ) + + is InitializationStatus.Initializing -> return ModelLoadingScreen( + loadingProgress = status.progress, + modifier = modifier, + ) + + else -> Unit + } + + val textValue = rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue(annotatedString = AnnotatedString(""))) } + val barState by viewmodel.textBarState.collectAsState() + val inputFieldEnabled by viewmodel.inputFieldEnabled.collectAsStateWithLifecycle() + val windowSizeConfiguration by remember { mutableStateOf(viewmodel.windowSizeConfiguration) } + val clipboardManager: ClipboardManager = LocalClipboardManager.current + val keyboardController = LocalSoftwareKeyboardController.current + + fun showToast(text: String) { + keyboardController?.hide() + + onShowToast(text) + } + + val copiedTextStr = stringResource(R.string.text_copied) + + AutoCompleteScreenContent( + inputValue = textValue.value, + inputEnabled = inputFieldEnabled, + onInputValueChange = { value -> + textValue.value = value + viewmodel.isTextEmpty = value.text.isEmpty() + }, + barState = barState, + inputConfiguration = windowSizeConfiguration, + onClear = { + textValue.value = TextFieldValue(AnnotatedString("")) + viewmodel.onClearInput() + }, + onCopy = { + clipboardManager.setText(textValue.value.annotatedString) + + onShowToast(copiedTextStr) + }, + onGenerate = { viewmodel.onGenerateAutoComplete(textValue.value.text) }, + onRetry = viewmodel::onRetryGenerateAutoComplete, + onAccept = { + viewmodel.onAcceptSuggestion() + textValue.value = TextFieldValue( + text = textValue.value.text, + selection = TextRange(textValue.value.text.length) + ) + }, + onWindowSizeChange = viewmodel::onWindowSizeChange, + modifier = modifier + ) + + val lifecycle = LocalLifecycleOwner.current.lifecycle + val colorScheme = MaterialTheme.colorScheme + + LaunchedEffect(key1 = Unit) { + lifecycle.repeatOnLifecycle(state = Lifecycle.State.STARTED) { + launch { + viewmodel.suggestion.collectLatest { words -> + words?.let { + animateSuggestion(textValue, words, colorScheme) { + viewmodel.onSuggestionReceived() + } + } + } + } + launch { + viewmodel.resetInputText.collectLatest { resetText -> + resetText?.let { text -> + textValue.value = TextFieldValue( + annotatedString = AnnotatedString(text), + selection = TextRange(text.length) + ) + + viewmodel.onResetReceived() + } + } + } + launch { + viewmodel.error.collectLatest { error -> + showToast(error.message ?: "Unknown error") + } + } + } + } +} + +suspend fun animateSuggestion( + textValueState: MutableState, + words: List, + colorScheme: ColorScheme, + onAnimationComplete: () -> Unit +) { + val builder = AnnotatedString.Builder(textValueState.value.annotatedString) + + val stylePos = builder.pushStyle( + SpanStyle( + color = colorScheme.primary, + fontWeight = FontWeight.Bold + ) + ) + + for (word in words) { + builder.append(word) + + val annotatedString = builder.toAnnotatedString() + textValueState.value = TextFieldValue( + annotatedString = annotatedString, + selection = TextRange(annotatedString.length) + ) + delay(100) + } + + builder.pop(stylePos) + + onAnimationComplete() +} + +@Composable +fun AutoCompleteScreenContent( + inputValue: TextFieldValue, + inputEnabled: Boolean, + onInputValueChange: (TextFieldValue) -> Unit, + barState: TextEditBarState, + inputConfiguration: AutoCompleteInputConfiguration, + onClear: () -> Unit, + onCopy: () -> Unit, + onGenerate: () -> Unit, + onRetry: () -> Unit, + onAccept: () -> Unit, + onWindowSizeChange: (Int) -> Unit, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier + .background(MaterialTheme.colorScheme.background) + .fillMaxHeight() + ) { + Column(modifier = Modifier.fillMaxHeight()) { + Column( + modifier = modifier + .padding(start = 16.dp, end = 16.dp, top = 20.dp) + ) { + AutoCompleteTextField( + inputValue = inputValue, + inputEnabled = inputEnabled, + onInputValueChange = onInputValueChange, + modifier = Modifier + .fillMaxWidth() + .height(250.dp) + .padding(bottom = 16.dp), + ) + TextControlBar( + state = barState, + onClearClick = onClear, + onGenerateClick = onGenerate, + onCopyClick = onCopy, + onAccept = onAccept, + onRetry = onRetry + ) + } + Spacer(modifier = Modifier.weight(1f)) + WindowSizeSelection( + inputConfiguration = inputConfiguration, + onWindowValueChange = onWindowSizeChange, + modifier = Modifier.padding(bottom = 32.dp, start = 16.dp, end = 16.dp) + ) + AutoCompleteInfo( + modifier = Modifier + .padding(horizontal = 8.dp, vertical = 8.dp) + ) + } + } +} + +@Composable +fun ModelLoadingScreen( + loadingProgress: Float, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier + .background(MaterialTheme.colorScheme.background) + .fillMaxHeight() + .padding(start = 16.dp, end = 16.dp) + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = Modifier + .fillMaxHeight() + ) { + val percent = (loadingProgress * 100).toInt() + + Text( + "Loading LLM model.\nPlease wait.", + color = MaterialTheme.colorScheme.secondary, + textAlign = TextAlign.Center, + style = MaterialTheme.typography.displayLarge, + ) + + Spacer(Modifier.height(30.dp)) + + LinearProgressIndicator( + modifier = Modifier.fillMaxWidth(), + progress = loadingProgress + ) + + Spacer(Modifier.height(16.dp)) + + Text( + "$percent%", + color = MaterialTheme.colorScheme.secondary, + textAlign = TextAlign.Center, + style = MaterialTheme.typography.displayLarge, + ) + } + } +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO, showBackground = true) +@Composable +fun PreviewAutoCompleteScreen() { + AeroEdgeTheme { + val inputValue by remember { mutableStateOf(TextFieldValue()) } + AutoCompleteScreenContent( + inputValue = inputValue, + onInputValueChange = {}, + inputEnabled = true, + inputConfiguration = AutoCompleteInputConfiguration(), + onClear = {}, + onCopy = {}, + onGenerate = {}, + onRetry = {}, + onAccept = {}, + onWindowSizeChange = {}, + barState = initialControlBarState, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/app/sarama/aeroedge/ui/screen/autocomplete/AutoCompleteViewModel.kt b/app/src/main/java/app/sarama/aeroedge/ui/screen/autocomplete/AutoCompleteViewModel.kt new file mode 100644 index 0000000..b58faa9 --- /dev/null +++ b/app/src/main/java/app/sarama/aeroedge/ui/screen/autocomplete/AutoCompleteViewModel.kt @@ -0,0 +1,248 @@ +package app.sarama.aeroedge.ui.screen.autocomplete + +import androidx.compose.runtime.mutableStateListOf +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import app.sarama.aeroedge.service.autocomplete.AutoCompleteService +import app.sarama.aeroedge.service.autocomplete.AutoCompleteServiceError +import app.sarama.aeroedge.service.autocomplete.InitializationStatus +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +class AutoCompleteViewModel( + private val autoCompleteService: AutoCompleteService +) : ViewModel() { + + private var currentSuggestionInputText: String = "" + private var currentSuggestionText: String? = null + private var windowSize = 0 + private var initModelError: Throwable? = null + private var previousSuggestionsIndex = 0 + private val isGenerating = MutableStateFlow(false) + private val hasGenerated = MutableStateFlow(false) + private val isSuggesting = MutableStateFlow(false) + private val modelInitializationStatus = MutableStateFlow(InitializationStatus.NotInitialized) + + val modelStatusUpdate: StateFlow + get() = modelInitializationStatus + + init { + modelInitializationStatus.value = autoCompleteService.initializationStatus + + if(modelInitializationStatus.value == InitializationStatus.NotInitialized) { + viewModelScope.launch { + autoCompleteService.loadModel(viewModelScope).collect { + modelInitializationStatus.emit(it) + } + } + } + } + + private val _isTextEmpty = MutableStateFlow(true) + var isTextEmpty: Boolean = true + set(value) { + field = value + _isTextEmpty.value = value + + // Clear previous suggestions if the input text is empty + if (value) { + _previousSuggestions.clear() + } + } + + val windowSizeConfiguration = autoCompleteService.inputConfiguration + + /** + * State flow to reset text to previous value. + * Needs to be acknowledged by calling [onResetReceived] since the previous value can be the same + */ + private val _resetInputText = MutableStateFlow(null) + val resetInputText: StateFlow + get() = _resetInputText + + fun onResetReceived() { + _resetInputText.value = null + } + + /** + * State flow containing most recent suggestion from model, as list of words + * Needs to be acknowledged by calling [onSuggestionReceived] + */ + private val _suggestion = MutableStateFlow?>(null) + val suggestion: StateFlow?> + get() = _suggestion + + /** + * Shared flow exposing errors from autocomplete service + */ + private val _error = MutableSharedFlow() + val error: SharedFlow + get() = _error + + /** + * State flow exposing previously made suggestions + */ + private val _previousSuggestions = mutableStateListOf() + val previousSuggestions: List + get() = _previousSuggestions + + /** + * State flow exposing whether Clear CTA should be enabled + */ + private val clearEnabled = combine(isGenerating, _isTextEmpty) { isGenerating, isEmpty -> + !isGenerating && !isEmpty + } + + /** + * State flow exposing whether Generate CTA should be enabled + */ + private val generateEnabled = combine(isGenerating, _isTextEmpty) { isGenerating, isEmpty -> + !isGenerating && !isEmpty + } + + /** + * State flow exposing whether Copy CTA should be enabled + */ + private val copyEnabled = combine(isGenerating, hasGenerated) { isGenerating, hasGenerated -> + !isGenerating && hasGenerated + } + + /** + * State flow exposing edit bar state for Clear, Generate & Copy CTAs & generation process state + */ + private val editingBarState = combine(clearEnabled, generateEnabled, copyEnabled, isGenerating) { clear, generate, copy, generating -> + TextEditBarState.Editing( + clearEnabled = clear, + generateEnabled = generate, + copyEnabled = copy, + generating = generating, + ) + } + + /** + * State flow exposing edit bar state & whether a suggestion is active + */ + val textBarState = combine(editingBarState, isSuggesting) { editState, suggesting -> + if (suggesting) TextEditBarState.Suggesting + else editState + }.stateIn( + scope = viewModelScope, + started = SharingStarted.Lazily, + initialValue = initialControlBarState + ) + + /** + * State flow exposing whether input by user should be possible + */ + val inputFieldEnabled = combine(modelInitializationStatus, isGenerating, isSuggesting) { modelInitializationStatus, generating, suggesting -> + modelInitializationStatus is InitializationStatus.Initialized && !generating && !suggesting + }.stateIn( + scope = viewModelScope, + started = SharingStarted.Lazily, + initialValue = true + ) + + fun onClearInput() { + isGenerating.value = false + hasGenerated.value = false + _isTextEmpty.value = true + currentSuggestionInputText = "" + + _previousSuggestions.clear() + } + + fun onWindowSizeChange(size: Int) { + windowSize = size + } + + fun onRetryGenerateAutoComplete() { + _resetInputText.value = currentSuggestionInputText + + isSuggesting.value = false + currentSuggestionText = null + + onGenerateAutoComplete(currentSuggestionInputText) + } + + fun onAcceptSuggestion() { + currentSuggestionText?.let { text -> + _previousSuggestions += Suggestion( + text = text, + id = previousSuggestionsIndex++ + ) + } + + isSuggesting.value = false + currentSuggestionText = null + } + + fun removeMissingSuggestions(ids: List) { + for (id in ids) { + _previousSuggestions.removeIf { suggestion -> suggestion.id == id } + } + } + + fun onGenerateAutoComplete(text: String) { + initModelError?.let { error -> + viewModelScope.launch { + _error.emit(error) + } + return + } + + currentSuggestionInputText = text + + isGenerating.value = true + + viewModelScope.launch { + autoCompleteService.autocomplete(text, applyWindow = true, windowSize = windowSize).fold( + onSuccess = {words -> + _suggestion.value = words + currentSuggestionText = words.joinToString(separator = "") + }, + onFailure = { + _error.emit(it) + + isGenerating.value = false + } + ) + } + } + + fun onSuggestionReceived() { + _suggestion.value = null + + isGenerating.value = false + hasGenerated.value = true + isSuggesting.value = true + } +} + +sealed class TextEditBarState { + data class Editing( + val clearEnabled: Boolean, + val generateEnabled: Boolean, + val copyEnabled: Boolean, + val generating: Boolean, + ) : TextEditBarState() + + data object Suggesting : TextEditBarState() +} + +val initialControlBarState: TextEditBarState = TextEditBarState.Editing( + clearEnabled = false, + generateEnabled = false, + copyEnabled = false, + generating = false +) + +data class Suggestion( + val text: String, + val id: Int +) \ No newline at end of file diff --git a/app/src/main/java/app/sarama/aeroedge/ui/screen/autocomplete/components/AutoCompleteInfo.kt b/app/src/main/java/app/sarama/aeroedge/ui/screen/autocomplete/components/AutoCompleteInfo.kt new file mode 100644 index 0000000..5f52a46 --- /dev/null +++ b/app/src/main/java/app/sarama/aeroedge/ui/screen/autocomplete/components/AutoCompleteInfo.kt @@ -0,0 +1,39 @@ +package app.sarama.aeroedge.ui.screen.autocomplete.components + +import android.content.res.Configuration +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import app.sarama.aeroedge.R +import app.sarama.aeroedge.ui.theme.AeroEdgeTheme + +@Composable +fun AutoCompleteInfo( + modifier: Modifier = Modifier +) { + Column( + modifier = modifier.fillMaxWidth() + ) { + Spacer(modifier = Modifier.weight(1f)) + Text( + text = stringResource(R.string.about_title), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.tertiary, + ) + Spacer(modifier = Modifier.weight(1f)) + } +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO, showBackground = true) +@Composable +fun PreviewAutCompleteInfo() { + AeroEdgeTheme { + AutoCompleteInfo() + } +} \ No newline at end of file diff --git a/app/src/main/java/app/sarama/aeroedge/ui/screen/autocomplete/components/AutoCompleteTextField.kt b/app/src/main/java/app/sarama/aeroedge/ui/screen/autocomplete/components/AutoCompleteTextField.kt new file mode 100644 index 0000000..c8232d0 --- /dev/null +++ b/app/src/main/java/app/sarama/aeroedge/ui/screen/autocomplete/components/AutoCompleteTextField.kt @@ -0,0 +1,76 @@ +package app.sarama.aeroedge.ui.screen.autocomplete.components + +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.TextFieldValue +import app.sarama.aeroedge.R +import app.sarama.aeroedge.ui.theme.ActiveOutlinedTextFieldBackground +import app.sarama.aeroedge.ui.theme.InactiveOutlinedTextFieldBackground +import app.sarama.aeroedge.ui.theme.InactiveOutlinedTextFieldBorder +import app.sarama.aeroedge.ui.theme.Purple40 +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AutoCompleteTextField( + inputValue: TextFieldValue, + inputEnabled: Boolean, + onInputValueChange: (TextFieldValue) -> Unit, + modifier: Modifier = Modifier +) { + val focusRequester = remember { FocusRequester() } + val scope = rememberCoroutineScope() + + SideEffect { + if (inputEnabled) { + scope.launch { + focusRequester.requestFocus() + } + } + } + + OutlinedTextField( + value = inputValue, + onValueChange = onInputValueChange, + enabled = inputEnabled, + textStyle = MaterialTheme.typography.bodySmall, + shape = MaterialTheme.shapes.medium, + placeholder = { + Text( + text = stringResource(R.string.input_hint), + color = MaterialTheme.colorScheme.tertiary, + modifier = Modifier.alpha(.7f) + ) + }, + colors = TextFieldDefaults.outlinedTextFieldColors( + disabledTextColor = MaterialTheme.colorScheme.onSurface, + unfocusedBorderColor = MaterialTheme.colorScheme.tertiary.copy(alpha = .7f), + disabledBorderColor = InactiveOutlinedTextFieldBorder, + focusedBorderColor = Purple40, + containerColor = when { + inputValue.text.isEmpty() -> InactiveOutlinedTextFieldBackground + inputEnabled -> ActiveOutlinedTextFieldBackground + else -> InactiveOutlinedTextFieldBackground + } + ), + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.Sentences, + ), + modifier = modifier + .focusRequester(focusRequester) + ) +} \ No newline at end of file diff --git a/app/src/main/java/app/sarama/aeroedge/ui/screen/autocomplete/components/TextControlBar.kt b/app/src/main/java/app/sarama/aeroedge/ui/screen/autocomplete/components/TextControlBar.kt new file mode 100644 index 0000000..3311d20 --- /dev/null +++ b/app/src/main/java/app/sarama/aeroedge/ui/screen/autocomplete/components/TextControlBar.kt @@ -0,0 +1,215 @@ +package app.sarama.aeroedge.ui.screen.autocomplete.components + +import android.content.res.Configuration.UI_MODE_NIGHT_NO +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.outlined.ContentCopy +import androidx.compose.material.icons.outlined.RestartAlt +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import app.sarama.aeroedge.R +import app.sarama.aeroedge.ui.screen.autocomplete.TextEditBarState +import app.sarama.aeroedge.ui.theme.AeroEdgeTheme + +@Composable +fun TextControlBar( + state: TextEditBarState, + onClearClick: () -> Unit, + onGenerateClick: () -> Unit, + onCopyClick: () -> Unit, + onRetry: () -> Unit, + onAccept: () -> Unit, + modifier: Modifier = Modifier +) { + when (state) { + is TextEditBarState.Editing -> + TextEditBar( + onClearClick = onClearClick, + onGenerateClick = onGenerateClick, + onCopyClick = onCopyClick, + barState = state, + modifier = modifier + ) + + is TextEditBarState.Suggesting -> + SuggestionControlBar( + onRetry = onRetry, + onAccept = onAccept, + modifier = modifier + ) + } +} + +@Composable +fun TextEditBar( + onClearClick: () -> Unit, + onGenerateClick: () -> Unit, + onCopyClick: () -> Unit, + barState: TextEditBarState.Editing, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + IconButton( + onClick = onClearClick, + enabled = barState.clearEnabled, + colors = IconButtonDefaults.iconButtonColors( + contentColor = MaterialTheme.colorScheme.tertiary + ), + modifier = Modifier.padding(start = 12.dp) + ) { + Icon( + imageVector = Icons.Outlined.RestartAlt, + contentDescription = stringResource(R.string.clear_cta) + ) + } + Spacer(modifier = Modifier.weight(1f)) + OutlinedButton( + onClick = onGenerateClick, + enabled = barState.generateEnabled, + border = if (barState.generateEnabled) { + BorderStroke(1.dp, MaterialTheme.colorScheme.primary) + } else { + BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = .38f)) + }, + colors = ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.tertiary + ), + modifier = Modifier + .height(40.dp) + .width(180.dp) + ) { + if (barState.generating) { + CircularProgressIndicator( + strokeWidth = 2.dp, + modifier = Modifier + .scale(.5f) + .offset(0.dp, (-8).dp) + ) + } else { + Text( + text = stringResource(R.string.generate_cta), + modifier = Modifier.padding(horizontal = 32.dp) + ) + } + } + Spacer(modifier = Modifier.weight(1f)) + IconButton( + onClick = onCopyClick, + enabled = barState.copyEnabled, + colors = IconButtonDefaults.iconButtonColors( + contentColor = MaterialTheme.colorScheme.tertiary + ), + modifier = Modifier.padding(end = 12.dp) + ) { + Icon( + imageVector = Icons.Outlined.ContentCopy, + contentDescription = stringResource(R.string.copy_cta) + ) + } + } +} + +@Composable +fun SuggestionControlBar( + onRetry: () -> Unit, + onAccept: () -> Unit, + modifier: Modifier = Modifier +) { + Row(modifier = modifier.fillMaxWidth()) { + TextButton( + onClick = onRetry, + modifier = Modifier.padding(start = 16.dp) + ) { + Text( + text = stringResource(R.string.reject_suggestion_cta), + color = MaterialTheme.colorScheme.tertiary + ) + } + + Spacer(modifier = Modifier.weight(1f)) + OutlinedButton( + onClick = onAccept, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.primary), + modifier = Modifier.padding(end = 10.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + ) { + Icon( + imageVector = Icons.Default.Check, + tint = MaterialTheme.colorScheme.tertiary, + contentDescription = null, + modifier = Modifier + .scale(.8f) + .padding(start = 24.dp) + ) + Text( + text = stringResource(R.string.accept_suggestion_cta), + color = MaterialTheme.colorScheme.tertiary, + modifier = Modifier.padding(start = 8.dp, end = 8.dp, top = 2.dp) + ) + } + + } + } +} + +@Preview(uiMode = UI_MODE_NIGHT_NO, showBackground = true) +@Composable +fun PreviewTextControlBar() { + AeroEdgeTheme { + Column { + TextControlBar( + state = TextEditBarState.Editing( + clearEnabled = false, + generateEnabled = true, + copyEnabled = false, + generating = false + ), + onClearClick = {}, + onGenerateClick = {}, + onCopyClick = {}, + onRetry = {}, + onAccept = {} + ) + Spacer(modifier = Modifier.height(8.dp)) + TextControlBar( + state = TextEditBarState.Suggesting, + onClearClick = { }, + onGenerateClick = { }, + onCopyClick = {}, + onRetry = {}, + onAccept = {} + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/app/sarama/aeroedge/ui/screen/autocomplete/components/WindowSizeSelection.kt b/app/src/main/java/app/sarama/aeroedge/ui/screen/autocomplete/components/WindowSizeSelection.kt new file mode 100644 index 0000000..c8edab4 --- /dev/null +++ b/app/src/main/java/app/sarama/aeroedge/ui/screen/autocomplete/components/WindowSizeSelection.kt @@ -0,0 +1,90 @@ +package app.sarama.aeroedge.ui.screen.autocomplete.components + +import android.content.res.Configuration +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Slider +import androidx.compose.material3.SliderDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import app.sarama.aeroedge.R +import app.sarama.aeroedge.service.autocomplete.AutoCompleteInputConfiguration +import app.sarama.aeroedge.ui.theme.AeroEdgeTheme +import app.sarama.aeroedge.ui.theme.Pink80 +import app.sarama.aeroedge.ui.theme.Purple80 + +@Composable +fun WindowSizeSelection( + inputConfiguration: AutoCompleteInputConfiguration, + onWindowValueChange: (Int) -> Unit, + modifier: Modifier = Modifier +) { + var sliderValue by remember { mutableIntStateOf(inputConfiguration.initialWordCount) } + + LaunchedEffect(key1 = Unit) { + onWindowValueChange(inputConfiguration.initialWordCount) + } + + Column(modifier = modifier.fillMaxWidth()) { + Text( + text = stringResource(R.string.window_size_slider_label), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.tertiary, + modifier = Modifier + ) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .height(50.dp) + ) { + Slider( + value = sliderValue.toFloat(), + onValueChange = { value -> + sliderValue = value.toInt() + + onWindowValueChange(sliderValue) + }, + valueRange = inputConfiguration.minWordCount.toFloat()..inputConfiguration.maxWordCount.toFloat(), + steps = 45, + colors = SliderDefaults.colors( + activeTickColor = MaterialTheme.colorScheme.primary, + inactiveTrackColor = Purple80, + inactiveTickColor = Pink80 + ), + modifier = Modifier.weight(1f) + ) + Text( + text = stringResource(R.string.window_size_wordcount).replace("{count}", sliderValue.toString()), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.tertiary, + modifier = Modifier.padding(start = 16.dp) + ) + } + } +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO, showBackground = true) +@Composable +fun PreviewWindowSizeSelection() { + AeroEdgeTheme{ + WindowSizeSelection( + inputConfiguration = AutoCompleteInputConfiguration(), + onWindowValueChange = {} + ) + } +} diff --git a/app/src/main/java/app/sarama/aeroedge/ui/theme/Color.kt b/app/src/main/java/app/sarama/aeroedge/ui/theme/Color.kt index 1562bfe..6d4d818 100644 --- a/app/src/main/java/app/sarama/aeroedge/ui/theme/Color.kt +++ b/app/src/main/java/app/sarama/aeroedge/ui/theme/Color.kt @@ -8,4 +8,8 @@ val Pink80 = Color(0xFFEFB8C8) val Purple40 = Color(0xFF6650a4) val PurpleGrey40 = Color(0xFF625b71) -val Pink40 = Color(0xFF7D5260) \ No newline at end of file +val Pink40 = Color(0xFF7D5260) + +val InactiveOutlinedTextFieldBorder = Color(0x20D4D7DC) +val InactiveOutlinedTextFieldBackground = Color(0x20F9F9F9) +val ActiveOutlinedTextFieldBackground = Color(0x20FFFFFF) \ No newline at end of file diff --git a/app/src/main/java/app/sarama/aeroedge/ui/theme/Theme.kt b/app/src/main/java/app/sarama/aeroedge/ui/theme/Theme.kt index 77d8dfa..09220ba 100644 --- a/app/src/main/java/app/sarama/aeroedge/ui/theme/Theme.kt +++ b/app/src/main/java/app/sarama/aeroedge/ui/theme/Theme.kt @@ -1,6 +1,5 @@ package app.sarama.aeroedge.ui.theme -import android.app.Activity import android.os.Build import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.MaterialTheme @@ -9,11 +8,7 @@ import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable -import androidx.compose.runtime.SideEffect -import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalView -import androidx.core.view.WindowCompat private val DarkColorScheme = darkColorScheme( primary = Purple80, @@ -53,18 +48,10 @@ fun AeroEdgeTheme( darkTheme -> DarkColorScheme else -> LightColorScheme } - val view = LocalView.current - if (!view.isInEditMode) { - SideEffect { - val window = (view.context as Activity).window - window.statusBarColor = colorScheme.primary.toArgb() - WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = darkTheme - } - } MaterialTheme( colorScheme = colorScheme, - typography = Typography, + typography = DemoTypography, content = content ) } \ No newline at end of file diff --git a/app/src/main/java/app/sarama/aeroedge/ui/theme/Type.kt b/app/src/main/java/app/sarama/aeroedge/ui/theme/Type.kt index 142e094..6eb6ff8 100644 --- a/app/src/main/java/app/sarama/aeroedge/ui/theme/Type.kt +++ b/app/src/main/java/app/sarama/aeroedge/ui/theme/Type.kt @@ -2,33 +2,33 @@ package app.sarama.aeroedge.ui.theme import androidx.compose.material3.Typography import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.TextUnitType import androidx.compose.ui.unit.sp +import app.sarama.aeroedge.R // Set of Material typography styles to start with -val Typography = Typography( - bodyLarge = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Normal, - fontSize = 16.sp, - lineHeight = 24.sp, - letterSpacing = 0.5.sp - ) - /* Other default text styles to override - titleLarge = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Normal, - fontSize = 22.sp, - lineHeight = 28.sp, - letterSpacing = 0.sp +val DemoTypography = Typography( + displayLarge = TextStyle( + fontFamily = FontFamily(Font(R.font.roboto_regular)), + fontSize = 20.sp, + fontWeight = FontWeight.W800, + letterSpacing = TextUnit(.05f, TextUnitType.Em), + ), + titleSmall = TextStyle( + fontFamily = FontFamily(Font(R.font.roboto_regular)), + fontSize = 14.sp, + fontWeight = FontWeight.W500, + letterSpacing = TextUnit(.1f, TextUnitType.Em), ), - labelSmall = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Medium, - fontSize = 11.sp, - lineHeight = 16.sp, - letterSpacing = 0.5.sp + bodySmall = TextStyle( + fontFamily = FontFamily(Font(R.font.roboto_regular)), + fontSize = 14.sp, + fontWeight = FontWeight.W200, + letterSpacing = TextUnit(.1f, TextUnitType.Em), + lineHeight = 20.sp ) - */ ) \ No newline at end of file diff --git a/app/src/main/java/app/sarama/aeroedge/util/StringExt.kt b/app/src/main/java/app/sarama/aeroedge/util/StringExt.kt new file mode 100644 index 0000000..3547ae3 --- /dev/null +++ b/app/src/main/java/app/sarama/aeroedge/util/StringExt.kt @@ -0,0 +1,48 @@ +package app.sarama.aeroedge.util + +fun String.trimToMaxWordCount(count: Int): String { + val allWords = allWords() + val wordCount = allWords.size + if (wordCount < count) return this + + val lastWords = allWords.toMutableList().subList(allWords.size - count, allWords.size) + val lastText = lastWords.joinToString(separator = "") + + var inputIndex = this.length + for (trimmedTextIndex in lastText.length - 1 downTo 0) { + inputIndex-- + val trimmedChar = lastText[trimmedTextIndex] + while (inputIndex >= 0 && this[inputIndex] != trimmedChar) { + inputIndex-- + } + } + return this.substring(inputIndex) +} + +fun String.splitToWords(): List { + val allWords = allWords() + + var index = 0 + val indexList = allWords.mapIndexed { wordIndex, word -> + if (wordIndex == 0) { + index += word.length + 0 + } else { + val ch = word[0] + while (index < length && this[index] != ch) index++ + val outputIndex = index + index += word.length + outputIndex + } + } + + return indexList.mapIndexed { i, wordStartIndex -> + if (i < indexList.size - 1) { + val wordEndIndex = indexList[i + 1] + this.substring(wordStartIndex, wordEndIndex) + } else this.substring(wordStartIndex) + } +} + +private val wordsRegex = """(\b\S+\b)""".toRegex() +fun String.allWords() = wordsRegex.findAll(this).toList().map { it.groupValues.first() } \ No newline at end of file diff --git a/app/src/main/res/drawable/header_background.xml b/app/src/main/res/drawable/header_background.xml new file mode 100644 index 0000000..8293085 --- /dev/null +++ b/app/src/main/res/drawable/header_background.xml @@ -0,0 +1,812 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/font/roboto_bold.ttf b/app/src/main/res/font/roboto_bold.ttf new file mode 100644 index 0000000..43da14d Binary files /dev/null and b/app/src/main/res/font/roboto_bold.ttf differ diff --git a/app/src/main/res/font/roboto_regular.ttf b/app/src/main/res/font/roboto_regular.ttf new file mode 100644 index 0000000..ddf4bfa Binary files /dev/null and b/app/src/main/res/font/roboto_regular.ttf differ diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c69bd53..220a3da 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,3 +1,25 @@ - AeroEdge + AeroEdge Demo + + Header background + Autocomplete + + Text copied + Tap to begin typing your story… + Clear + Generate + Copy + New suggestion + Accept + + Apply context window + {count} words + + Powered by AeroEdge and TensorFlow Lite + + + Model not initialized + No suggestions found + The autocomplete.tflite model is missing + Mind your language! \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 1142517..c04ed7d 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -1,5 +1,8 @@ - \ No newline at end of file