diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml new file mode 100644 index 0000000..fcccd68 --- /dev/null +++ b/.github/workflows/gradle.yml @@ -0,0 +1,25 @@ +name: Gradle CI + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +jobs: + build: + strategy: + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + java: [17] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v3 + with: + distribution: temurin + java-version: ${{ matrix.java }} + - uses: gradle/gradle-build-action@v2 + - run: chmod +x gradlew + - run: ./gradlew clean +# - run: ./gradlew build diff --git a/README.md b/README.md index babf224..f8244b8 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,15 @@ ## Code +### Build + +Set `apiKey=` in `local.properties` + +``` +./gradlew clean +./gradlew build +``` + ... ## Submission Details diff --git a/app/build.gradle.kts b/app/build.gradle.kts index cdca35a..35dc45d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,15 +1,16 @@ plugins { - alias(libs.plugins.androidApplication) - alias(libs.plugins.jetbrainsKotlinAndroid) + id("com.android.application") + id("org.jetbrains.kotlin.android") + id("com.google.android.libraries.mapsplatform.secrets-gradle-plugin") } android { - namespace = "ai.elimu.soga" + namespace = "com.google.ai.soga" compileSdk = 34 defaultConfig { - applicationId = "ai.elimu.soga" - minSdk = 34 + applicationId = "com.google.ai.soga" + minSdk = 26 targetSdk = 34 versionCode = 1000000 versionName = "1.0.0" @@ -20,15 +21,6 @@ android { } } - buildTypes { - release { - isMinifyEnabled = false - proguardFiles( - getDefaultProguardFile("proguard-android-optimize.txt"), - "proguard-rules.pro" - ) - } - } compileOptions { sourceCompatibility = JavaVersion.VERSION_1_8 targetCompatibility = JavaVersion.VERSION_1_8 @@ -40,30 +32,32 @@ android { compose = true } composeOptions { - kotlinCompilerExtensionVersion = "1.5.1" - } - packaging { - resources { - excludes += "/META-INF/{AL2.0,LGPL2.1}" - } + kotlinCompilerExtensionVersion = "1.5.4" } } dependencies { + implementation("androidx.core:core-ktx:1.9.0") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.2") + implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.6.2") + implementation("androidx.activity:activity-compose:1.8.1") + implementation("androidx.navigation:navigation-compose:2.7.5") - implementation(libs.androidx.core.ktx) - implementation(libs.androidx.lifecycle.runtime.ktx) - implementation(libs.androidx.activity.compose) - implementation(platform(libs.androidx.compose.bom)) - implementation(libs.androidx.ui) - implementation(libs.androidx.ui.graphics) - implementation(libs.androidx.ui.tooling.preview) - implementation(libs.androidx.material3) - testImplementation(libs.junit) - androidTestImplementation(libs.androidx.junit) - androidTestImplementation(libs.androidx.espresso.core) - androidTestImplementation(platform(libs.androidx.compose.bom)) - androidTestImplementation(libs.androidx.ui.test.junit4) - debugImplementation(libs.androidx.ui.tooling) - debugImplementation(libs.androidx.ui.test.manifest) -} \ No newline at end of file + implementation(platform("androidx.compose:compose-bom:2023.10.01")) + implementation("androidx.compose.ui:ui") + implementation("androidx.compose.ui:ui-graphics") + implementation("androidx.compose.ui:ui-tooling-preview") + implementation("androidx.compose.material3:material3") + + implementation("io.coil-kt:coil-compose:2.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") + + implementation("com.google.ai.client.generativeai:generativeai:0.2.2") +} diff --git a/app/src/androidTest/java/ai/elimu/soga/ExampleInstrumentedTest.kt b/app/src/androidTest/java/ai/elimu/soga/ExampleInstrumentedTest.kt deleted file mode 100644 index 63aadd9..0000000 --- a/app/src/androidTest/java/ai/elimu/soga/ExampleInstrumentedTest.kt +++ /dev/null @@ -1,24 +0,0 @@ -package ai.elimu.soga - -import androidx.test.platform.app.InstrumentationRegistry -import androidx.test.ext.junit.runners.AndroidJUnit4 - -import org.junit.Test -import org.junit.runner.RunWith - -import org.junit.Assert.* - -/** - * Instrumented test, which will execute on an Android device. - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -@RunWith(AndroidJUnit4::class) -class ExampleInstrumentedTest { - @Test - fun useAppContext() { - // Context of the app under test. - val appContext = InstrumentationRegistry.getInstrumentation().targetContext - assertEquals("ai.elimu.soga", appContext.packageName) - } -} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index dd8c2df..dfb030f 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -10,12 +10,11 @@ android:label="@string/app_name" android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" - android:theme="@style/Theme.Soga"> + tools:targetApi="31"> + android:label="@string/app_name"> @@ -24,4 +23,4 @@ - \ No newline at end of file + diff --git a/app/src/main/java/ai/elimu/soga/MainActivity.kt b/app/src/main/java/ai/elimu/soga/MainActivity.kt deleted file mode 100644 index b48f5cf..0000000 --- a/app/src/main/java/ai/elimu/soga/MainActivity.kt +++ /dev/null @@ -1,46 +0,0 @@ -package ai.elimu.soga - -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 ai.elimu.soga.ui.theme.SogaTheme - -class MainActivity : ComponentActivity() { - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContent { - SogaTheme { - // 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() { - SogaTheme { - Greeting("Android") - } -} \ No newline at end of file diff --git a/app/src/main/java/ai/elimu/soga/ui/theme/Theme.kt b/app/src/main/java/ai/elimu/soga/ui/theme/Theme.kt deleted file mode 100644 index e26e62d..0000000 --- a/app/src/main/java/ai/elimu/soga/ui/theme/Theme.kt +++ /dev/null @@ -1,70 +0,0 @@ -package ai.elimu.soga.ui.theme - -import android.app.Activity -import android.os.Build -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.darkColorScheme -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, - secondary = PurpleGrey80, - tertiary = Pink80 -) - -private val LightColorScheme = lightColorScheme( - primary = Purple40, - secondary = PurpleGrey40, - tertiary = Pink40 - - /* Other default colors to override - background = Color(0xFFFFFBFE), - surface = Color(0xFFFFFBFE), - onPrimary = Color.White, - onSecondary = Color.White, - onTertiary = Color.White, - onBackground = Color(0xFF1C1B1F), - onSurface = Color(0xFF1C1B1F), - */ -) - -@Composable -fun SogaTheme( - darkTheme: Boolean = isSystemInDarkTheme(), - // Dynamic color is available on Android 12+ - dynamicColor: Boolean = true, - content: @Composable () -> Unit -) { - val colorScheme = when { - dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { - val context = LocalContext.current - if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) - } - - 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, - content = content - ) -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/google/ai/soga/GenerativeAiViewModelFactory.kt b/app/src/main/kotlin/com/google/ai/soga/GenerativeAiViewModelFactory.kt new file mode 100644 index 0000000..b58472f --- /dev/null +++ b/app/src/main/kotlin/com/google/ai/soga/GenerativeAiViewModelFactory.kt @@ -0,0 +1,36 @@ +package com.google.ai.soga + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewmodel.CreationExtras +import com.google.ai.client.generativeai.GenerativeModel +import com.google.ai.client.generativeai.type.generationConfig +import com.google.ai.soga.feature.chat.ChatViewModel + +val GenerativeViewModelFactory = object : ViewModelProvider.Factory { + override fun create( + viewModelClass: Class, + extras: CreationExtras + ): T { + val config = generationConfig { + temperature = 0.7f + } + + return with(viewModelClass) { + when { + isAssignableFrom(ChatViewModel::class.java) -> { + // Initialize a GenerativeModel with the `gemini-pro` AI model for chat + val generativeModel = GenerativeModel( + modelName = "gemini-1.0-pro", + apiKey = BuildConfig.apiKey, + generationConfig = config + ) + ChatViewModel(generativeModel) + } + + else -> + throw IllegalArgumentException("Unknown ViewModel class: ${viewModelClass.name}") + } + } as T + } +} diff --git a/app/src/main/kotlin/com/google/ai/soga/MainActivity.kt b/app/src/main/kotlin/com/google/ai/soga/MainActivity.kt new file mode 100644 index 0000000..469d40a --- /dev/null +++ b/app/src/main/kotlin/com/google/ai/soga/MainActivity.kt @@ -0,0 +1,44 @@ +package com.google.ai.soga + +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.ui.Modifier +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import com.google.ai.soga.feature.chat.ChatRoute +import com.google.ai.soga.ui.theme.GenerativeAISample + +class MainActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContent { + GenerativeAISample { + // A surface container using the 'background' color from the theme + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + val navController = rememberNavController() + + NavHost(navController = navController, startDestination = "menu") { + composable("menu") { + MenuScreen(onItemClicked = { routeId -> + navController.navigate(routeId) + }) + } + composable("chat") { + ChatRoute() + } + } + } + } + } + } +} diff --git a/app/src/main/kotlin/com/google/ai/soga/MenuScreen.kt b/app/src/main/kotlin/com/google/ai/soga/MenuScreen.kt new file mode 100644 index 0000000..fb93f51 --- /dev/null +++ b/app/src/main/kotlin/com/google/ai/soga/MenuScreen.kt @@ -0,0 +1,74 @@ +package com.google.ai.soga + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Card +import androidx.compose.material3.MaterialTheme +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.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +data class MenuItem( + val routeId: String, + val titleResId: Int, + val descriptionResId: Int +) + +@Composable +fun MenuScreen( + onItemClicked: (String) -> Unit = { } +) { + val menuItems = listOf( + MenuItem("chat", R.string.menu_chat_title, R.string.menu_chat_description) + ) + LazyColumn( + Modifier + .padding(top = 16.dp, bottom = 16.dp) + ) { + items(menuItems) { menuItem -> + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp) + ) { + Column( + modifier = Modifier + .padding(all = 16.dp) + .fillMaxWidth() + ) { + Text( + text = stringResource(menuItem.titleResId), + style = MaterialTheme.typography.titleMedium + ) + Text( + text = stringResource(menuItem.descriptionResId), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(top = 8.dp) + ) + TextButton( + onClick = { + onItemClicked(menuItem.routeId) + }, + modifier = Modifier.align(Alignment.End) + ) { + Text(text = stringResource(R.string.action_try)) + } + } + } + } + } +} + +@Preview(showSystemUi = true) +@Composable +fun MenuScreenPreview() { + MenuScreen() +} diff --git a/app/src/main/kotlin/com/google/ai/soga/feature/chat/ChatMessage.kt b/app/src/main/kotlin/com/google/ai/soga/feature/chat/ChatMessage.kt new file mode 100644 index 0000000..023b176 --- /dev/null +++ b/app/src/main/kotlin/com/google/ai/soga/feature/chat/ChatMessage.kt @@ -0,0 +1,14 @@ +package com.google.ai.soga.feature.chat + +import java.util.UUID + +enum class Participant { + USER, MODEL, ERROR +} + +data class ChatMessage( + val id: String = UUID.randomUUID().toString(), + var text: String = "", + val participant: Participant = Participant.USER, + var isPending: Boolean = false +) diff --git a/app/src/main/kotlin/com/google/ai/soga/feature/chat/ChatScreen.kt b/app/src/main/kotlin/com/google/ai/soga/feature/chat/ChatScreen.kt new file mode 100644 index 0000000..17da2cc --- /dev/null +++ b/app/src/main/kotlin/com/google/ai/soga/feature/chat/ChatScreen.kt @@ -0,0 +1,203 @@ +package com.google.ai.soga.feature.chat + +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Send +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +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.text.input.KeyboardCapitalization +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.google.ai.soga.GenerativeViewModelFactory +import com.google.ai.soga.R +import kotlinx.coroutines.launch + +@Composable +internal fun ChatRoute( + chatViewModel: ChatViewModel = viewModel(factory = GenerativeViewModelFactory) +) { + val chatUiState by chatViewModel.uiState.collectAsState() + val listState = rememberLazyListState() + val coroutineScope = rememberCoroutineScope() + + Scaffold( + bottomBar = { + MessageInput( + onSendMessage = { inputText -> + chatViewModel.sendMessage(inputText) + }, + resetScroll = { + coroutineScope.launch { + listState.scrollToItem(0) + } + } + ) + } + ) { paddingValues -> + Column( + modifier = Modifier + .padding(paddingValues) + .fillMaxSize() + ) { + // Messages List + ChatList(chatUiState.messages, listState) + } + } +} + +@Composable +fun ChatList( + chatMessages: List, + listState: LazyListState +) { + LazyColumn( + reverseLayout = true, + state = listState + ) { + items(chatMessages.reversed()) { message -> + ChatBubbleItem(message) + } + } +} + +@Composable +fun ChatBubbleItem( + chatMessage: ChatMessage +) { + val isModelMessage = chatMessage.participant == Participant.MODEL || + chatMessage.participant == Participant.ERROR + + val backgroundColor = when (chatMessage.participant) { + Participant.MODEL -> MaterialTheme.colorScheme.primaryContainer + Participant.USER -> MaterialTheme.colorScheme.tertiaryContainer + Participant.ERROR -> MaterialTheme.colorScheme.errorContainer + } + + val bubbleShape = if (isModelMessage) { + RoundedCornerShape(4.dp, 20.dp, 20.dp, 20.dp) + } else { + RoundedCornerShape(20.dp, 4.dp, 20.dp, 20.dp) + } + + val horizontalAlignment = if (isModelMessage) { + Alignment.Start + } else { + Alignment.End + } + + Column( + horizontalAlignment = horizontalAlignment, + modifier = Modifier + .padding(horizontal = 8.dp, vertical = 4.dp) + .fillMaxWidth() + ) { + Text( + text = chatMessage.participant.name, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(bottom = 4.dp) + ) + Row { + if (chatMessage.isPending) { + CircularProgressIndicator( + modifier = Modifier + .align(Alignment.CenterVertically) + .padding(all = 8.dp) + ) + } + BoxWithConstraints { + Card( + colors = CardDefaults.cardColors(containerColor = backgroundColor), + shape = bubbleShape, + modifier = Modifier.widthIn(0.dp, maxWidth * 0.9f) + ) { + Text( + text = chatMessage.text, + modifier = Modifier.padding(16.dp) + ) + } + } + } + } +} + +@Composable +fun MessageInput( + onSendMessage: (String) -> Unit, + resetScroll: () -> Unit = {} +) { + var userMessage by rememberSaveable { mutableStateOf("") } + + ElevatedCard( + modifier = Modifier + .fillMaxWidth() + ) { + Row( + modifier = Modifier + .padding(16.dp) + .fillMaxWidth() + ) { + OutlinedTextField( + value = userMessage, + label = { Text(stringResource(R.string.chat_label)) }, + onValueChange = { userMessage = it }, + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.Sentences, + ), + modifier = Modifier + .align(Alignment.CenterVertically) + .fillMaxWidth() + .weight(0.85f) + ) + IconButton( + onClick = { + if (userMessage.isNotBlank()) { + onSendMessage(userMessage) + userMessage = "" + resetScroll() + } + }, + modifier = Modifier + .padding(start = 16.dp) + .align(Alignment.CenterVertically) + .fillMaxWidth() + .weight(0.15f) + ) { + Icon( + Icons.Default.Send, + contentDescription = stringResource(R.string.action_send), + modifier = Modifier + ) + } + } + } +} diff --git a/app/src/main/kotlin/com/google/ai/soga/feature/chat/ChatUiState.kt b/app/src/main/kotlin/com/google/ai/soga/feature/chat/ChatUiState.kt new file mode 100644 index 0000000..1d58c22 --- /dev/null +++ b/app/src/main/kotlin/com/google/ai/soga/feature/chat/ChatUiState.kt @@ -0,0 +1,23 @@ +package com.google.ai.soga.feature.chat + +import androidx.compose.runtime.toMutableStateList + +class ChatUiState( + messages: List = emptyList() +) { + private val _messages: MutableList = messages.toMutableStateList() + val messages: List = _messages + + fun addMessage(msg: ChatMessage) { + _messages.add(msg) + } + + fun replaceLastPendingMessage() { + val lastMessage = _messages.lastOrNull() + lastMessage?.let { + val newMessage = lastMessage.apply { isPending = false } + _messages.removeLast() + _messages.add(newMessage) + } + } +} diff --git a/app/src/main/kotlin/com/google/ai/soga/feature/chat/ChatViewModel.kt b/app/src/main/kotlin/com/google/ai/soga/feature/chat/ChatViewModel.kt new file mode 100644 index 0000000..ab9dc36 --- /dev/null +++ b/app/src/main/kotlin/com/google/ai/soga/feature/chat/ChatViewModel.kt @@ -0,0 +1,72 @@ +package com.google.ai.soga.feature.chat + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.google.ai.client.generativeai.GenerativeModel +import com.google.ai.client.generativeai.type.asTextOrNull +import com.google.ai.client.generativeai.type.content +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +class ChatViewModel( + generativeModel: GenerativeModel +) : ViewModel() { + private val chat = generativeModel.startChat( + history = listOf( + content(role = "user") { text("Hello, I have 2 dogs in my house.") }, + content(role = "model") { text("Great to meet you. What would you like to know?") } + ) + ) + + private val _uiState: MutableStateFlow = + MutableStateFlow(ChatUiState(chat.history.map { content -> + // Map the initial messages + ChatMessage( + text = content.parts.first().asTextOrNull() ?: "", + participant = if (content.role == "user") Participant.USER else Participant.MODEL, + isPending = false + ) + })) + val uiState: StateFlow = + _uiState.asStateFlow() + + + fun sendMessage(userMessage: String) { + // Add a pending message + _uiState.value.addMessage( + ChatMessage( + text = userMessage, + participant = Participant.USER, + isPending = true + ) + ) + + viewModelScope.launch { + try { + val response = chat.sendMessage(userMessage) + + _uiState.value.replaceLastPendingMessage() + + response.text?.let { modelResponse -> + _uiState.value.addMessage( + ChatMessage( + text = modelResponse, + participant = Participant.MODEL, + isPending = false + ) + ) + } + } catch (e: Exception) { + _uiState.value.replaceLastPendingMessage() + _uiState.value.addMessage( + ChatMessage( + text = e.localizedMessage, + participant = Participant.ERROR + ) + ) + } + } + } +} diff --git a/app/src/main/java/ai/elimu/soga/ui/theme/Color.kt b/app/src/main/kotlin/com/google/ai/soga/ui/theme/Color.kt similarity index 76% rename from app/src/main/java/ai/elimu/soga/ui/theme/Color.kt rename to app/src/main/kotlin/com/google/ai/soga/ui/theme/Color.kt index 07b49c6..b015683 100644 --- a/app/src/main/java/ai/elimu/soga/ui/theme/Color.kt +++ b/app/src/main/kotlin/com/google/ai/soga/ui/theme/Color.kt @@ -1,4 +1,4 @@ -package ai.elimu.soga.ui.theme +package com.google.ai.soga.ui.theme import androidx.compose.ui.graphics.Color @@ -8,4 +8,4 @@ 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) diff --git a/app/src/main/kotlin/com/google/ai/soga/ui/theme/Theme.kt b/app/src/main/kotlin/com/google/ai/soga/ui/theme/Theme.kt new file mode 100644 index 0000000..863cf7e --- /dev/null +++ b/app/src/main/kotlin/com/google/ai/soga/ui/theme/Theme.kt @@ -0,0 +1,35 @@ +package com.google.ai.soga.ui.theme + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color + +private val DarkColorScheme = darkColorScheme( + primary = Purple80, + secondary = PurpleGrey80, + tertiary = Pink80, + background = Color(0xFF1C1B1F), +) + +private val LightColorScheme = lightColorScheme( + primary = Purple40, + secondary = PurpleGrey40, + tertiary = Pink40, + background = Color(0xFFFFFBFE), +) + +@Composable +fun GenerativeAISample( + darkTheme: Boolean = isSystemInDarkTheme(), + content: @Composable () -> Unit +) { + val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) +} diff --git a/app/src/main/java/ai/elimu/soga/ui/theme/Type.kt b/app/src/main/kotlin/com/google/ai/soga/ui/theme/Type.kt similarity index 50% rename from app/src/main/java/ai/elimu/soga/ui/theme/Type.kt rename to app/src/main/kotlin/com/google/ai/soga/ui/theme/Type.kt index 0c4435a..c0b6505 100644 --- a/app/src/main/java/ai/elimu/soga/ui/theme/Type.kt +++ b/app/src/main/kotlin/com/google/ai/soga/ui/theme/Type.kt @@ -1,4 +1,4 @@ -package ai.elimu.soga.ui.theme +package com.google.ai.soga.ui.theme import androidx.compose.material3.Typography import androidx.compose.ui.text.TextStyle @@ -15,20 +15,4 @@ val Typography = Typography( 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 - ), - labelSmall = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Medium, - fontSize = 11.sp, - lineHeight = 16.sp, - letterSpacing = 0.5.sp - ) - */ -) \ No newline at end of file +) diff --git a/app/src/main/kotlin/com/google/ai/soga/util/UriSaver.kt b/app/src/main/kotlin/com/google/ai/soga/util/UriSaver.kt new file mode 100644 index 0000000..55f0d77 --- /dev/null +++ b/app/src/main/kotlin/com/google/ai/soga/util/UriSaver.kt @@ -0,0 +1,16 @@ +package com.google.ai.soga.util + +import android.net.Uri +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.SaverScope + +/** + * Saves a list of Uris across configuration changes + */ +class UriSaver : Saver, List> { + override fun restore(value: List): MutableList = value.map { + Uri.parse(it) + }.toMutableList() + + override fun SaverScope.save(value: MutableList): List = value.map { it.toString() } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c97ff3b..ff3a8ad 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,3 +1,13 @@ Soga - \ No newline at end of file + Go + Try it + + + Chat + Chat with your AI tutor + + + Message + Send + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 1826e9b..b12e78b 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -1,5 +1,5 @@ -