Skip to content
Open
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
1 change: 1 addition & 0 deletions code/aepcomposeui/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/build
42 changes: 42 additions & 0 deletions code/aepcomposeui/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
plugins {
id("aep-library")
}

val mavenCoreVersion: String by project
val aepComposeUiModuleName: String by project
val aepComposeUiVersion: String by project
val aepComposeUiMavenRepoName: String by project
val aepComposeUiMavenRepoDescription: String by project

aepLibrary {
namespace = "com.adobe.marketing.mobile.aepcomposeui"

moduleName = aepComposeUiModuleName
moduleVersion = aepComposeUiVersion
enableSpotless = true
enableCheckStyle = true
enableDokkaDoc = true

publishing {
mavenRepoName = aepComposeUiMavenRepoName
mavenRepoDescription = aepComposeUiMavenRepoDescription
gitRepoName = "aepsdk-ui-android"
addCoreDependency(mavenCoreVersion)
}
}

dependencies {
implementation("com.adobe.marketing.mobile:core:$mavenCoreVersion")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need core dependency here?

testImplementation("org.robolectric:robolectric:4.7")
testImplementation("io.mockk:mockk:1.13.11")
api(project(":aepuitemplates"))
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)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.lifecycle.runtime.compose)
}


Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package com.adobe.marketing.mobile.aepcomposeui

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("com.adobe.marketing.mobile.aepcomposeui.test", appContext.packageName)
}
}
4 changes: 4 additions & 0 deletions code/aepcomposeui/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

</manifest>
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package com.adobe.marketing.mobile.aepcomposeui.aepui

import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import com.adobe.marketing.mobile.aepuitemplates.AepUiTemplate
import com.adobe.marketing.mobile.aepuitemplates.SmallImageTemplate
import com.adobe.marketing.mobile.aepcomposeui.aepui.state.AepUiState
import com.adobe.marketing.mobile.aepcomposeui.aepui.state.SmallImageUIState


/**
* Represents a UI component that can be rendered in the AepUI Engine. Binds a template data with a tracking observer.
* This is a sealed interface that can be implemented by different UI components like [SmallImageAepUi], [LargeImageAepUi], etc.
* This allows restricting the type of template and observer that can be associated with a UI component, which can be later
* used in the app to render the UI component via UIComposeExtensions.
* @param T The type of the template associated with the UI component.
* @param S The type of the state associated with the UI component.
*/
sealed interface AepUI<T : AepUiTemplate, S : AepUiState> {
fun getTemplate(): T
fun getState(): S
fun updateState(newState: S)
}


class SmallImageAepUi(
Copy link
Contributor

@spoorthipujariadobe spoorthipujariadobe Sep 27, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we move this to its own file?

Can we decide if it needs to be Aep or AEP and apply it across all file and class names? I vote for the latter since its an acronym and it would be consistent with the class names in Core UI service

Can we also change ui to UI?

private val template: SmallImageTemplate,
state: SmallImageUIState
) : AepUI<SmallImageTemplate, SmallImageUIState> {
private val _state = mutableStateOf(state)
override fun updateState(newState: SmallImageUIState) {
_state.value = newState
}

override fun getTemplate(): SmallImageTemplate {
return template
}

override fun getState(): SmallImageUIState {
return _state.value
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
package com.adobe.marketing.mobile.aepcomposeui.aepui.components

import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
import com.adobe.marketing.mobile.aepuitemplates.SmallImageTemplate
import com.adobe.marketing.mobile.aepcomposeui.aepui.AepUI
import com.adobe.marketing.mobile.aepcomposeui.aepui.SmallImageAepUi
import com.adobe.marketing.mobile.aepcomposeui.contentprovider.AepUiContentProvider
import com.adobe.marketing.mobile.aepcomposeui.observers.AepUiEventObserver
import com.adobe.marketing.mobile.aepcomposeui.aepui.state.SmallImageUIState
import com.adobe.marketing.mobile.aepcomposeui.aepui.style.AepUiStyle
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch

/**
* Composable for rendering a list of AEP UI components.
* Maintains a list of AEP UI components and renders them using the provided container.
*/
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's add descriptions of what the @params mean here

@Composable
fun AepList(
contentProvider: AepUiContentProvider,
aepUiEventObserver: AepUiEventObserver,
aepUiStyle: AepUiStyle,
viewModelKey: String,
container: @Composable (@Composable () -> Unit) -> Unit = { content ->
// Default to a Column if no container is provided
LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
item {
content()
}
}
}
) {
val viewModel: AepListViewModel = viewModel(
factory = AepComposableViewModelFactory(
contentProvider
),
key = viewModelKey
)
val uiList = viewModel.uiList.collectAsStateWithLifecycle()

container {
uiList.value.forEach { ui ->
val uiAsComposable = asComposable(ui, aepUiEventObserver, aepUiStyle)
uiAsComposable.invoke()
}

Button(onClick = { viewModel.loadMore() }, Modifier.fillMaxWidth()) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This button was added for testing. We don't need it in the actual implementation

Text(text = "Load More")
}
}
}


