diff --git a/jchucomponentscompose/build.gradle b/jchucomponentscompose/build.gradle index af52747b..5178fa0c 100644 --- a/jchucomponentscompose/build.gradle +++ b/jchucomponentscompose/build.gradle @@ -57,7 +57,7 @@ dependencies { implementation 'androidx.compose.runtime:runtime:1.0.1' implementation 'androidx.compose.runtime:runtime-livedata:1.0.1' implementation 'androidx.constraintlayout:constraintlayout-compose:1.0.0-beta02' - implementation 'androidx.navigation:navigation-compose:2.4.0-alpha07' + implementation 'androidx.navigation:navigation-compose:2.4.0-alpha08' // FIREBASE LIBRARY ---------------------------------------------------------------------------- implementation("com.google.firebase:firebase-analytics-ktx:19.0.1") @@ -92,7 +92,7 @@ afterEvaluate { from components.release groupId = "com.jeluchu" artifactId = "jchucomponentscompose" - version = "0.1.1" + version = "0.2.1" } } } diff --git a/jchucomponentscompose/src/main/java/com/jeluchu/jchucomponentscompose/ui/pager/PageIndicator.kt b/jchucomponentscompose/src/main/java/com/jeluchu/jchucomponentscompose/ui/pager/PageIndicator.kt new file mode 100644 index 00000000..b4c23a53 --- /dev/null +++ b/jchucomponentscompose/src/main/java/com/jeluchu/jchucomponentscompose/ui/pager/PageIndicator.kt @@ -0,0 +1,39 @@ +package com.jeluchu.jchucomponentscompose.ui.pager + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp + +@Composable +fun PageIndicator( + pagesCount: Int, + currentPageIndex: Int, + color: Color, + modifier: Modifier = Modifier +) { + Row(modifier = modifier.wrapContentSize()) { + for (pageIndex in 0 until pagesCount) { + val (tint, width) = if (currentPageIndex == pageIndex) { + color to 16.dp + } else { + color.copy(alpha = 0.5f) to 4.dp + } + Spacer( + modifier = Modifier + .padding(4.dp) + .height(4.dp) + .width(width) + .background(tint, RoundedCornerShape(percent = 50)) + ) + } + } +} diff --git a/jchucomponentscompose/src/main/java/com/jeluchu/jchucomponentscompose/ui/pager/Pager.kt b/jchucomponentscompose/src/main/java/com/jeluchu/jchucomponentscompose/ui/pager/Pager.kt new file mode 100644 index 00000000..391ff99e --- /dev/null +++ b/jchucomponentscompose/src/main/java/com/jeluchu/jchucomponentscompose/ui/pager/Pager.kt @@ -0,0 +1,192 @@ +package com.jeluchu.jchucomponentscompose.ui.pager + +import androidx.compose.animation.core.Animatable +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.draggable +import androidx.compose.foundation.gestures.rememberDraggableState +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.structuralEqualityPolicy +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.layout.Measurable +import androidx.compose.ui.layout.ParentDataModifier +import androidx.compose.ui.unit.Density +import kotlinx.coroutines.launch +import kotlin.math.roundToInt + +class PagerState( + currentPage: Int = 0, + minPage: Int = 0, + maxPage: Int = 0 +) { + private var _minPage by mutableStateOf(minPage) + var minPage: Int + get() = _minPage + set(value) { + _minPage = value.coerceAtMost(_maxPage) + _currentPage = _currentPage.coerceIn(_minPage, _maxPage) + } + + private var _maxPage by mutableStateOf(maxPage, structuralEqualityPolicy()) + var maxPage: Int + get() = _maxPage + set(value) { + _maxPage = value.coerceAtLeast(_minPage) + _currentPage = _currentPage.coerceIn(_minPage, maxPage) + } + + private var _currentPage by mutableStateOf(currentPage.coerceIn(minPage, maxPage)) + var currentPage: Int + get() = _currentPage + set(value) { + _currentPage = value.coerceIn(minPage, maxPage) + } + + enum class SelectionState { Selected, Undecided } + + var selectionState by mutableStateOf(SelectionState.Selected) + + suspend inline fun selectPage(block: PagerState.() -> R): R = try { + selectionState = SelectionState.Undecided + block() + } finally { + selectPage() + } + + suspend fun selectPage() { + currentPage -= currentPageOffset.roundToInt() + snapToOffset(0f) + selectionState = SelectionState.Selected + } + + private var _currentPageOffset = Animatable(0f).apply { + updateBounds(-1f, 1f) + } + val currentPageOffset: Float + get() = _currentPageOffset.value + + suspend fun snapToOffset(offset: Float) { + val max = if (currentPage == minPage) 0f else 1f + val min = if (currentPage == maxPage) 0f else -1f + _currentPageOffset.snapTo(offset.coerceIn(min, max)) + } + + suspend fun fling(velocity: Float) { + if (velocity < 0 && currentPage == maxPage) return + if (velocity > 0 && currentPage == minPage) return + + _currentPageOffset.animateTo(currentPageOffset.roundToInt().toFloat()) + selectPage() + } + + override fun toString(): String = "PagerState{minPage=$minPage, maxPage=$maxPage, " + + "currentPage=$currentPage, currentPageOffset=$currentPageOffset}" +} + +@Immutable +private data class PageData(val page: Int) : ParentDataModifier { + override fun Density.modifyParentData(parentData: Any?): Any = this@PageData +} + +private val Measurable.page: Int + get() = (parentData as? PageData)?.page ?: error("no PageData for measurable $this") + +@Composable +fun Pager( + state: PagerState, + modifier: Modifier = Modifier, + offscreenLimit: Int = 2, + pageContent: @Composable PagerScope.() -> Unit +) { + var pageSize by remember { mutableStateOf(0) } + val coroutineScope = rememberCoroutineScope() + Layout( + content = { + val minPage = (state.currentPage - offscreenLimit).coerceAtLeast(state.minPage) + val maxPage = (state.currentPage + offscreenLimit).coerceAtMost(state.maxPage) + + for (page in minPage..maxPage) { + val pageData = PageData(page) + val scope = PagerScope(state, page) + key(pageData) { + Box(contentAlignment = Alignment.Center, modifier = pageData) { + scope.pageContent() + } + } + } + }, + modifier = modifier.draggable( + orientation = Orientation.Horizontal, + onDragStarted = { + state.selectionState = PagerState.SelectionState.Undecided + }, + onDragStopped = { velocity -> + coroutineScope.launch { + // Velocity is in pixels per second, but we deal in percentage offsets, so we + // need to scale the velocity to match + state.fling(velocity / pageSize) + } + }, + state = rememberDraggableState { dy -> + coroutineScope.launch { + with(state) { + val pos = pageSize * currentPageOffset + val max = if (currentPage == minPage) 0 else pageSize * offscreenLimit + val min = if (currentPage == maxPage) 0 else -pageSize * offscreenLimit + val newPos = (pos + dy).coerceIn(min.toFloat(), max.toFloat()) + snapToOffset(newPos / pageSize) + } + } + }, + ) + ) { measurables, constraints -> + layout(constraints.maxWidth, constraints.maxHeight) { + val currentPage = state.currentPage + val offset = state.currentPageOffset + val childConstraints = constraints.copy(minWidth = 0, minHeight = 0) + + measurables + .map { + it.measure(childConstraints) to it.page + } + .forEach { (placeable, page) -> + val xCenterOffset = (constraints.maxWidth - placeable.width) / 2 + val yCenterOffset = (constraints.maxHeight - placeable.height) / 2 + + if (currentPage == page) { + pageSize = placeable.width + } + + val xItemOffset = ((page + offset - currentPage) * placeable.width).roundToInt() + + placeable.place( + x = xCenterOffset + xItemOffset, + y = yCenterOffset + ) + } + } + } +} + +class PagerScope( + private val state: PagerState, + val page: Int +) { + val currentPage: Int + get() = state.currentPage + + val currentPageOffset: Float + get() = state.currentPageOffset + + val selectionState: PagerState.SelectionState + get() = state.selectionState +} diff --git a/jchucomponentscompose/src/main/java/com/jeluchu/jchucomponentscompose/ui/text/MarqueeText.kt b/jchucomponentscompose/src/main/java/com/jeluchu/jchucomponentscompose/ui/text/MarqueeText.kt new file mode 100644 index 00000000..9332491f --- /dev/null +++ b/jchucomponentscompose/src/main/java/com/jeluchu/jchucomponentscompose/ui/text/MarqueeText.kt @@ -0,0 +1,166 @@ +package com.jeluchu.jchucomponentscompose.ui.text + +import androidx.compose.animation.core.* +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.material.LocalTextStyle +import androidx.compose.material.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.Placeable +import androidx.compose.ui.layout.SubcomposeLayout +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.delay + +@Composable +fun MarqueeText( + text: String, + modifier: Modifier = Modifier, + gradientEdgeColor: Color = Color.White, + color: Color = Color.Unspecified, + fontSize: TextUnit = TextUnit.Unspecified, + fontStyle: FontStyle? = null, + fontWeight: FontWeight? = null, + fontFamily: FontFamily? = null, + letterSpacing: TextUnit = TextUnit.Unspecified, + textDecoration: TextDecoration? = null, + textAlign: TextAlign? = null, + lineHeight: TextUnit = TextUnit.Unspecified, + overflow: TextOverflow = TextOverflow.Clip, + softWrap: Boolean = true, + onTextLayout: (TextLayoutResult) -> Unit = {}, + style: TextStyle = LocalTextStyle.current, +) { + val createText = @Composable { localModifier: Modifier -> + Text( + text, + textAlign = textAlign, + modifier = localModifier, + color = color, + fontSize = fontSize, + fontStyle = fontStyle, + fontWeight = fontWeight, + fontFamily = fontFamily, + letterSpacing = letterSpacing, + textDecoration = textDecoration, + lineHeight = lineHeight, + overflow = overflow, + softWrap = softWrap, + maxLines = 1, + onTextLayout = onTextLayout, + style = style, + ) + } + var offset by remember { mutableStateOf(0) } + val textLayoutInfoState = remember { mutableStateOf(null) } + LaunchedEffect(textLayoutInfoState.value) { + val textLayoutInfo = textLayoutInfoState.value ?: return@LaunchedEffect + if (textLayoutInfo.textWidth <= textLayoutInfo.containerWidth) return@LaunchedEffect + val duration = 7500 * textLayoutInfo.textWidth / textLayoutInfo.containerWidth + val delay = 1000L + + do { + val animation = TargetBasedAnimation( + animationSpec = infiniteRepeatable( + animation = tween( + durationMillis = duration, + delayMillis = 1000, + easing = LinearEasing, + ), + repeatMode = RepeatMode.Restart + ), + typeConverter = Int.VectorConverter, + initialValue = 0, + targetValue = -textLayoutInfo.textWidth + ) + val startTime = withFrameNanos { it } + do { + val playTime = withFrameNanos { it } - startTime + offset = (animation.getValueFromNanos(playTime)) + } while (!animation.isFinishedFromNanos(playTime)) + delay(delay) + } while (true) + } + + SubcomposeLayout( + modifier = modifier.clipToBounds() + ) { constraints -> + val infiniteWidthConstraints = constraints.copy(maxWidth = Int.MAX_VALUE) + var mainText = subcompose(MarqueeLayers.MainText) { + createText(Modifier) + }.first().measure(infiniteWidthConstraints) + + var gradient: Placeable? = null + + var secondPlaceableWithOffset: Pair? = null + if (mainText.width <= constraints.maxWidth) { + mainText = subcompose(MarqueeLayers.SecondaryText) { + createText(Modifier.fillMaxWidth()) + }.first().measure(constraints) + textLayoutInfoState.value = null + } else { + val spacing = constraints.maxWidth * 2 / 3 + textLayoutInfoState.value = TextLayoutInfo( + textWidth = mainText.width + spacing, + containerWidth = constraints.maxWidth + ) + val secondTextOffset = mainText.width + offset + spacing + val secondTextSpace = constraints.maxWidth - secondTextOffset + if (secondTextSpace > 0) { + secondPlaceableWithOffset = subcompose(MarqueeLayers.SecondaryText) { + createText(Modifier) + }.first().measure(infiniteWidthConstraints) to secondTextOffset + } + gradient = subcompose(MarqueeLayers.EdgesGradient) { + Row { + GradientEdge(gradientEdgeColor, Color.Transparent) + Spacer(Modifier.weight(1f)) + GradientEdge(Color.Transparent, gradientEdgeColor) + } + }.first().measure(constraints.copy(maxHeight = mainText.height)) + } + + layout( + width = constraints.maxWidth, + height = mainText.height + ) { + mainText.place(offset, 0) + secondPlaceableWithOffset?.let { + it.first.place(it.second, 0) + } + gradient?.place(0, 0) + } + } +} + +@Composable +private fun GradientEdge( + startColor: Color, endColor: Color, +) { + Box( + modifier = Modifier + .width(10.dp) + .fillMaxHeight() + .background( + brush = Brush.horizontalGradient( + 0f to startColor, 1f to endColor, + ) + ) + ) +} + +private enum class MarqueeLayers { MainText, SecondaryText, EdgesGradient } +private data class TextLayoutInfo(val textWidth: Int, val containerWidth: Int) \ No newline at end of file diff --git a/jchucomponentscompose/src/main/java/com/jeluchu/jchucomponentscompose/utils/network/RetrofitClient.kt b/jchucomponentscompose/src/main/java/com/jeluchu/jchucomponentscompose/utils/network/RetrofitClient.kt new file mode 100644 index 00000000..9c7dd0d3 --- /dev/null +++ b/jchucomponentscompose/src/main/java/com/jeluchu/jchucomponentscompose/utils/network/RetrofitClient.kt @@ -0,0 +1,63 @@ +package com.jeluchu.jchucomponentscompose.utils.network + +import android.content.Context +import com.jeluchu.jchucomponentscompose.utils.network.interceptors.DebugInterceptor +import com.jeluchu.jchucomponentscompose.utils.network.interceptors.Interceptor +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import retrofit2.Converter +import retrofit2.Retrofit +import java.util.concurrent.TimeUnit + +object RetrofitClient { + + private var CONNECT_TIMEOUT = 90L + private var READ_TIMEOUT = 90L + private var WRITE_TIMEOUT = 90L + + fun buildRetrofit( + baseUrl: String, + converterFactory: Converter.Factory, + context: Context, + interceptor: Interceptor, + isDebug: Boolean + ): Retrofit { + + return Retrofit.Builder() + .baseUrl(baseUrl) + .client(createClientBuilder(context, interceptor, isDebug)) + .addConverterFactory(converterFactory) + .build() + } + + private fun createClientBuilder( + context: Context, + interceptor: Interceptor, + isDebug: Boolean + ): OkHttpClient { + + val builder = OkHttpClient.Builder() + + with(builder) { + + addInterceptor(interceptor) + + val httpLoggingInterceptor = HttpLoggingInterceptor(HttpLoggingInterceptor.Logger.DEFAULT) + httpLoggingInterceptor.level = HttpLoggingInterceptor.Level.BODY + + if (isDebug) { + addNetworkInterceptor(DebugInterceptor(context)) + addInterceptor(httpLoggingInterceptor) + } + + connectTimeout(CONNECT_TIMEOUT, TimeUnit.SECONDS) + writeTimeout(WRITE_TIMEOUT, TimeUnit.SECONDS) + readTimeout(READ_TIMEOUT, TimeUnit.SECONDS) + retryOnConnectionFailure(true) + + } + + return builder.build() + } + +} diff --git a/jchucomponentscompose/src/main/java/com/jeluchu/jchucomponentscompose/utils/network/interceptors/DebugInterceptor.kt b/jchucomponentscompose/src/main/java/com/jeluchu/jchucomponentscompose/utils/network/interceptors/DebugInterceptor.kt new file mode 100644 index 00000000..52861116 --- /dev/null +++ b/jchucomponentscompose/src/main/java/com/jeluchu/jchucomponentscompose/utils/network/interceptors/DebugInterceptor.kt @@ -0,0 +1,99 @@ +package com.jeluchu.jchucomponentscompose.utils.network.interceptors + +import android.content.Context +import com.jeluchu.jchucomponentscompose.core.extensions.strings.InputStreamToString +import okhttp3.Interceptor +import okhttp3.Response +import okhttp3.ResponseBody +import okio.Buffer +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.FileOutputStream +import java.io.IOException +import java.util.zip.GZIPInputStream +import java.util.zip.GZIPOutputStream + +class DebugInterceptor(val context: Context) : Interceptor { + + private val debugFile: FileOutputStream by lazy { + context.openFileOutput("netdebug", Context.MODE_PRIVATE or Context.MODE_APPEND) + } + + override fun intercept(chain: Interceptor.Chain): Response { + + val request = chain.request() + val requestBodyBuffer = Buffer() + val requestBody = requestBodyBuffer.readUtf8() + var response = chain.proceed(request) + val responseEncoding = response.header("Content-Encoding") + val isCompressResponse = responseEncoding != null && responseEncoding == "gzip" + + val method = request.method + val url = request.url + val headers = response.headers + val t1 = System.nanoTime() + val responseTime = (System.nanoTime() - t1) / 1e6 + val responseCode = response.code + + request.newBuilder().build().body?.writeTo(requestBodyBuffer) + + if (response.header("Content-Type")?.startsWith("application/json") == true) { + val responseBody = + if (isCompressResponse) response.body?.bytes() else response.body?.string() ?: "" + val responseContent = + if (isCompressResponse) decompress(responseBody as ByteArray) else responseBody as String + debugFile.bufferedWriter().append( + String.format( + "%s %s^%s^%s^%.3fms^%d^%s`", + method, + url, + headers, + requestBody, + responseTime, + responseCode, + responseContent + ) + ).flush() + + response = response.newBuilder() + .body(ResponseBody.create(response.body?.contentType(), if (!isCompressResponse) responseContent.toByteArray() else compress(responseContent))) + .build() + + } else { + debugFile.bufferedWriter().append( + String.format( + "%s %s^%s^%s^%.3fms^%d^%s`", + method, + url, + headers, + requestBody, + responseTime, + responseCode, + null + ) + ).flush() + } + + return response + + } + + @Throws(IOException::class) + fun decompress(compressed: ByteArray): String { + GZIPInputStream(ByteArrayInputStream(compressed)).use { gzipIn -> + return InputStreamToString().convertInputStreamToString(gzipIn) + } + } + + @Throws(IOException::class) + fun compress(string: String): ByteArray { + val os = ByteArrayOutputStream(string.length) + val gos = GZIPOutputStream(os) + gos.write(string.toByteArray()) + gos.close() + val compressed = os.toByteArray() + os.close() + return compressed + } + +} \ No newline at end of file diff --git a/jchucomponentscompose/src/main/java/com/jeluchu/jchucomponentscompose/utils/network/interceptors/Interceptor.kt b/jchucomponentscompose/src/main/java/com/jeluchu/jchucomponentscompose/utils/network/interceptors/Interceptor.kt new file mode 100644 index 00000000..d6c5738d --- /dev/null +++ b/jchucomponentscompose/src/main/java/com/jeluchu/jchucomponentscompose/utils/network/interceptors/Interceptor.kt @@ -0,0 +1,49 @@ +package com.jeluchu.jchucomponentscompose.utils.network.interceptors + +import android.os.Build +import okhttp3.Interceptor +import okhttp3.Response +import java.text.Normalizer +import java.util.* + +class Interceptor( + private val interceptorHeaders: InterceptorHeaders +) : Interceptor { + + override fun intercept(chain: Interceptor.Chain): Response { + + val builder= chain.request().newBuilder() + .addHeader("User-Agent", "${interceptorHeaders.userAgent.appName}/${interceptorHeaders.userAgent.versionName} (rv ${interceptorHeaders.userAgent.versionCode}) okhttp/${interceptorHeaders.userAgent.okhttpVersion}") + .addHeader("X-Client", "${interceptorHeaders.client}-android") + .addHeader("Accept", "application/json") + .addHeader("Accept-Language", Locale.getDefault().toString().replace("_", "-")) + .addHeader("X-Request-AppVersion", interceptorHeaders.userAgent.versionName) + .addHeader("X-Request-OsVersion", osVersion) + .addHeader("X-Request-Device", cleanInvalidCharacters(deviceName)) + .addHeader("X-Mobile-Native", "Android") + .addHeader("X-User-TimezoneOffset", Date().toString()) + if (interceptorHeaders.key.isNotEmpty()) { + builder.addHeader(interceptorHeaders.keyHeaderAgent, interceptorHeaders.key) + } + + return chain.proceed(builder.build()) + + } + + private fun cleanInvalidCharacters(`in`: String): String { + var subjectString = `in` + subjectString = Normalizer.normalize(subjectString, Normalizer.Form.NFD) + return subjectString.replace("[^\\x00-\\x7F]".toRegex(), "") + } + + private val deviceName: String + get() { + val manufacturer = Build.MANUFACTURER + val model = Build.MODEL + return if (model.startsWith(manufacturer)) model else "$manufacturer $model" + } + + private val osVersion: String + get() = "Android " + Build.VERSION.RELEASE + " (" + Build.VERSION.SDK_INT + ")" + +} \ No newline at end of file diff --git a/jchucomponentscompose/src/main/java/com/jeluchu/jchucomponentscompose/utils/network/interceptors/InterceptorHeaders.kt b/jchucomponentscompose/src/main/java/com/jeluchu/jchucomponentscompose/utils/network/interceptors/InterceptorHeaders.kt new file mode 100644 index 00000000..a2615709 --- /dev/null +++ b/jchucomponentscompose/src/main/java/com/jeluchu/jchucomponentscompose/utils/network/interceptors/InterceptorHeaders.kt @@ -0,0 +1,21 @@ +package com.jeluchu.jchucomponentscompose.utils.network.interceptors + +import com.jeluchu.jchucomponentscompose.core.extensions.strings.empty +import okhttp3.OkHttp + +data class InterceptorHeaders( + val userAgent: UserAgent, + val keyHeaderAgent: String = String.empty(), + val key: String = String.empty(), + val client: String +) { + + data class UserAgent( + val appName: String, + val versionName: String, + val versionCode: Int, + val okhttpVersion: String = OkHttp.VERSION + ) + +} +