private fun asComposable(
aepUI: AepUI<*, *>,
observer: AepUiEventObserver,
aepUiStyle: AepUiStyle
): @Composable () -> Unit {
return when (aepUI) {
is SmallImageAepUi -> {
{
val state = aepUI.getState()
if (!state.dismissed) {
SmallImageCard(
ui = aepUI,
style = aepUiStyle.smallImageAepUiStyle,
observer = observer
)
}
}
}

else -> throw IllegalArgumentException("Unknown template type")
}
}


private class AepListViewModel(
private val contentProvider: AepUiContentProvider,
) : ViewModel() {

private val _uiList = MutableStateFlow(listOf<AepUI<*, *>>())
val uiList: StateFlow<List<AepUI<*, *>>> = _uiList

init {
viewModelScope.launch {
contentProvider.getContent().collect { templates ->
val uiList = templates.map { template ->

val aepUiState: AepUI<*, *> = when (template) {
is SmallImageTemplate -> SmallImageAepUi(
template,
SmallImageUIState(title = template.title)
)
else -> throw IllegalArgumentException("Unknown template type")
}

aepUiState
}
_uiList.value = uiList
}
}
}

fun loadMore() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we call this refreshList? The content provider is expected to update the contentFlow when this is called thereby refreshing the list view

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also we don't need to support this right now. It was just there for POC. We can add it later if needed

viewModelScope.launch {
contentProvider.refreshContent()
}
}
}

private class AepComposableViewModelFactory(
private val contentProvider: AepUiContentProvider
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
if (modelClass.isAssignableFrom(AepListViewModel::class.java)) {
@Suppress("UNCHECKED_CAST")
return AepListViewModel(contentProvider) as T
}
throw IllegalArgumentException("Unknown ViewModel class")
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package com.adobe.marketing.mobile.aepcomposeui.aepui.components

import androidx.compose.foundation.clickable
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.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.adobe.marketing.mobile.aepcomposeui.aepui.SmallImageAepUi
import com.adobe.marketing.mobile.aepcomposeui.aepui.style.SmallImageAepUiStyle
import com.adobe.marketing.mobile.aepcomposeui.interactions.UIEvent
import com.adobe.marketing.mobile.aepcomposeui.observers.AepUiEventObserver

/**
* Composable for rendering a Small Image Card
*/
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please add description for @params
Also let's make this public. Apps should be able to use this composable, just like the list one

@Composable
internal fun SmallImageCard(
ui: SmallImageAepUi,
style: SmallImageAepUiStyle,
observer: AepUiEventObserver?,
) {

LaunchedEffect(key1 = Unit) {
observer?.onEvent(UIEvent.Display(ui))
}

DisposableEffect(key1 = Unit) {
onDispose {
observer?.onEvent(UIEvent.Dismiss(ui))
}
}

Card(
shape = RoundedCornerShape(8.dp),
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
.clickable {
observer?.onEvent(UIEvent.Click(ui))
},
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
// TODO - Add image support
Spacer(modifier = Modifier.width(16.dp))

Column(
verticalArrangement = Arrangement.Center
) {
ui.getState().title?.let {
Text(
text = it,
style = style.getTitleTextStyle(ui.getTemplate()),
)
}
Text(
text = ui.getTemplate().description,
style = MaterialTheme.typography.bodyMedium
)
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.adobe.marketing.mobile.aepcomposeui.aepui.state

sealed interface AepUiState

data class SmallImageUIState(val title: String?, val dismissed: Boolean = false) : AepUiState
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's move this to its own file
It will need to be changed in future PRs to reflect actual values the state needs to hold

data class LargeImageUIState(val dismissed: Boolean = false) : AepUiState
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LargeImageUIState is out of the scope of this release let's remove it

Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package com.adobe.marketing.mobile.aepcomposeui.aepui.style

import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextAlign

// only needed when we support server side styling
fun AEPText.getComposeTextStyle(): TextStyle {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we remove this class? There's no support for server side styling in AJO. We can add it when we need to

var textStyle = TextStyle()
if (color != null) {
textStyle = textStyle.merge(Color(android.graphics.Color.parseColor(color)))
}
if (align != null) {
textStyle = textStyle.merge(textAlign = when (align) {
"center" -> TextAlign.Center
"left" -> TextAlign.Left
"right" -> TextAlign.Right
"start" -> TextAlign.Start
"end" -> TextAlign.End
else -> TextAlign.Unspecified
})
}
return textStyle
}
Loading