├── .gitignore ├── README.md ├── app ├── .gitignore ├── build.gradle.kts ├── proguard-rules.pro ├── release │ ├── app-release-1.0.0.apk │ └── output-metadata.json └── src │ ├── androidTest │ └── java │ │ └── com │ │ └── paradoxo │ │ └── threadscompose │ │ └── ExampleInstrumentedTest.kt │ ├── main │ ├── AndroidManifest.xml │ ├── ic_launcher-playstore.png │ ├── java │ │ └── com │ │ │ └── paradoxo │ │ │ └── threadscompose │ │ │ ├── MainActivity.kt │ │ │ ├── model │ │ │ ├── Notification.kt │ │ │ ├── Post.kt │ │ │ └── UserAccount.kt │ │ │ ├── network │ │ │ └── firebase │ │ │ │ ├── MediaFirebaseStorage.kt │ │ │ │ ├── PostFirestore.kt │ │ │ │ └── UserFirestore.kt │ │ │ ├── sampleData │ │ │ └── SampleData.kt │ │ │ ├── ui │ │ │ ├── Components.kt │ │ │ ├── feed │ │ │ │ ├── FeedScreen.kt │ │ │ │ ├── FeedScreenState.kt │ │ │ │ └── FeedViewModel.kt │ │ │ ├── home │ │ │ │ ├── SessionState.kt │ │ │ │ └── SessionViewModel.kt │ │ │ ├── login │ │ │ │ ├── LoginScreen.kt │ │ │ │ ├── LoginScreenState.kt │ │ │ │ ├── LoginViewModel.kt │ │ │ │ └── SplashScreen.kt │ │ │ ├── navigation │ │ │ │ ├── Destinations.kt │ │ │ │ ├── ThreadsNavController.kt │ │ │ │ └── ThreadsNavHost.kt │ │ │ ├── notification │ │ │ │ ├── NotificationScreenState.kt │ │ │ │ └── NotificationsScreen.kt │ │ │ ├── post │ │ │ │ ├── PostScreen.kt │ │ │ │ └── PostScreenState.kt │ │ │ ├── profile │ │ │ │ ├── ProfileEditScreen.kt │ │ │ │ └── ProfileScreen.kt │ │ │ ├── search │ │ │ │ └── SearchScreen.kt │ │ │ └── theme │ │ │ │ ├── Color.kt │ │ │ │ ├── Theme.kt │ │ │ │ └── Type.kt │ │ │ └── utils │ │ │ ├── Extensions.kt │ │ │ └── Utils.kt │ └── res │ │ ├── drawable-v24 │ │ └── ic_launcher_foreground.xml │ │ ├── drawable │ │ ├── bg_lines_1.png │ │ ├── ic_attach_file.xml │ │ ├── ic_circle.xml │ │ ├── ic_comment.xml │ │ ├── ic_facebook.xml │ │ ├── ic_heart.xml │ │ ├── ic_heart_outlined.xml │ │ ├── ic_home.xml │ │ ├── ic_home_outlined.xml │ │ ├── ic_insta.xml │ │ ├── ic_launcher_background.xml │ │ ├── ic_logo_colors.xml │ │ ├── ic_menu_config.xml │ │ ├── ic_net.xml │ │ ├── ic_post.xml │ │ ├── ic_profile.xml │ │ ├── ic_profile_outlined.xml │ │ ├── ic_reply.xml │ │ ├── ic_repost.xml │ │ ├── ic_search.xml │ │ ├── ic_send.xml │ │ ├── placeholder_image.xml │ │ ├── profile_pic_emoji_1.xml │ │ ├── profile_pic_emoji_2.xml │ │ ├── profile_pic_emoji_3.xml │ │ └── profile_pic_emoji_4.xml │ │ ├── mipmap-anydpi-v26 │ │ ├── ic_launcher.xml │ │ └── ic_launcher_round.xml │ │ ├── mipmap-hdpi │ │ ├── ic_launcher.webp │ │ ├── ic_launcher_foreground.webp │ │ └── ic_launcher_round.webp │ │ ├── mipmap-mdpi │ │ ├── ic_launcher.webp │ │ ├── ic_launcher_foreground.webp │ │ └── ic_launcher_round.webp │ │ ├── mipmap-xhdpi │ │ ├── ic_launcher.webp │ │ ├── ic_launcher_foreground.webp │ │ └── ic_launcher_round.webp │ │ ├── mipmap-xxhdpi │ │ ├── ic_launcher.webp │ │ ├── ic_launcher_foreground.webp │ │ └── ic_launcher_round.webp │ │ ├── mipmap-xxxhdpi │ │ ├── ic_launcher.webp │ │ ├── ic_launcher_foreground.webp │ │ └── ic_launcher_round.webp │ │ ├── raw │ │ └── logo_lines_animated.json │ │ ├── values │ │ ├── colors.xml │ │ ├── ic_launcher_background.xml │ │ ├── strings.xml │ │ └── themes.xml │ │ └── xml │ │ ├── backup_rules.xml │ │ └── data_extraction_rules.xml │ └── test │ └── java │ └── com │ └── paradoxo │ └── threadscompose │ └── ExampleUnitTest.kt ├── build.gradle.kts ├── gradle.properties ├── gradle ├── libs.versions.toml └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat └── settings.gradle.kts /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | /.idea/caches 5 | /.idea/libraries 6 | /.idea/modules.xml 7 | /.idea/workspace.xml 8 | /.idea/navEditor.xml 9 | /.idea/assetWizardSettings.xml 10 | .DS_Store 11 | /build 12 | /captures 13 | .externalNativeBuild 14 | .cxx 15 | local.properties 16 | /.idea/ 17 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![banner_lines_github](https://github.com/git-jr/Threads-Jetpack-Compose/assets/35709152/7c6b3484-e87a-4f20-ad93-14239a88927c) 2 | 3 | **Lines** é um aplicativo desenvolvido com propósito de se parecer ao máximo com o app [**Threads**][threads-net] da [Meta][meta], feito em Jetpack Composse assim como o original esse projeto também teve o objetivo de ser feito no menor tempo possível, em breve o [resultdo saí aqui][video-recriando-threads] 4 | 5 | 💻 As seguintes tecnologias estão em uso no momento: 6 | - [Jetpack Compose][compose] - Interface de usuário 7 | - [Facebook API][login-facebook] - Sistema de Login 8 | - [Firebase Auth][firebase-auth] - Integração com a API de autenticação do Facebook 9 | - [Firebase Firestore][firebase-firestore] - Banco de dado online 10 | - [Firebase Storage][firebase-storage] - Armazenamento de imagens que podem ser enviadas pelo app 11 | - [LottieFiles][lottie] - Animações controladas usando a API oficial do Airbnb 12 | - [Coil][coil] - Carregamento de imagens 13 | - [Jetpack Compose Animations][compose-animations] - Pequenas animações e transições de elementos de layout 14 | 15 | 📱 As seguintes funções estão disponíveis no momento: 16 | - Login com Facebook, permitindo trocar algumas informações pessoais do perfil. 17 | - Publicar posts únicos e claro as **Threads** 18 | - Visualizar posts únicos feito por outros usuários 19 | - Interagir com animações de movimento feitas através do Jetpack Compose e da API Lottie 20 | - Explorar a telas Feed, Busca, Post, Notificações e Perfil. 21 | 22 | ## 🎨 Previews 23 | preview_5 24 | preview_6 25 | preview_2 26 | preview_3 27 | preview_4 28 | preview_1 29 | 30 | 31 | 32 | 33 | ## 🏃‍♂️ Algumas animações 34 | https://github.com/git-jr/Threads-Jetpack-Compose/assets/35709152/61c577a5-5b91-40c4-8999-962f793dffb5 35 | 36 | 37 | 38 | ## 📲 Testar o app 39 | Aviso: A versão atual deste projeto foi desenvolvida com o objetivo de criar, no menor tempo possível, a versão mais próxima do Threads. Você pode conferir o resultado desse desafio em breve [neste vídeo][video-recriando-threads], então ainda tem muita coisa pra ajustar 😉 40 | 41 | Vá até [Releases][releases], baixe o arquivo APK da última versão disponível e escolha a forma login: 42 | > Como convidado: Não precisa digitar nenhuma credencial, seu perfil dentro do app será gerado aleatoriamente com dados de teste, os posts de outros usuários não serão exibidos. 43 | 44 | > Com o Facebook: Você verá posts de outros usuários reais do app. Uma mensagem de "Permissões ainda não verificadas pelo Facebook" pode aparecer no início, mas não se preocupe. Este é um alerta padrão, pois o app ainda não foi revisado pela equipe do Facebook ainda. Você pode prosseguir com segurança. 45 | 46 | 47 | 48 | 💻 Como rodar o projeto 49 | Esse projeto precisa de 2 arquivos principais para ser compilado corremente no Android Studio: 50 | 51 | 1. `google-services.json`, arquivo de configuração do Firebase 52 | - Você pode aprender como gerar um [através da documentação oficial][tutorial-firebase] 53 | - Adicione o arquivo gerado dentro da pasta app: 54 | 55 | localizacao-google-services 56 | 57 | 58 | 2. `local.properties`, esse arquivo é gerado automaticamente pelo Android Studio, dentro será necessário adicionar 3 linhas de código para identificar o app perante a API de Login do Facebook 59 | - Na [documentação oficial do Facebook][tutorial-facebook-login-api], você encontrará instruções para criar `facebookAppId`, `fbLoginProtocolScheme` e `facebookClientToken`. Depois de obtê-los, adicione cada um desses valores ao arquivo de propriedades em linhas separadas e referenciando seus nomes. 60 | 61 | exemplo-arquivo-local-properties 62 | 63 | ## 😎 Gostou do app? 64 | Clica ali na estrela ⭐ do topo para dar aquela força! 65 | 66 | [compose]: https://developer.android.com/jetpack/compose 67 | [threads-net]: https://www.threads.net/ 68 | [meta]: https://about.meta.com/ 69 | [login-facebook]: https://developers.facebook.com/docs/facebook-login 70 | [lottie]: https://airbnb.io/lottie 71 | 72 | [firebase-auth]: https://firebase.google.com/docs/auth 73 | [firebase-storage]: https://firebase.google.com/docs/storage 74 | [firebase-firestore]: https://firebase.google.com/docs/firestore 75 | 76 | [coil]: https://coil-kt.github.io/coil/compose/ 77 | [compose-animations]: https://developer.android.com/jetpack/compose/animation 78 | 79 | [releases]:https://github.com/git-jr/Threads-Jetpack-Compose/releases 80 | 81 | [tutorial-firebase]: https://firebase.google.com/docs/android/setup?hl=pt-br#create-firebase-project 82 | [tutorial-facebook-login-api]: https://developers.facebook.com/docs/facebook-login/android 83 | 84 | [video-recriando-threads]: https://youtu.be/Kr4Kn0ewnIw 85 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | google-services.json -------------------------------------------------------------------------------- /app/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import java.io.FileInputStream 2 | import java.util.Properties 3 | 4 | @Suppress("DSL_SCOPE_VIOLATION") // TODO: Remove once KTIJ-19369 is fixed 5 | plugins { 6 | alias(libs.plugins.androidApplication) 7 | alias(libs.plugins.kotlinAndroid) 8 | alias(libs.plugins.googleServices) 9 | } 10 | 11 | val properties = Properties().apply { 12 | FileInputStream(rootProject.file("local.properties")).use { load(it) } 13 | } 14 | 15 | android { 16 | namespace = "com.paradoxo.threadscompose" 17 | compileSdk = 34 18 | 19 | defaultConfig { 20 | applicationId = "com.paradoxo.threadscompose" 21 | minSdk = 21 22 | targetSdk = 34 23 | versionCode = 1 24 | versionName = "1.0.0" 25 | 26 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" 27 | vectorDrawables { 28 | useSupportLibrary = true 29 | } 30 | 31 | // To generate a new facebook app id, go to https://developers.facebook.com/apps/ 32 | // and create a new app. Then, go to Settings > Basic and copy the App ID. 33 | // Finally, paste the App ID in the facebookAppId on file local.properties 34 | val facebookAppId = properties.getProperty("facebookAppId") ?: "INSIRA O ID DO SEU APP AQUI" 35 | resValue("string", "facebook_app_id", facebookAppId) 36 | 37 | val facebookClientToken = properties.getProperty("facebookClientToken") ?: "INSIRA O TOKEN DO SEU APP AQUI" 38 | resValue("string", "facebook_client_token", facebookClientToken) 39 | 40 | val fbLoginProtocolScheme = "fb${facebookAppId}" 41 | resValue("string", "fb_login_protocol_scheme", fbLoginProtocolScheme) 42 | 43 | } 44 | 45 | buildTypes { 46 | release { 47 | isMinifyEnabled = false 48 | proguardFiles( 49 | getDefaultProguardFile("proguard-android-optimize.txt"), 50 | "proguard-rules.pro" 51 | ) 52 | } 53 | } 54 | compileOptions { 55 | isCoreLibraryDesugaringEnabled = true 56 | 57 | sourceCompatibility = JavaVersion.VERSION_1_8 58 | targetCompatibility = JavaVersion.VERSION_1_8 59 | } 60 | kotlinOptions { 61 | jvmTarget = "1.8" 62 | } 63 | buildFeatures { 64 | compose = true 65 | } 66 | composeOptions { 67 | kotlinCompilerExtensionVersion = "1.4.3" 68 | } 69 | packaging { 70 | resources { 71 | excludes += "/META-INF/{AL2.0,LGPL2.1}" 72 | } 73 | } 74 | } 75 | 76 | dependencies { 77 | 78 | implementation (libs.lottie.compose) 79 | implementation(libs.androidx.foundation) 80 | 81 | implementation(platform(libs.firebase.bom)) 82 | implementation(libs.coil.compose) 83 | implementation(libs.google.services) 84 | implementation (libs.facebook.android.sdk) 85 | implementation(libs.firebase.auth.ktx) 86 | implementation(libs.firebase.storage.ktx) 87 | 88 | 89 | implementation(libs.lifecycle.viewmodel) 90 | implementation(libs.navigation.compose) 91 | implementation(libs.firebase.firestore.ktx) 92 | implementation(libs.play.services.auth) 93 | 94 | coreLibraryDesugaring(libs.desugar.jdk.libs) 95 | 96 | implementation(libs.core.ktx) 97 | implementation(libs.lifecycle.runtime.ktx) 98 | implementation(libs.activity.compose) 99 | implementation(platform(libs.compose.bom)) 100 | implementation(libs.ui) 101 | implementation(libs.ui.graphics) 102 | implementation(libs.ui.tooling.preview) 103 | implementation(libs.material3) 104 | testImplementation(libs.junit) 105 | androidTestImplementation(libs.androidx.test.ext.junit) 106 | androidTestImplementation(libs.espresso.core) 107 | androidTestImplementation(platform(libs.compose.bom)) 108 | androidTestImplementation(libs.ui.test.junit4) 109 | debugImplementation(libs.ui.tooling) 110 | debugImplementation(libs.ui.test.manifest) 111 | } -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile -------------------------------------------------------------------------------- /app/release/app-release-1.0.0.apk: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/git-jr/Threads-Jetpack-Compose/6d5ee19ce593bdcdecda8eb4b4dd1252da50bed4/app/release/app-release-1.0.0.apk -------------------------------------------------------------------------------- /app/release/output-metadata.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 3, 3 | "artifactType": { 4 | "type": "APK", 5 | "kind": "Directory" 6 | }, 7 | "applicationId": "com.paradoxo.threadscompose", 8 | "variantName": "release", 9 | "elements": [ 10 | { 11 | "type": "SINGLE", 12 | "filters": [], 13 | "attributes": [], 14 | "versionCode": 1, 15 | "versionName": "1.0.0", 16 | "outputFile": "app-release.apk" 17 | } 18 | ], 19 | "elementType": "File" 20 | } -------------------------------------------------------------------------------- /app/src/androidTest/java/com/paradoxo/threadscompose/ExampleInstrumentedTest.kt: -------------------------------------------------------------------------------- 1 | package com.paradoxo.threadscompose 2 | 3 | import androidx.test.platform.app.InstrumentationRegistry 4 | import androidx.test.ext.junit.runners.AndroidJUnit4 5 | 6 | import org.junit.Test 7 | import org.junit.runner.RunWith 8 | 9 | import org.junit.Assert.* 10 | 11 | /** 12 | * Instrumented test, which will execute on an Android device. 13 | * 14 | * See [testing documentation](http://d.android.com/tools/testing). 15 | */ 16 | @RunWith(AndroidJUnit4::class) 17 | class ExampleInstrumentedTest { 18 | @Test 19 | fun useAppContext() { 20 | // Context of the app under test. 21 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext 22 | assertEquals("com.paradoxo.threadscompose", appContext.packageName) 23 | } 24 | } -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | 8 | 18 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 34 | 37 | 38 | 39 | 42 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | -------------------------------------------------------------------------------- /app/src/main/ic_launcher-playstore.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/git-jr/Threads-Jetpack-Compose/6d5ee19ce593bdcdecda8eb4b4dd1252da50bed4/app/src/main/ic_launcher-playstore.png -------------------------------------------------------------------------------- /app/src/main/java/com/paradoxo/threadscompose/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package com.paradoxo.threadscompose 2 | 3 | import android.content.Intent 4 | import android.net.Uri 5 | import android.os.Bundle 6 | import androidx.activity.ComponentActivity 7 | import androidx.activity.compose.setContent 8 | import androidx.compose.foundation.layout.Arrangement 9 | import androidx.compose.foundation.layout.Box 10 | import androidx.compose.foundation.layout.Column 11 | import androidx.compose.foundation.layout.PaddingValues 12 | import androidx.compose.foundation.layout.fillMaxSize 13 | import androidx.compose.material3.Icon 14 | import androidx.compose.material3.NavigationBar 15 | import androidx.compose.material3.NavigationBarItem 16 | import androidx.compose.material3.NavigationBarItemDefaults 17 | import androidx.compose.material3.Scaffold 18 | import androidx.compose.runtime.Composable 19 | import androidx.compose.runtime.LaunchedEffect 20 | import androidx.compose.runtime.getValue 21 | import androidx.compose.runtime.mutableStateOf 22 | import androidx.compose.runtime.remember 23 | import androidx.compose.runtime.setValue 24 | import androidx.compose.ui.Alignment 25 | import androidx.compose.ui.Modifier 26 | import androidx.compose.ui.draw.alpha 27 | import androidx.compose.ui.graphics.Color 28 | import androidx.compose.ui.res.painterResource 29 | import androidx.core.view.WindowCompat 30 | import androidx.navigation.NavDestination 31 | import androidx.navigation.NavDestination.Companion.hierarchy 32 | import androidx.navigation.NavGraph.Companion.findStartDestination 33 | import androidx.navigation.NavHostController 34 | import androidx.navigation.compose.currentBackStackEntryAsState 35 | import androidx.navigation.compose.rememberNavController 36 | import com.paradoxo.threadscompose.ui.navigation.Destinations 37 | import com.paradoxo.threadscompose.ui.navigation.ThreadsNavHost 38 | import com.paradoxo.threadscompose.ui.navigation.screenItems 39 | import com.paradoxo.threadscompose.ui.theme.ThreadsComposeTheme 40 | 41 | class MainActivity : ComponentActivity() { 42 | private val testMode = false 43 | 44 | override fun onCreate(savedInstanceState: Bundle?) { 45 | super.onCreate(savedInstanceState) 46 | WindowCompat.setDecorFitsSystemWindows(window, false) 47 | 48 | setContent { 49 | if (testMode) { 50 | Box( 51 | Modifier.fillMaxSize(), 52 | contentAlignment = Alignment.Center 53 | ) {} 54 | } else { 55 | ThreadsComposeTheme { 56 | Box( 57 | Modifier.fillMaxSize() 58 | ) { 59 | val navController: NavHostController = rememberNavController() 60 | 61 | var showNavigationBar by remember { mutableStateOf(false) } 62 | val navBackStackEntry by navController.currentBackStackEntryAsState() 63 | val currentDestination = navBackStackEntry?.destination 64 | 65 | val destinysWithoutNavigationBar by remember { 66 | mutableStateOf( 67 | listOf( 68 | Destinations.Login.route, 69 | Destinations.ProfileEdit.route, 70 | Destinations.Post.route, 71 | ) 72 | ) 73 | } 74 | 75 | LaunchedEffect(currentDestination) { 76 | showNavigationBar = 77 | !destinysWithoutNavigationBar.contains(currentDestination?.route) 78 | } 79 | 80 | ThreadsApp( 81 | navController = navController, 82 | showNavigationBar = showNavigationBar, 83 | currentDestination = currentDestination, 84 | content = { 85 | ThreadsNavHost( 86 | navController = navController, 87 | navigateToInstagram = { 88 | val instagramIntent = Intent( 89 | Intent.ACTION_VIEW, 90 | Uri.parse("https://www.instagram.com/threadsapp/") 91 | ) 92 | startActivity(instagramIntent) 93 | } 94 | ) 95 | } 96 | ) 97 | } 98 | } 99 | } 100 | 101 | } 102 | } 103 | 104 | @Composable 105 | fun ThreadsApp( 106 | content: @Composable (PaddingValues) -> Unit, 107 | navController: NavHostController, 108 | showNavigationBar: Boolean, 109 | currentDestination: NavDestination? 110 | ) { 111 | Scaffold( 112 | bottomBar = { 113 | if (showNavigationBar) { 114 | NavigationBar( 115 | containerColor = Color.White, 116 | ) { 117 | screenItems.forEach { screen -> 118 | val isSelected = 119 | currentDestination?.hierarchy?.any { it.route == screen.route } == true 120 | 121 | NavigationBarItem( 122 | icon = { 123 | screen.resourceId?.let { assetIcon -> 124 | val icon = painterResource( 125 | id = if (isSelected) { 126 | assetIcon.first 127 | } else { 128 | assetIcon.second 129 | }, 130 | ) 131 | 132 | 133 | Icon( 134 | icon, 135 | contentDescription = null, 136 | modifier = Modifier 137 | .alpha( 138 | if (isSelected) 1f else 0.5f 139 | ) 140 | ) 141 | } 142 | }, 143 | selected = isSelected, 144 | onClick = { 145 | if (screen.route == Destinations.Post.route) { 146 | navController.navigate(screen.route) { 147 | launchSingleTop = true 148 | } 149 | } else { 150 | navController.navigate(screen.route) { 151 | popUpTo(navController.graph.findStartDestination().id) { 152 | saveState = true 153 | } 154 | 155 | launchSingleTop = true 156 | restoreState = true 157 | } 158 | } 159 | }, 160 | colors = NavigationBarItemDefaults.colors( 161 | indicatorColor = Color.White 162 | ) 163 | ) 164 | } 165 | } 166 | } 167 | } 168 | ) { paddingValues -> 169 | Column( 170 | Modifier 171 | .fillMaxSize(), 172 | horizontalAlignment = Alignment.CenterHorizontally, 173 | verticalArrangement = Arrangement.Center 174 | ) { 175 | Box(Modifier.fillMaxSize()) { 176 | content(paddingValues) 177 | } 178 | } 179 | } 180 | } 181 | 182 | 183 | } -------------------------------------------------------------------------------- /app/src/main/java/com/paradoxo/threadscompose/model/Notification.kt: -------------------------------------------------------------------------------- 1 | package com.paradoxo.threadscompose.model 2 | 3 | data class Notification( 4 | val id: String = "", 5 | val title: String = "", 6 | val description: String = "", 7 | val image: Int = 0, 8 | val time: String = "", 9 | val extraContent: String? = null, 10 | val type: NotificationTypeEnum = NotificationTypeEnum.Follow, 11 | var isFollowing: Boolean = false, 12 | ) 13 | 14 | enum class NotificationTypeEnum { 15 | All, 16 | Comment, 17 | Mention, 18 | Follow, 19 | Like, 20 | Verified, 21 | } 22 | -------------------------------------------------------------------------------- /app/src/main/java/com/paradoxo/threadscompose/model/Post.kt: -------------------------------------------------------------------------------- 1 | package com.paradoxo.threadscompose.model 2 | 3 | data class Post( 4 | val id: String = "", 5 | val mainPost: Boolean = false, 6 | val userAccount: UserAccount = UserAccount(), 7 | val description: String = "", 8 | val date: Long = 0L, 9 | val medias: List = emptyList(), 10 | val likes: List = emptyList(), 11 | val comments: List = emptyList() 12 | ) 13 | 14 | data class Like( 15 | val id: String = "", 16 | val profilePicAuthor: String = "", 17 | ) 18 | 19 | data class Comment( 20 | val id: String = "", 21 | val profilePicAuthor: String = "", 22 | ) 23 | -------------------------------------------------------------------------------- /app/src/main/java/com/paradoxo/threadscompose/model/UserAccount.kt: -------------------------------------------------------------------------------- 1 | package com.paradoxo.threadscompose.model 2 | 3 | data class UserAccount( 4 | val id: String = "", 5 | val name: String = "", 6 | val userName: String = "", 7 | val bio: String = "", 8 | val link: String = "", 9 | val imageProfileUrl: String = "", 10 | val posts:List = emptyList(), 11 | val follows:List = emptyList(), 12 | val followers:List = emptyList() 13 | ) 14 | 15 | -------------------------------------------------------------------------------- /app/src/main/java/com/paradoxo/threadscompose/network/firebase/MediaFirebaseStorage.kt: -------------------------------------------------------------------------------- 1 | package com.paradoxo.threadscompose.network.firebase 2 | 3 | import android.net.Uri 4 | import android.util.Log 5 | import com.google.firebase.auth.ktx.auth 6 | import com.google.firebase.ktx.Firebase 7 | import com.google.firebase.storage.ktx.storage 8 | import kotlinx.coroutines.Dispatchers 9 | import kotlinx.coroutines.async 10 | import kotlinx.coroutines.awaitAll 11 | import kotlinx.coroutines.coroutineScope 12 | import kotlinx.coroutines.tasks.await 13 | import kotlinx.coroutines.withContext 14 | import java.util.UUID 15 | 16 | class MediaFirebaseStorage { 17 | private val idCurrentUser = Firebase.auth.currentUser?.uid 18 | private val storage = Firebase.storage 19 | private val storageRef = storage.reference 20 | private val ref = storageRef.child("users/$idCurrentUser/medias/posts/") 21 | 22 | suspend fun uploadMedia( 23 | medias: List, 24 | onSuccess: () -> Unit = {}, 25 | onError: () -> Unit = {}, 26 | ): List = coroutineScope { 27 | val dispatcher = Dispatchers.IO 28 | 29 | withContext(dispatcher) { 30 | val stackTasks = medias.map { imageUri -> 31 | async { 32 | val uuid = UUID.randomUUID().toString() 33 | val currentRef = ref.child(uuid) 34 | val file = Uri.parse(imageUri) 35 | val uploadTask = currentRef.putFile(file) 36 | 37 | uploadTask.await() 38 | val downloadUrl = currentRef.downloadUrl.await().toString() 39 | 40 | Log.i("imageUpload", "imageUpload link: $downloadUrl") 41 | 42 | downloadUrl 43 | } 44 | } 45 | try { 46 | val downloadUrls = stackTasks.awaitAll() 47 | withContext(Dispatchers.Main) { 48 | onSuccess() 49 | } 50 | downloadUrls 51 | } catch (e: Exception) { 52 | withContext(Dispatchers.Main) { 53 | onError() 54 | } 55 | Log.i("imageUpload", "imageUpload error: ${e.message}") 56 | emptyList() 57 | } 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /app/src/main/java/com/paradoxo/threadscompose/network/firebase/PostFirestore.kt: -------------------------------------------------------------------------------- 1 | package com.paradoxo.threadscompose.network.firebase 2 | 3 | import android.util.Log 4 | import com.google.firebase.firestore.FirebaseFirestore 5 | import com.google.firebase.firestore.Query 6 | import com.paradoxo.threadscompose.model.Comment 7 | import com.paradoxo.threadscompose.model.Post 8 | import kotlinx.coroutines.CoroutineScope 9 | import kotlinx.coroutines.Dispatchers.IO 10 | import kotlinx.coroutines.launch 11 | import java.util.UUID 12 | 13 | class PostFirestore { 14 | private val firebaseFirestore = FirebaseFirestore.getInstance() 15 | private val dbPosts = firebaseFirestore.collection("posts") 16 | 17 | private val mediaFirebaseStorage = MediaFirebaseStorage() 18 | 19 | fun getAllPosts( 20 | onSuccess: (List) -> Unit = {}, 21 | onError: () -> Unit = {}, 22 | ) { 23 | dbPosts 24 | .whereEqualTo("mainPost", true) 25 | .orderBy("date", Query.Direction.DESCENDING) 26 | .get() 27 | .addOnSuccessListener { 28 | val posts = it.toObjects(Post::class.java) 29 | onSuccess(posts) 30 | Log.i("getAllPosts", "Posts obtidos com sucesso ${posts.size}") 31 | } 32 | .addOnFailureListener { 33 | onError() 34 | Log.i("getAllPosts", "Erro ao obter posts ${it.message}") 35 | } 36 | } 37 | 38 | fun savePost( 39 | posts: List, 40 | onSuccess: () -> Unit = {}, 41 | onError: () -> Unit = {}, 42 | ) { 43 | if (posts.size > 1) { 44 | savePostThread(posts, onSuccess, onError) 45 | } else { 46 | 47 | CoroutineScope(IO).launch { 48 | val post = posts.first().copy( 49 | id = UUID.randomUUID().toString(), 50 | mainPost = true, 51 | medias = mediaFirebaseStorage.uploadMedia(posts.first().medias) 52 | ) 53 | dbPosts.document() 54 | .set(post) 55 | .addOnSuccessListener { 56 | onSuccess() 57 | Log.i("savePost", "Post salvo com sucesso") 58 | } 59 | .addOnFailureListener { 60 | onError() 61 | Log.i("savePost", "Erro ao salvar post ${it.message}") 62 | } 63 | } 64 | } 65 | } 66 | 67 | private fun savePostThread( 68 | posts: List, 69 | onSuccess: () -> Unit, 70 | onError: () -> Unit 71 | ) { 72 | val batch = firebaseFirestore.batch() 73 | 74 | val postsInThreadFormat: List = generatePostsInThreadFormat(posts) 75 | 76 | CoroutineScope(IO).launch { 77 | try { 78 | postsInThreadFormat.forEach { post -> 79 | val urls = mediaFirebaseStorage.uploadMedia(post.medias) 80 | val newPost = post.copy(medias = urls) 81 | val documentReference = dbPosts.document() 82 | batch.set(documentReference, newPost) 83 | } 84 | 85 | batch.commit() 86 | .addOnSuccessListener { 87 | onSuccess() 88 | Log.i("savePostThread", "Thread salvo com sucesso") 89 | } 90 | .addOnFailureListener { 91 | onError() 92 | Log.i("savePostThread", "Erro ao salvar Thread ${it.message}") 93 | } 94 | } catch (e: Exception) { 95 | Log.e("savePostThread", "Erro ao salvar Thread ${e.message}") 96 | } 97 | } 98 | } 99 | 100 | private fun generatePostsInThreadFormat(posts: List): List { 101 | val postsInThreadFormat = mutableListOf() 102 | val randomIdList: List = mutableListOf().apply { 103 | repeat(posts.size) { 104 | add(UUID.randomUUID().toString()) 105 | } 106 | } 107 | 108 | posts.forEachIndexed { index, post -> 109 | 110 | val commentList = mutableListOf() 111 | randomIdList.subList(index.plus(1), posts.size).forEach { id -> 112 | commentList.add( 113 | Comment( 114 | id = id, 115 | profilePicAuthor = post.userAccount.imageProfileUrl, 116 | ) 117 | ) 118 | } 119 | 120 | postsInThreadFormat.add( 121 | post.copy( 122 | id = randomIdList[index], 123 | mainPost = index == 0, 124 | comments = commentList, 125 | likes = emptyList() 126 | ) 127 | ) 128 | } 129 | 130 | return postsInThreadFormat 131 | } 132 | } -------------------------------------------------------------------------------- /app/src/main/java/com/paradoxo/threadscompose/network/firebase/UserFirestore.kt: -------------------------------------------------------------------------------- 1 | package com.paradoxo.threadscompose.network.firebase 2 | 3 | import android.util.Log 4 | import com.google.firebase.firestore.FirebaseFirestore 5 | import com.paradoxo.threadscompose.model.UserAccount 6 | 7 | class UserFirestore { 8 | private val firebaseFirestore = FirebaseFirestore.getInstance() 9 | private val dbUsers = firebaseFirestore.collection("users") 10 | 11 | 12 | fun getUserById( 13 | userId: String, 14 | onSuccess: (UserAccount) -> Unit = {}, 15 | onError: () -> Unit = {}, 16 | ) { 17 | dbUsers.document(userId) 18 | .get() 19 | .addOnSuccessListener { 20 | it.toObject(UserAccount::class.java)?.let { userAccount -> 21 | onSuccess(userAccount) 22 | Log.i("getUserById", "Usuário obtido com sucesso ${userAccount.name}") 23 | } ?: run { 24 | onError() 25 | Log.i("getUserById", "Usuário não encontrado") 26 | } 27 | 28 | }.addOnFailureListener { 29 | Log.i("getUserById", "Erro ao obter usuário ${it.message}") 30 | } 31 | } 32 | 33 | 34 | fun saveUser( 35 | userId: String, 36 | userAccount: UserAccount, 37 | onSuccess: () -> Unit = {}, 38 | onError: () -> Unit = {}, 39 | ) { 40 | dbUsers.document(userId) 41 | .set(userAccount) 42 | .addOnSuccessListener { 43 | onSuccess() 44 | Log.i("saveUser", "Usuário salvo com sucesso") 45 | } 46 | .addOnFailureListener { 47 | onError() 48 | Log.i("saveUser", "Erro ao salvar usuário ${it.message}") 49 | } 50 | } 51 | } -------------------------------------------------------------------------------- /app/src/main/java/com/paradoxo/threadscompose/sampleData/SampleData.kt: -------------------------------------------------------------------------------- 1 | package com.paradoxo.threadscompose.sampleData 2 | 3 | import com.paradoxo.threadscompose.R 4 | import com.paradoxo.threadscompose.model.Comment 5 | import com.paradoxo.threadscompose.model.Like 6 | import com.paradoxo.threadscompose.model.Notification 7 | import com.paradoxo.threadscompose.model.NotificationTypeEnum 8 | import com.paradoxo.threadscompose.model.Post 9 | import com.paradoxo.threadscompose.model.UserAccount 10 | import com.paradoxo.threadscompose.utils.getCurrentTime 11 | import java.util.UUID 12 | 13 | import kotlin.random.Random 14 | 15 | class SampleData { 16 | val notifications = mutableListOf() 17 | val posts = mutableListOf() 18 | val userAccounts = mutableListOf() 19 | private val comments = mutableListOf() 20 | 21 | private val descriptions = listOf( 22 | "Viver o momento é a verdadeira essência da vida!", 23 | "Tirando um tempo para admirar as pequenas coisas da vida.", 24 | "A melhor parte da vida são as pessoas que conhecemos ao longo do caminho.", 25 | "A natureza sempre veste as cores do espírito.", 26 | "Acredite em si mesmo e tudo é possível.", 27 | "Sonhe grande e ouse falhar.", 28 | "Desfrutando de alguns dos meus momentos favoritos.", 29 | "Faça o que você ama, ame o que você faz.", 30 | "É sempre melhor quando estamos juntos.", 31 | "Inspire-se, mas seja você mesmo!" 32 | ) 33 | private val bios = listOf( 34 | "Apaixonado por viagens e fotografia.", 35 | "Viciado em café e aventuras.", 36 | "Admirador da beleza da natureza.", 37 | "Colecionador de momentos, não de coisas.", 38 | "Um sonhador que se recusa a acordar.", 39 | "Vivendo um dia de cada vez.", 40 | "Amante de todas as coisas belas e extraordinárias.", 41 | "Em busca de inspiração e felicidade.", 42 | "Buscando a magia em cada dia.", 43 | "Vivendo a vida ao máximo!" 44 | ) 45 | val images = listOf( 46 | "https://raw.githubusercontent.com/git-jr/sample-files/7bc859dfa8a6241fa9c0d723ba6e7517bdfedd50/profile%20pics/profile_pic_emoji_1.png", 47 | "https://raw.githubusercontent.com/git-jr/sample-files/7bc859dfa8a6241fa9c0d723ba6e7517bdfedd50/profile%20pics/profile_pic_emoji_2.png", 48 | "https://raw.githubusercontent.com/git-jr/sample-files/7bc859dfa8a6241fa9c0d723ba6e7517bdfedd50/profile%20pics/profile_pic_emoji_3.png", 49 | "https://raw.githubusercontent.com/git-jr/sample-files/7bc859dfa8a6241fa9c0d723ba6e7517bdfedd50/profile%20pics/profile_pic_emoji_4.png", 50 | "https://raw.githubusercontent.com/git-jr/sample-files/7bc859dfa8a6241fa9c0d723ba6e7517bdfedd50/profile%20pics/profile_pic_emoji_5.png", 51 | ) 52 | 53 | val names = listOf( 54 | "Sheldon Cooper", 55 | "Eleanor Shellstrop", 56 | "Sherlock Holmes", 57 | "John Watson", 58 | "Walter White", 59 | "Jesse Pinkman", 60 | "Daenerys Targaryen", 61 | "Jon Snow", 62 | "Michael Scott", 63 | "Chrisjen Avasarala", 64 | "Tyrion Lannister", 65 | "Arya Stark", 66 | "Tommy Shelby", 67 | "Gol D. Roger", 68 | "Jake Peralta", 69 | "Amy Santiago", 70 | "Chloe Morningstar", 71 | "Barry allen", 72 | "David Mailer", 73 | "Elizabeth Keen" 74 | ) 75 | 76 | init { 77 | val randomNumbers = mutableListOf() 78 | repeat(1000) { 79 | randomNumbers.add(it.toLong()) 80 | } 81 | 82 | for (i in 1..10) { 83 | val userAccount = UserAccount( 84 | id = i.toString(), 85 | name = names[Random.nextInt(0, names.size)], 86 | userName = "usuario$i", 87 | bio = bios[i - 1], 88 | imageProfileUrl = images.random(), 89 | posts = randomNumbers.subList(0, Random.nextInt(1, 1000)), 90 | follows = randomNumbers.subList(0, Random.nextInt(1, 1000)), 91 | followers = randomNumbers.subList(0, Random.nextInt(1, 1000)), 92 | ) 93 | userAccounts.add(userAccount) 94 | 95 | val post = Post( 96 | id = i.toString(), 97 | userAccount = userAccount, 98 | description = descriptions[i - 1], 99 | date = getCurrentTime(), 100 | medias = if (Random.nextBoolean()) images.shuffled() else listOf(), 101 | likes = generateListSamplesLikes(), 102 | comments = generateSampleComments() 103 | ) 104 | posts.add(post) 105 | } 106 | 107 | for (i in 1..10) { 108 | userAccounts[i - 1] = 109 | userAccounts[i - 1].copy(posts = posts.filter { it.userAccount.id == i.toString() } 110 | .map { it.id.toLong() }) 111 | } 112 | 113 | repeat(10) { 114 | notifications.add( 115 | Notification( 116 | id = UUID.randomUUID().toString(), 117 | title = "Titulo $it", 118 | description = "Descrição $it", 119 | extraContent = if (Random.nextBoolean()) "Conteúdo extra $it" else null, 120 | image = R.drawable.profile_pic_emoji_4, 121 | time = "1d", 122 | type = if (it in 2..4) NotificationTypeEnum.values().sortedArray()[it] else 123 | NotificationTypeEnum.values().sortedArray()[Random.nextInt(0, 5)], 124 | isFollowing = Random.nextBoolean(), 125 | ) 126 | ) 127 | 128 | comments.add( 129 | Comment( 130 | id = UUID.randomUUID().toString(), 131 | profilePicAuthor = images.random(), 132 | ) 133 | ) 134 | } 135 | 136 | } 137 | 138 | private fun generateListRandomIdStrings(): List { 139 | if (Random.nextBoolean()) return emptyList() 140 | val randomList = mutableListOf() 141 | repeat(Random.nextInt(0, 1000)) { 142 | randomList.add(UUID.randomUUID().toString()) 143 | } 144 | return randomList 145 | } 146 | 147 | private fun generateSampleComments(): List { 148 | 149 | if (Random.nextBoolean()) return emptyList() 150 | 151 | val comments = mutableListOf() 152 | repeat(Random.nextInt(0, 10)) { 153 | comments.add( 154 | Comment( 155 | id = it.toString(), 156 | profilePicAuthor = images.random(), 157 | ) 158 | ) 159 | } 160 | return comments 161 | } 162 | 163 | private fun generateListSamplesLikes(): List { 164 | if (Random.nextBoolean()) return emptyList() 165 | 166 | val likes = mutableListOf() 167 | repeat(Random.nextInt(0, 10)) { 168 | likes.add( 169 | Like( 170 | id = it.toString(), 171 | profilePicAuthor = images.random(), 172 | ) 173 | ) 174 | } 175 | return likes 176 | } 177 | 178 | fun generateSampleInvitedUser(): UserAccount { 179 | val name = names[Random.nextInt(0, names.size)] 180 | return UserAccount( 181 | id = UUID.randomUUID().toString(), 182 | name = name, 183 | userName = name.replace(" ", "").lowercase(), 184 | bio = "O tal do Lorem ipsum dolor sit amet, consectetur adipiscing elit", 185 | link = "https://github.com/git-jr", 186 | imageProfileUrl = images.random(), 187 | posts = (1L..Random.nextLong(100)).toList(), 188 | follows = (1L..Random.nextLong(100)).toList(), 189 | followers = (1L..Random.nextLong(100)).toList(), 190 | ) 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /app/src/main/java/com/paradoxo/threadscompose/ui/Components.kt: -------------------------------------------------------------------------------- 1 | package com.paradoxo.threadscompose.ui 2 | 3 | import androidx.compose.animation.core.Spring 4 | import androidx.compose.animation.core.animateFloatAsState 5 | import androidx.compose.animation.core.spring 6 | import androidx.compose.foundation.border 7 | import androidx.compose.foundation.layout.Arrangement 8 | import androidx.compose.foundation.layout.Box 9 | import androidx.compose.foundation.layout.Column 10 | import androidx.compose.foundation.layout.IntrinsicSize 11 | import androidx.compose.foundation.layout.Row 12 | import androidx.compose.foundation.layout.Spacer 13 | import androidx.compose.foundation.layout.defaultMinSize 14 | import androidx.compose.foundation.layout.fillMaxHeight 15 | import androidx.compose.foundation.layout.fillMaxWidth 16 | import androidx.compose.foundation.layout.height 17 | import androidx.compose.foundation.layout.offset 18 | import androidx.compose.foundation.layout.padding 19 | import androidx.compose.foundation.layout.size 20 | import androidx.compose.foundation.layout.width 21 | import androidx.compose.foundation.lazy.LazyRow 22 | import androidx.compose.foundation.lazy.items 23 | import androidx.compose.foundation.shape.CircleShape 24 | import androidx.compose.foundation.shape.RoundedCornerShape 25 | import androidx.compose.material.icons.Icons 26 | import androidx.compose.material.icons.filled.MoreVert 27 | import androidx.compose.material3.Divider 28 | import androidx.compose.material3.Icon 29 | import androidx.compose.material3.MaterialTheme 30 | import androidx.compose.material3.Text 31 | import androidx.compose.runtime.Composable 32 | import androidx.compose.runtime.getValue 33 | import androidx.compose.ui.Alignment 34 | import androidx.compose.ui.Modifier 35 | import androidx.compose.ui.draw.clip 36 | import androidx.compose.ui.draw.scale 37 | import androidx.compose.ui.graphics.Color 38 | import androidx.compose.ui.layout.ContentScale 39 | import androidx.compose.ui.platform.LocalContext 40 | import androidx.compose.ui.res.painterResource 41 | import androidx.compose.ui.text.font.FontWeight 42 | import androidx.compose.ui.tooling.preview.Preview 43 | import androidx.compose.ui.unit.dp 44 | import coil.compose.AsyncImage 45 | import coil.request.ImageRequest 46 | import com.paradoxo.threadscompose.R 47 | import com.paradoxo.threadscompose.model.Post 48 | import com.paradoxo.threadscompose.sampleData.SampleData 49 | import com.paradoxo.threadscompose.utils.formatTimeElapsed 50 | import com.paradoxo.threadscompose.utils.getCurrentTime 51 | import com.paradoxo.threadscompose.utils.noRippleClickable 52 | 53 | @Composable 54 | fun PostItem( 55 | post: Post, 56 | isLiked: Boolean = false, 57 | onLikeClick: (String) -> Unit = {}, 58 | ) { 59 | val dividerColor = Color.Gray.copy(alpha = 0.2f) 60 | 61 | val commentsSize = post.comments.size 62 | val likesSize = post.likes.size 63 | val groupImageOffsetSize = if (commentsSize > 0) 8.dp else 0.dp 64 | val hasMedia = post.medias.isNotEmpty() 65 | 66 | Column { 67 | Row( 68 | Modifier 69 | .height(IntrinsicSize.Min) 70 | .fillMaxWidth() 71 | ) { 72 | Column( 73 | Modifier 74 | .padding(start = 8.dp, top = 8.dp, bottom = 8.dp) 75 | .weight(0.2f), 76 | horizontalAlignment = Alignment.CenterHorizontally 77 | ) { 78 | AsyncImage( 79 | model = ImageRequest.Builder(LocalContext.current) 80 | .data(post.userAccount.imageProfileUrl) 81 | .crossfade(true) 82 | .build(), 83 | placeholder = painterResource(id = R.drawable.placeholder_image), 84 | error = painterResource(id = R.drawable.placeholder_image), 85 | contentDescription = "avatar", 86 | modifier = Modifier 87 | .padding(horizontal = 8.dp) 88 | .clip(CircleShape) 89 | ) 90 | 91 | if (commentsSize > 0 || likesSize > 0) { 92 | Divider( 93 | color = dividerColor, 94 | modifier = Modifier 95 | .padding(top = 8.dp) 96 | .fillMaxHeight() 97 | .width(3.dp) 98 | .clip(CircleShape) 99 | ) 100 | } 101 | } 102 | Column( 103 | Modifier 104 | .padding(vertical = 8.dp) 105 | .weight(0.8f) 106 | ) { 107 | Row( 108 | Modifier 109 | .padding(end = 8.dp) 110 | .fillMaxWidth(), 111 | verticalAlignment = Alignment.CenterVertically 112 | ) { 113 | Text( 114 | text = post.userAccount.name, 115 | fontWeight = FontWeight.Bold, 116 | modifier = Modifier.weight(0.8f) 117 | ) 118 | 119 | Row( 120 | verticalAlignment = Alignment.CenterVertically 121 | ) { 122 | Text( 123 | text = formatTimeElapsed(post.date, getCurrentTime()), 124 | fontWeight = FontWeight.Light 125 | ) 126 | Icon( 127 | imageVector = Icons.Default.MoreVert, 128 | contentDescription = "more" 129 | ) 130 | } 131 | } 132 | 133 | Text( 134 | text = post.description, 135 | modifier = Modifier.padding(end = 8.dp) 136 | ) 137 | 138 | 139 | if (hasMedia) { 140 | Row( 141 | modifier = Modifier 142 | .height(height = 200.dp) 143 | .fillMaxWidth() 144 | ) { 145 | LazyRow( 146 | Modifier 147 | .fillMaxWidth() 148 | ) { 149 | items(post.medias) { media -> 150 | Row( 151 | Modifier 152 | .defaultMinSize(minHeight = 200.dp) 153 | ) { 154 | AsyncImage( 155 | contentScale = ContentScale.Crop, 156 | model = ImageRequest.Builder(LocalContext.current) 157 | .data(media) 158 | .crossfade(true) 159 | .build(), 160 | placeholder = painterResource(id = R.drawable.placeholder_image), 161 | error = painterResource(id = R.drawable.placeholder_image), 162 | contentDescription = "avatar", 163 | modifier = Modifier 164 | .fillMaxHeight() 165 | .padding(vertical = 8.dp) 166 | .clip(RoundedCornerShape(10)) 167 | .border( 168 | width = 1.dp, 169 | color = Color.Gray.copy(alpha = 0.5f), 170 | shape = RoundedCornerShape(10) 171 | ) 172 | 173 | ) 174 | Spacer(modifier = Modifier.width(8.dp)) 175 | } 176 | } 177 | } 178 | } 179 | } 180 | 181 | Row( 182 | Modifier.padding(vertical = 8.dp), 183 | ) { 184 | 185 | LikeButton( 186 | isLiked = isLiked, 187 | onLikeClick = onLikeClick, 188 | postId = post.id 189 | ) 190 | 191 | Spacer(modifier = Modifier.width(10.dp)) 192 | Icon( 193 | painterResource(id = R.drawable.ic_comment), 194 | contentDescription = "comment" 195 | ) 196 | 197 | Spacer(modifier = Modifier.width(10.dp)) 198 | Icon( 199 | painterResource(id = R.drawable.ic_repost), 200 | contentDescription = "retweet" 201 | ) 202 | 203 | Spacer(modifier = Modifier.width(10.dp)) 204 | Icon( 205 | painterResource(id = R.drawable.ic_send), 206 | contentDescription = "share" 207 | ) 208 | } 209 | } 210 | } 211 | if (commentsSize > 0 || likesSize > 0) { 212 | Row( 213 | Modifier 214 | .fillMaxWidth(), 215 | verticalAlignment = Alignment.CenterVertically, 216 | ) { 217 | Column( 218 | Modifier 219 | .weight(0.2f) 220 | .padding(start = 8.dp) 221 | .offset(y = (-groupImageOffsetSize * 2)), 222 | horizontalAlignment = Alignment.CenterHorizontally, 223 | verticalArrangement = Arrangement.Center 224 | ) { 225 | if (commentsSize > 0) { 226 | when (post.comments.size) { 227 | 1 -> { 228 | ContainerOneProfilePic(post.comments.first().profilePicAuthor) 229 | } 230 | 231 | 2 -> { 232 | val profilePics = post.comments.take(2).map { it.profilePicAuthor } 233 | if (profilePics.distinct().size == 1) { 234 | ContainerOneProfilePic(profilePics.first()) 235 | } else { 236 | ContainerTwoProfilePics(profilePics[0], profilePics[1]) 237 | } 238 | } 239 | 240 | else -> { 241 | val profilePics = post.comments.take(3).map { it.profilePicAuthor } 242 | if (profilePics.distinct().size == 1) { 243 | ContainerOneProfilePic(profilePics.first()) 244 | } else { 245 | ContainerMoreTwoProfilePics( 246 | profilePics[0], 247 | profilePics[1], 248 | profilePics[2] 249 | ) 250 | } 251 | } 252 | } 253 | } else { 254 | when (post.likes.size) { 255 | 1 -> { 256 | ContainerOneProfilePic(post.likes.first().profilePicAuthor) 257 | } 258 | 259 | 2 -> { 260 | val profilePics = post.likes.take(2).map { it.profilePicAuthor } 261 | if (profilePics.distinct().size == 1) { 262 | ContainerOneProfilePic(profilePics.first()) 263 | } else { 264 | ContainerTwoProfilePics(profilePics[0], profilePics[1]) 265 | } 266 | } 267 | 268 | else -> { 269 | val profilePics = post.likes.take(3).map { it.profilePicAuthor } 270 | if (profilePics.distinct().size == 1) { 271 | ContainerOneProfilePic(profilePics.first()) 272 | } else { 273 | ContainerMoreTwoProfilePics( 274 | profilePics[0], 275 | profilePics[1], 276 | profilePics[2] 277 | ) 278 | } 279 | } 280 | } 281 | } 282 | } 283 | Row( 284 | Modifier 285 | .weight(0.8f) 286 | .offset(y = (-groupImageOffsetSize * 2)), 287 | verticalAlignment = Alignment.CenterVertically 288 | ) { 289 | if (commentsSize > 0) { 290 | val textCommentsSize = 291 | if (commentsSize == 1) "1 Resposta" else "$commentsSize Respostas" 292 | Text( 293 | text = textCommentsSize, 294 | style = MaterialTheme.typography.bodyLarge.copy( 295 | color = Color.Gray.copy(alpha = 0.8f) 296 | ) 297 | ) 298 | Spacer(modifier = Modifier.width(4.dp)) 299 | } 300 | 301 | if (likesSize > 0) { 302 | val separator = if (commentsSize > 0) " • " else "" 303 | val textLikesSize = 304 | separator + if (likesSize == 1) "1 Curtida" else "$likesSize Curtidas" 305 | 306 | Text( 307 | text = textLikesSize, 308 | style = MaterialTheme.typography.bodyLarge.copy( 309 | color = Color.Gray.copy(alpha = 0.8f) 310 | ) 311 | ) 312 | Spacer(modifier = Modifier.width(4.dp)) 313 | } 314 | } 315 | } 316 | } 317 | } 318 | 319 | Divider( 320 | color = dividerColor, 321 | modifier = Modifier 322 | .fillMaxWidth() 323 | .offset(y = -groupImageOffsetSize * 2) 324 | 325 | ) 326 | } 327 | 328 | 329 | @Composable 330 | private fun ContainerOneProfilePic( 331 | profilePicAuthor: String 332 | ) { 333 | Box( 334 | Modifier.padding(horizontal = 8.dp, vertical = 16.dp) 335 | ) { 336 | AsyncImage( 337 | model = ImageRequest.Builder(LocalContext.current) 338 | .data(profilePicAuthor) 339 | .crossfade(true) 340 | .build(), 341 | contentDescription = "avatar", 342 | modifier = Modifier 343 | .size(22.dp) 344 | .border( 345 | width = 2.dp, 346 | color = Color.White, 347 | shape = CircleShape 348 | ) 349 | .clip(CircleShape) 350 | .padding(2.dp) 351 | ) 352 | } 353 | } 354 | 355 | @Composable 356 | private fun ContainerTwoProfilePics( 357 | firstProfilePic: String, 358 | secondProfilePic: String 359 | ) { 360 | Box( 361 | Modifier.padding(horizontal = 8.dp, vertical = 16.dp) 362 | ) { 363 | AsyncImage( 364 | model = firstProfilePic, 365 | contentDescription = "avatar", 366 | modifier = Modifier 367 | .size(22.dp) 368 | .offset(x = (-5).dp) 369 | .border( 370 | width = 2.dp, 371 | color = Color.White, 372 | shape = CircleShape 373 | ) 374 | .clip(CircleShape) 375 | .padding(2.dp) 376 | ) 377 | 378 | AsyncImage( 379 | model = secondProfilePic, 380 | contentDescription = "avatar", 381 | modifier = Modifier 382 | .size(22.dp) 383 | .offset(x = 5.dp) 384 | .border( 385 | width = 2.dp, 386 | color = Color.White, 387 | shape = CircleShape 388 | ) 389 | .clip(CircleShape) 390 | .padding(2.dp) 391 | ) 392 | } 393 | } 394 | 395 | @Composable 396 | private fun ContainerMoreTwoProfilePics( 397 | firstProfilePic: String, 398 | secondProfilePic: String, 399 | thirdProfilePic: String 400 | ) { 401 | Box( 402 | Modifier.padding(horizontal = 8.dp) 403 | ) { 404 | Column( 405 | Modifier 406 | .fillMaxWidth() 407 | .padding(start = 8.dp, end = 8.dp, top = 8.dp), 408 | verticalArrangement = Arrangement.Center, 409 | ) { 410 | AsyncImage( 411 | model = firstProfilePic, 412 | contentDescription = "avatar", 413 | modifier = Modifier 414 | .size(22.dp) 415 | .border( 416 | width = 2.dp, 417 | color = Color.White, 418 | shape = CircleShape 419 | ) 420 | .clip(CircleShape) 421 | .padding(2.dp) 422 | .align(Alignment.End), 423 | ) 424 | 425 | AsyncImage( 426 | model = secondProfilePic, 427 | contentDescription = "avatar", 428 | modifier = Modifier 429 | .size(16.dp) 430 | .offset(y = (-14).dp) 431 | .border( 432 | width = 2.dp, 433 | color = Color.White, 434 | shape = CircleShape 435 | ) 436 | .clip(CircleShape) 437 | .padding(2.dp) 438 | .align(Alignment.Start) 439 | ) 440 | 441 | AsyncImage( 442 | model = thirdProfilePic, 443 | contentDescription = "avatar", 444 | modifier = Modifier 445 | .size(14.dp) 446 | .offset(y = (-18).dp) 447 | .border( 448 | width = 2.dp, 449 | color = Color.White, 450 | shape = CircleShape 451 | ) 452 | .clip(CircleShape) 453 | .padding(2.dp) 454 | .align(Alignment.CenterHorizontally) 455 | ) 456 | } 457 | } 458 | } 459 | 460 | 461 | @Composable 462 | fun LikeButton( 463 | isLiked: Boolean, onLikeClick: (String) -> Unit, 464 | postId: String 465 | ) { 466 | val scale: Float by animateFloatAsState( 467 | targetValue = if (isLiked) 1.1f else 1f, 468 | animationSpec = spring( 469 | dampingRatio = Spring.DampingRatioHighBouncy, 470 | stiffness = Spring.StiffnessMedium 471 | ), label = "" 472 | ) 473 | 474 | val resourceId = if (isLiked) R.drawable.ic_heart else R.drawable.ic_heart_outlined 475 | Icon( 476 | painter = painterResource(id = resourceId), 477 | contentDescription = "like", 478 | modifier = Modifier 479 | .noRippleClickable { 480 | onLikeClick(postId) 481 | } 482 | .scale(scale), 483 | tint = if (isLiked) Color.Red else Color.Black 484 | ) 485 | } 486 | 487 | @Preview(showBackground = true) 488 | @Composable 489 | private fun PostItemPreview() { 490 | PostItem( 491 | post = SampleData().posts.first(), 492 | isLiked = true 493 | ) 494 | } 495 | -------------------------------------------------------------------------------- /app/src/main/java/com/paradoxo/threadscompose/ui/feed/FeedScreen.kt: -------------------------------------------------------------------------------- 1 | package com.paradoxo.threadscompose.ui.feed 2 | 3 | import androidx.compose.animation.core.Animatable 4 | import androidx.compose.animation.core.Spring 5 | import androidx.compose.animation.core.spring 6 | import androidx.compose.foundation.background 7 | import androidx.compose.foundation.gestures.detectVerticalDragGestures 8 | import androidx.compose.foundation.layout.Arrangement 9 | import androidx.compose.foundation.layout.Box 10 | import androidx.compose.foundation.layout.Row 11 | import androidx.compose.foundation.layout.Spacer 12 | import androidx.compose.foundation.layout.fillMaxSize 13 | import androidx.compose.foundation.layout.fillMaxWidth 14 | import androidx.compose.foundation.layout.height 15 | import androidx.compose.foundation.layout.size 16 | import androidx.compose.foundation.layout.systemBarsPadding 17 | import androidx.compose.foundation.lazy.LazyColumn 18 | import androidx.compose.foundation.lazy.items 19 | import androidx.compose.runtime.Composable 20 | import androidx.compose.runtime.LaunchedEffect 21 | import androidx.compose.runtime.getValue 22 | import androidx.compose.runtime.mutableFloatStateOf 23 | import androidx.compose.runtime.mutableStateOf 24 | import androidx.compose.runtime.remember 25 | import androidx.compose.runtime.rememberCoroutineScope 26 | import androidx.compose.runtime.saveable.rememberSaveable 27 | import androidx.compose.ui.Modifier 28 | import androidx.compose.ui.graphics.Color 29 | import androidx.compose.ui.input.pointer.pointerInput 30 | import androidx.compose.ui.tooling.preview.Preview 31 | import androidx.compose.ui.unit.dp 32 | import com.airbnb.lottie.compose.LottieAnimation 33 | import com.airbnb.lottie.compose.LottieCompositionSpec 34 | import com.airbnb.lottie.compose.LottieConstants 35 | import com.airbnb.lottie.compose.rememberLottieAnimatable 36 | import com.airbnb.lottie.compose.rememberLottieComposition 37 | import com.paradoxo.threadscompose.R 38 | import com.paradoxo.threadscompose.model.Post 39 | import com.paradoxo.threadscompose.ui.PostItem 40 | import kotlinx.coroutines.launch 41 | 42 | 43 | @Composable 44 | fun FeedScreen( 45 | modifier: Modifier = Modifier, 46 | posts: List = emptyList(), 47 | onLikeClick: (Post) -> Unit = {}, 48 | onReload: () -> Unit = {}, 49 | idCurrentUserProfile: String = "", 50 | ) { 51 | LazyColumn( 52 | modifier = modifier 53 | .background(color = Color.White) 54 | .fillMaxSize() 55 | .systemBarsPadding() 56 | ) { 57 | item { 58 | ExpandableAppLogoLottie(onReload = onReload) 59 | } 60 | 61 | items( 62 | posts, 63 | key = { post -> post.id } 64 | ) { post -> 65 | val isLiked = rememberSaveable { 66 | mutableStateOf(post.likes.any { it.id == idCurrentUserProfile }) 67 | } 68 | PostItem( 69 | post, 70 | isLiked.value, 71 | onLikeClick = { 72 | onLikeClick(post) 73 | isLiked.value = !isLiked.value 74 | } 75 | ) 76 | } 77 | 78 | item { 79 | Spacer(modifier = Modifier.height(56.dp)) 80 | } 81 | } 82 | } 83 | 84 | @Composable 85 | private fun ExpandableAppLogoLottie(onReload: () -> Unit = {}) { 86 | val maxHeightImage = 200.dp 87 | val defaultSizeImage = 72.dp 88 | val coroutineScope = rememberCoroutineScope() 89 | val animatedImageSize = remember { Animatable(defaultSizeImage.value) } 90 | 91 | 92 | val lottiePreviousProgress by remember { mutableFloatStateOf(0f) } 93 | val lottieSpeed by remember { mutableFloatStateOf(1f) } 94 | val lottieComposition by rememberLottieComposition( 95 | LottieCompositionSpec.RawRes( 96 | R.raw.logo_lines_animated 97 | ) 98 | ) 99 | val lottieAnimatable = rememberLottieAnimatable() 100 | 101 | LaunchedEffect(lottieSpeed) { 102 | lottieAnimatable.animate( 103 | lottieComposition, 104 | iteration = LottieConstants.IterateForever, 105 | speed = lottieSpeed, 106 | ) 107 | } 108 | 109 | Box( 110 | modifier = Modifier 111 | .pointerInput(Unit) { 112 | detectVerticalDragGestures( 113 | onVerticalDrag = { change, offset -> 114 | coroutineScope.launch { 115 | val newSize = 116 | (animatedImageSize.value + offset / 8).coerceAtLeast( 117 | defaultSizeImage.value 118 | ) 119 | if (newSize < maxHeightImage.value) { 120 | animatedImageSize.snapTo(newSize) 121 | } 122 | change.consume() 123 | } 124 | }, 125 | onDragEnd = { 126 | coroutineScope.launch { 127 | animatedImageSize.animateTo( 128 | defaultSizeImage.value, 129 | animationSpec = spring( 130 | dampingRatio = Spring.DampingRatioLowBouncy, 131 | stiffness = Spring.StiffnessLow 132 | ) 133 | ) 134 | } 135 | coroutineScope.launch { 136 | lottieAnimatable.animate( 137 | lottieComposition, 138 | iteration = 1, 139 | speed = lottieSpeed, 140 | reverseOnRepeat = true, 141 | initialProgress = lottiePreviousProgress, 142 | ) 143 | } 144 | 145 | onReload() 146 | }, 147 | onDragStart = { 148 | coroutineScope.launch { 149 | lottieAnimatable.animate( 150 | lottieComposition, 151 | iteration = LottieConstants.IterateForever, 152 | speed = lottieSpeed, 153 | initialProgress = lottiePreviousProgress, 154 | ) 155 | } 156 | } 157 | ) 158 | } 159 | ) { 160 | Row( 161 | modifier = Modifier 162 | .fillMaxWidth(), 163 | horizontalArrangement = Arrangement.Center, 164 | ) { 165 | 166 | LottieAnimation( 167 | composition = lottieComposition, 168 | progress = { lottieAnimatable.progress }, 169 | modifier = Modifier 170 | .size(animatedImageSize.value.dp) 171 | ) 172 | 173 | } 174 | } 175 | } 176 | 177 | @Preview(showBackground = true) 178 | @Composable 179 | fun FeedScreenPreview() { 180 | FeedScreen() 181 | } -------------------------------------------------------------------------------- /app/src/main/java/com/paradoxo/threadscompose/ui/feed/FeedScreenState.kt: -------------------------------------------------------------------------------- 1 | package com.paradoxo.threadscompose.ui.feed 2 | 3 | import com.paradoxo.threadscompose.model.Post 4 | import com.paradoxo.threadscompose.model.UserAccount 5 | 6 | data class FeedScreenState( 7 | var posts: List = emptyList(), 8 | val currentUserProfile: UserAccount = UserAccount(), 9 | ) 10 | -------------------------------------------------------------------------------- /app/src/main/java/com/paradoxo/threadscompose/ui/feed/FeedViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.paradoxo.threadscompose.ui.feed 2 | 3 | import androidx.lifecycle.ViewModel 4 | import com.google.firebase.auth.ktx.auth 5 | import com.google.firebase.ktx.Firebase 6 | import com.paradoxo.threadscompose.model.Like 7 | import com.paradoxo.threadscompose.model.Post 8 | import com.paradoxo.threadscompose.model.UserAccount 9 | import com.paradoxo.threadscompose.network.firebase.PostFirestore 10 | import com.paradoxo.threadscompose.sampleData.SampleData 11 | import kotlinx.coroutines.flow.MutableStateFlow 12 | import kotlinx.coroutines.flow.StateFlow 13 | import kotlinx.coroutines.flow.asStateFlow 14 | 15 | internal class FeedViewModel : ViewModel() { 16 | 17 | private val _uiState = MutableStateFlow(FeedScreenState()) 18 | val uiState: StateFlow = _uiState.asStateFlow() 19 | private val postFirestore = PostFirestore() 20 | 21 | init { 22 | // val postFirestore = PostFirestore() 23 | searchNewPosts() 24 | 25 | _uiState.value = _uiState.value.copy( 26 | currentUserProfile = UserAccount( 27 | id = Firebase.auth.currentUser?.uid ?: "", 28 | userName = Firebase.auth.currentUser?.displayName ?: "", 29 | imageProfileUrl = Firebase.auth.currentUser?.photoUrl.toString() 30 | ) 31 | ) 32 | } 33 | 34 | fun searchNewPosts() { 35 | if (Firebase.auth.currentUser == null) { 36 | _uiState.value = _uiState.value.copy(posts = SampleData().posts) 37 | return 38 | } 39 | 40 | postFirestore.getAllPosts( 41 | onSuccess = { posts -> 42 | _uiState.value = _uiState.value.copy(posts = posts) 43 | }, 44 | onError = { 45 | _uiState.value = _uiState.value.copy(posts = SampleData().posts) 46 | } 47 | ) 48 | } 49 | 50 | fun likePost(it: Post) { 51 | 52 | if (it.likes.any { like -> like.id == _uiState.value.currentUserProfile.id }) { 53 | _uiState.value = _uiState.value.copy(posts = _uiState.value.posts.map { post -> 54 | if (post.id == it.id) { 55 | post.copy( 56 | likes = post.likes.toMutableList().apply { 57 | removeIf { like -> like.id == _uiState.value.currentUserProfile.id } 58 | } 59 | ) 60 | } else { 61 | post 62 | } 63 | }) 64 | return 65 | } 66 | 67 | _uiState.value = _uiState.value.copy(posts = _uiState.value.posts.map { post -> 68 | if (post.id == it.id) { 69 | post.copy( 70 | likes = post.likes.toMutableList().apply { 71 | add( 72 | Like( 73 | id = _uiState.value.currentUserProfile.id, 74 | profilePicAuthor = _uiState.value.currentUserProfile.imageProfileUrl 75 | ) 76 | ) 77 | } 78 | ) 79 | } else { 80 | post 81 | } 82 | }) 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /app/src/main/java/com/paradoxo/threadscompose/ui/home/SessionState.kt: -------------------------------------------------------------------------------- 1 | package com.paradoxo.threadscompose.ui.home 2 | 3 | import com.paradoxo.threadscompose.model.UserAccount 4 | import com.paradoxo.threadscompose.ui.login.AppState 5 | 6 | data class SessionState( 7 | var appState: AppState = AppState.Loading, 8 | var userAccount: UserAccount = UserAccount() 9 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/paradoxo/threadscompose/ui/home/SessionViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.paradoxo.threadscompose.ui.home 2 | 3 | import androidx.lifecycle.ViewModel 4 | import com.paradoxo.threadscompose.model.UserAccount 5 | import kotlinx.coroutines.flow.MutableStateFlow 6 | import kotlinx.coroutines.flow.StateFlow 7 | import kotlinx.coroutines.flow.asStateFlow 8 | 9 | internal class SessionViewModel : ViewModel() { 10 | 11 | private val _uiState = MutableStateFlow(SessionState()) 12 | val uiState: StateFlow = _uiState.asStateFlow() 13 | 14 | fun setCurrentUser(currentUser: UserAccount) { 15 | _uiState.value = _uiState.value.copy(userAccount = currentUser) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /app/src/main/java/com/paradoxo/threadscompose/ui/login/LoginScreen.kt: -------------------------------------------------------------------------------- 1 | package com.paradoxo.threadscompose.ui.login 2 | 3 | import android.util.Log 4 | import androidx.activity.compose.rememberLauncherForActivityResult 5 | import androidx.activity.result.contract.ActivityResultContracts 6 | import androidx.compose.foundation.Image 7 | import androidx.compose.foundation.background 8 | import androidx.compose.foundation.border 9 | import androidx.compose.foundation.layout.Arrangement 10 | import androidx.compose.foundation.layout.Box 11 | import androidx.compose.foundation.layout.Column 12 | import androidx.compose.foundation.layout.Row 13 | import androidx.compose.foundation.layout.Spacer 14 | import androidx.compose.foundation.layout.fillMaxHeight 15 | import androidx.compose.foundation.layout.fillMaxSize 16 | import androidx.compose.foundation.layout.fillMaxWidth 17 | import androidx.compose.foundation.layout.height 18 | import androidx.compose.foundation.layout.padding 19 | import androidx.compose.foundation.layout.size 20 | import androidx.compose.foundation.shape.RoundedCornerShape 21 | import androidx.compose.material3.MaterialTheme 22 | import androidx.compose.material3.Text 23 | import androidx.compose.runtime.Composable 24 | import androidx.compose.runtime.DisposableEffect 25 | import androidx.compose.runtime.collectAsState 26 | import androidx.compose.runtime.getValue 27 | import androidx.compose.runtime.remember 28 | import androidx.compose.runtime.rememberCoroutineScope 29 | import androidx.compose.ui.Alignment 30 | import androidx.compose.ui.Modifier 31 | import androidx.compose.ui.graphics.Color 32 | import androidx.compose.ui.platform.LocalContext 33 | import androidx.compose.ui.res.painterResource 34 | import androidx.compose.ui.res.stringResource 35 | import androidx.compose.ui.text.font.FontWeight 36 | import androidx.compose.ui.text.style.TextAlign 37 | import androidx.compose.ui.tooling.preview.Preview 38 | import androidx.compose.ui.unit.dp 39 | import androidx.compose.ui.unit.sp 40 | import androidx.lifecycle.viewmodel.compose.viewModel 41 | import coil.compose.AsyncImage 42 | import com.facebook.CallbackManager 43 | import com.facebook.FacebookCallback 44 | import com.facebook.FacebookException 45 | import com.facebook.Profile 46 | import com.facebook.login.LoginManager 47 | import com.facebook.login.LoginResult 48 | import com.google.android.gms.auth.api.signin.GoogleSignIn 49 | import com.google.android.gms.auth.api.signin.GoogleSignInOptions 50 | import com.google.firebase.auth.FacebookAuthProvider 51 | import com.google.firebase.auth.GoogleAuthProvider 52 | import com.google.firebase.auth.ktx.auth 53 | import com.google.firebase.ktx.Firebase 54 | import com.paradoxo.threadscompose.R 55 | import com.paradoxo.threadscompose.ui.theme.ThreadsComposeTheme 56 | import com.paradoxo.threadscompose.utils.noRippleClickable 57 | import com.paradoxo.threadscompose.utils.showMessage 58 | import kotlinx.coroutines.launch 59 | import kotlinx.coroutines.tasks.await 60 | 61 | @Composable 62 | internal fun LoginScreen( 63 | loginViewModel: LoginViewModel = viewModel(), 64 | onAuthComplete: (String?) -> Unit = {}, 65 | ) { 66 | val loginState by loginViewModel.uiState.collectAsState() 67 | 68 | val context = LocalContext.current 69 | 70 | Column( 71 | Modifier 72 | .fillMaxSize() 73 | ) { 74 | when (loginState.appState) { 75 | AppState.Loading -> { 76 | SplashScreen() 77 | } 78 | 79 | AppState.LoggedIn -> { 80 | if (loginState.profileInServer) { 81 | onAuthComplete(loginState.currentUserName) 82 | } else { 83 | onAuthComplete(null) 84 | } 85 | } 86 | 87 | AppState.LoggedOut -> { 88 | LoggedOutScreen( 89 | onAuthComplete = { profileName -> 90 | if (profileName == null) { 91 | onAuthComplete(null) 92 | } else { 93 | onAuthComplete(profileName) 94 | } 95 | Log.i("login", "onAuthComplete: $profileName") 96 | }, 97 | onAuthError = { 98 | context.showMessage("Erro ao fazer login, tente novamente") 99 | }, 100 | onAuthCancel = { 101 | context.showMessage("Login cancelado") 102 | } 103 | ) 104 | } 105 | } 106 | } 107 | } 108 | 109 | @Composable 110 | fun LoggedOutScreen( 111 | onAuthComplete: (String?) -> Unit = {}, 112 | onAuthError: () -> Unit = {}, 113 | onAuthCancel: () -> Unit = {} 114 | ) { 115 | val scope = rememberCoroutineScope() 116 | val loginManager = LoginManager.getInstance() 117 | val callbackManager = remember { CallbackManager.Factory.create() } 118 | 119 | val context = LocalContext.current 120 | 121 | val facebookAuthLauncher = rememberLauncherForActivityResult( 122 | contract = loginManager.createLogInActivityResultContract(callbackManager, null), 123 | onResult = { 124 | // The result are handled by the callbackManager 125 | } 126 | ) 127 | 128 | val googleAuthLauncher = rememberLauncherForActivityResult( 129 | contract = ActivityResultContracts.StartActivityForResult(), 130 | onResult = { 131 | val data = it.data 132 | val account = GoogleSignIn.getSignedInAccountFromIntent(data).result 133 | val credential = GoogleAuthProvider.getCredential(account.idToken, null) 134 | 135 | Firebase.auth.signInWithCredential(credential).addOnCompleteListener { 136 | if (it.isSuccessful) { 137 | onAuthComplete(account?.displayName) 138 | 139 | } else { 140 | Log.e("googleAuth", "Erro ao logar com o Google", it.exception) 141 | onAuthError() 142 | } 143 | } 144 | } 145 | ) 146 | 147 | val gso = 148 | GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN) 149 | .requestIdToken(stringResource(R.string.default_web_client_id)) 150 | .requestEmail() 151 | .build() 152 | 153 | val client = GoogleSignIn.getClient(context, gso) 154 | 155 | DisposableEffect(Unit) { 156 | loginManager.registerCallback(callbackManager, object : FacebookCallback { 157 | override fun onCancel() { 158 | onAuthCancel() 159 | } 160 | 161 | override fun onError(error: FacebookException) { 162 | onAuthError() 163 | } 164 | 165 | override fun onSuccess(result: LoginResult) { 166 | scope.launch { 167 | val token = result.accessToken.token 168 | val credential = FacebookAuthProvider.getCredential(token) 169 | val authResult = Firebase.auth.signInWithCredential(credential).await() 170 | if (authResult.user != null) { 171 | Profile.getCurrentProfile()?.name?.let { profileName -> 172 | onAuthComplete(profileName) 173 | } ?: run { 174 | Log.e("login", "Login error: Profile name is null") 175 | onAuthError() 176 | } 177 | } else { 178 | Log.i("login", "Unable to sign in with Facebook") 179 | onAuthError() 180 | } 181 | } 182 | } 183 | }) 184 | 185 | onDispose { 186 | loginManager.unregisterCallback(callbackManager) 187 | } 188 | } 189 | 190 | Column( 191 | Modifier 192 | .fillMaxSize(), 193 | horizontalAlignment = Alignment.CenterHorizontally, 194 | ) { 195 | Image( 196 | painter = painterResource(id = R.drawable.bg_lines_1), 197 | contentDescription = "app logo" 198 | ) 199 | 200 | Column( 201 | Modifier.padding(16.dp), 202 | horizontalAlignment = Alignment.CenterHorizontally, 203 | ) { 204 | Row( 205 | modifier = Modifier 206 | .noRippleClickable { 207 | facebookAuthLauncher.launch(listOf("email", "public_profile")) 208 | } 209 | .fillMaxWidth() 210 | .background( 211 | color = Color.White, 212 | shape = RoundedCornerShape(25) 213 | ) 214 | .border( 215 | width = 1.dp, 216 | color = Color.LightGray, 217 | shape = RoundedCornerShape(25) 218 | ) 219 | .padding(horizontal = 16.dp, vertical = 4.dp), 220 | horizontalArrangement = Arrangement.SpaceBetween, 221 | verticalAlignment = Alignment.CenterVertically 222 | ) { 223 | Column { 224 | Text( 225 | text = "Entrar com Facebook", 226 | style = MaterialTheme.typography.bodyLarge.copy( 227 | color = Color.Gray.copy(alpha = 0.8f) 228 | ) 229 | ) 230 | Text( 231 | text = "Fazer login", 232 | style = MaterialTheme.typography.titleMedium 233 | ) 234 | } 235 | 236 | Image( 237 | painter = painterResource(id = R.drawable.ic_facebook), 238 | contentDescription = "logo instagram", 239 | modifier = Modifier 240 | .padding(16.dp) 241 | .size(50.dp) 242 | ) 243 | } 244 | 245 | Spacer(modifier = Modifier.height(16.dp)) 246 | 247 | Column( 248 | modifier = Modifier 249 | .fillMaxWidth() 250 | .height(48.dp) 251 | .background(Color(66, 133, 244, 255), shape = RoundedCornerShape(8.dp)) 252 | .padding(8.dp) 253 | .noRippleClickable { 254 | googleAuthLauncher.launch(client.signInIntent) 255 | } 256 | ) { 257 | Box( 258 | Modifier.fillMaxHeight(), 259 | contentAlignment = Alignment.CenterStart 260 | ) { 261 | AsyncImage( 262 | model = "https://lh3.googleusercontent.com/COxitqgJr1sJnIDe8-jiKhxDx1FrYbtRHKJ9z_hELisAlapwE9LUPh6fcXIfb5vwpbMl4xl9H9TRFPc5NOO8Sb3VSgIBrfRYvW6cUA", 263 | contentDescription = "Logo do Google", 264 | modifier = Modifier 265 | .background(Color.White, shape = RoundedCornerShape(4.dp)) 266 | .padding(8.dp) 267 | ) 268 | Text( 269 | text = "Entrar com o Google", 270 | modifier = Modifier.fillMaxWidth(), 271 | textAlign = TextAlign.Center, 272 | color = Color.White, 273 | fontSize = 14.sp, 274 | fontWeight = FontWeight.Bold 275 | ) 276 | } 277 | } 278 | 279 | Spacer(modifier = Modifier.height(16.dp)) 280 | Text( 281 | text = "Entrar como convidado", 282 | style = MaterialTheme.typography.bodyLarge.copy( 283 | color = Color.Gray.copy(alpha = 0.8f), 284 | textAlign = TextAlign.Center 285 | ), 286 | modifier = Modifier 287 | .fillMaxWidth() 288 | .noRippleClickable { 289 | onAuthComplete(null) 290 | } 291 | .padding(horizontal = 16.dp, vertical = 22.dp) 292 | ) 293 | } 294 | 295 | } 296 | } 297 | 298 | @Preview(showBackground = true) 299 | @Composable 300 | fun LoggedOutScreenPreview() { 301 | ThreadsComposeTheme { 302 | LoggedOutScreen() 303 | } 304 | } 305 | 306 | @Preview(showBackground = true) 307 | @Composable 308 | fun LoginScreenPreview() { 309 | ThreadsComposeTheme { 310 | LoginScreen() 311 | } 312 | } 313 | -------------------------------------------------------------------------------- /app/src/main/java/com/paradoxo/threadscompose/ui/login/LoginScreenState.kt: -------------------------------------------------------------------------------- 1 | package com.paradoxo.threadscompose.ui.login 2 | 3 | sealed class AppState { 4 | object Loading : AppState() 5 | object LoggedIn : AppState() 6 | object LoggedOut : AppState() 7 | } 8 | 9 | data class LoginState( 10 | var appState: AppState = AppState.Loading, 11 | var profileInServer: Boolean = false, 12 | val currentUserName: String = "", 13 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/paradoxo/threadscompose/ui/login/LoginViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.paradoxo.threadscompose.ui.login 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.viewModelScope 5 | import com.facebook.Profile 6 | import com.google.firebase.auth.FirebaseAuth 7 | import com.google.firebase.auth.ktx.auth 8 | import com.google.firebase.ktx.Firebase 9 | import com.paradoxo.threadscompose.model.UserAccount 10 | import com.paradoxo.threadscompose.network.firebase.UserFirestore 11 | import kotlinx.coroutines.delay 12 | import kotlinx.coroutines.flow.MutableStateFlow 13 | import kotlinx.coroutines.flow.StateFlow 14 | import kotlinx.coroutines.flow.asStateFlow 15 | import kotlinx.coroutines.launch 16 | import kotlin.random.Random 17 | 18 | 19 | internal class LoginViewModel : ViewModel() { 20 | private val _uiState = MutableStateFlow(LoginState()) 21 | val uiState: StateFlow = _uiState.asStateFlow() 22 | 23 | private var auth: FirebaseAuth = Firebase.auth 24 | private var userFirestore = UserFirestore() 25 | 26 | init { 27 | verifyLoginState() 28 | } 29 | 30 | private fun verifyLoginState() { 31 | viewModelScope.launch { 32 | delay(Random.nextLong(600, 1200)) 33 | if (auth.currentUser != null) { 34 | _uiState.value = _uiState.value.copy( 35 | appState = AppState.LoggedIn, 36 | profileInServer = true, 37 | currentUserName = auth.currentUser?.displayName ?: "" 38 | ) 39 | } else { 40 | _uiState.value = LoginState(AppState.LoggedOut) 41 | } 42 | } 43 | } 44 | 45 | 46 | fun getProfile( 47 | onSuccess: (UserAccount) -> Unit = {}, 48 | onError: () -> Unit = {}, 49 | ) { 50 | val userId = auth.currentUser?.uid 51 | userId?.let { 52 | userFirestore.getUserById( 53 | userId = userId, 54 | onSuccess = { 55 | onSuccess(it) 56 | }, 57 | onError = onError 58 | ) 59 | } ?: onError() 60 | } 61 | 62 | fun saveNewUser( 63 | userAccount: UserAccount, 64 | onSuccess: () -> Unit, 65 | onError: () -> Unit 66 | ) { 67 | auth.currentUser?.let { 68 | val user = userAccount.copy( 69 | id = it.uid, 70 | name = it.displayName ?: "", 71 | userName = it.displayName?.replace(" ", "") ?: "", 72 | ) 73 | 74 | userFirestore.saveUser( 75 | userId = it.uid, 76 | userAccount = user, 77 | onSuccess = onSuccess, 78 | onError = onError 79 | ) 80 | } ?: onError() 81 | } 82 | 83 | fun getCurrentUser(): UserAccount { 84 | val currentUser = Firebase.auth.currentUser 85 | val profile = Profile.getCurrentProfile() 86 | val profileFacebookPicUri = profile?.getProfilePictureUri(200, 200).toString() 87 | 88 | val photoUrl = currentUser?.photoUrl.toString().replace("=s96", "=s200") 89 | val profileImage = profileFacebookPicUri.ifEmpty { photoUrl } 90 | 91 | val userAccount = UserAccount( 92 | id = currentUser?.uid ?: "", 93 | name = currentUser?.displayName ?: "", 94 | userName = currentUser?.displayName?.lowercase()?.replace(" ", "_") ?: "", 95 | imageProfileUrl = profileImage 96 | ) 97 | 98 | return userAccount 99 | } 100 | } -------------------------------------------------------------------------------- /app/src/main/java/com/paradoxo/threadscompose/ui/login/SplashScreen.kt: -------------------------------------------------------------------------------- 1 | package com.paradoxo.threadscompose.ui.login 2 | 3 | import androidx.compose.foundation.Image 4 | import androidx.compose.foundation.layout.Arrangement 5 | import androidx.compose.foundation.layout.Column 6 | import androidx.compose.foundation.layout.fillMaxSize 7 | import androidx.compose.foundation.layout.padding 8 | import androidx.compose.foundation.layout.size 9 | import androidx.compose.runtime.Composable 10 | import androidx.compose.ui.Alignment 11 | import androidx.compose.ui.Modifier 12 | import androidx.compose.ui.res.painterResource 13 | import androidx.compose.ui.tooling.preview.Preview 14 | import androidx.compose.ui.unit.dp 15 | import com.paradoxo.threadscompose.R 16 | import com.paradoxo.threadscompose.ui.theme.ThreadsComposeTheme 17 | 18 | @Composable 19 | internal fun SplashScreen() { 20 | Column( 21 | Modifier 22 | .fillMaxSize() 23 | .padding(16.dp), 24 | horizontalAlignment = Alignment.CenterHorizontally, 25 | verticalArrangement = Arrangement.Center 26 | ) { 27 | Image( 28 | painter = painterResource(id = R.drawable.ic_logo_colors), 29 | contentDescription = "app logo", 30 | modifier = Modifier.size(200.dp) 31 | ) 32 | } 33 | } 34 | 35 | @Preview(showBackground = true) 36 | @Composable 37 | private fun SplashScreenPreview() { 38 | ThreadsComposeTheme { 39 | SplashScreen() 40 | } 41 | } -------------------------------------------------------------------------------- /app/src/main/java/com/paradoxo/threadscompose/ui/navigation/Destinations.kt: -------------------------------------------------------------------------------- 1 | package com.paradoxo.threadscompose.ui.navigation 2 | 3 | import com.paradoxo.threadscompose.R 4 | 5 | 6 | sealed class Destinations(val route: String, val resourceId: Pair? = null) { 7 | object Login : Destinations("login") 8 | object Feed : Destinations("feed", Pair(R.drawable.ic_home, R.drawable.ic_home_outlined)) 9 | object Search : Destinations("search", Pair(R.drawable.ic_search, R.drawable.ic_search)) 10 | object Post : Destinations("post", Pair(R.drawable.ic_post, R.drawable.ic_post)) 11 | object Notifications : 12 | Destinations("notifications", Pair(R.drawable.ic_heart, R.drawable.ic_heart_outlined)) 13 | 14 | object Profile : 15 | Destinations("profile", Pair(R.drawable.ic_profile, R.drawable.ic_profile_outlined)) 16 | 17 | object ProfileEdit : Destinations("profileEdit") 18 | 19 | object LoginNavigation : Destinations("loginNavigation") 20 | object HomeNavigation : Destinations("homeNavigation") 21 | } 22 | 23 | val screenItems = listOf( 24 | Destinations.Feed, 25 | Destinations.Search, 26 | Destinations.Post, 27 | Destinations.Notifications, 28 | Destinations.Profile 29 | ) 30 | -------------------------------------------------------------------------------- /app/src/main/java/com/paradoxo/threadscompose/ui/navigation/ThreadsNavController.kt: -------------------------------------------------------------------------------- 1 | package com.paradoxo.threadscompose.ui.navigation 2 | 3 | import androidx.compose.foundation.layout.PaddingValues 4 | import androidx.compose.foundation.layout.padding 5 | import androidx.compose.runtime.State 6 | import androidx.compose.runtime.collectAsState 7 | import androidx.compose.runtime.getValue 8 | import androidx.compose.runtime.mutableStateOf 9 | import androidx.compose.runtime.remember 10 | import androidx.compose.runtime.rememberCoroutineScope 11 | import androidx.compose.runtime.toMutableStateList 12 | import androidx.compose.ui.Modifier 13 | import androidx.compose.ui.platform.LocalContext 14 | import androidx.compose.ui.unit.dp 15 | import androidx.lifecycle.viewmodel.compose.viewModel 16 | import androidx.navigation.NavGraphBuilder 17 | import androidx.navigation.compose.composable 18 | import androidx.navigation.navigation 19 | import com.google.firebase.auth.ktx.auth 20 | import com.google.firebase.ktx.Firebase 21 | import com.paradoxo.threadscompose.model.UserAccount 22 | import com.paradoxo.threadscompose.network.firebase.PostFirestore 23 | import com.paradoxo.threadscompose.sampleData.SampleData 24 | import com.paradoxo.threadscompose.ui.feed.FeedScreen 25 | import com.paradoxo.threadscompose.ui.feed.FeedViewModel 26 | import com.paradoxo.threadscompose.ui.home.SessionState 27 | import com.paradoxo.threadscompose.ui.login.LoginScreen 28 | import com.paradoxo.threadscompose.ui.login.LoginViewModel 29 | import com.paradoxo.threadscompose.ui.notification.NotificationScreenState 30 | import com.paradoxo.threadscompose.ui.notification.NotificationsScreen 31 | import com.paradoxo.threadscompose.ui.post.PostScreen 32 | import com.paradoxo.threadscompose.ui.profile.ProfileEditScreen 33 | import com.paradoxo.threadscompose.ui.profile.ProfileScreen 34 | import com.paradoxo.threadscompose.ui.search.SearchScreen 35 | import com.paradoxo.threadscompose.utils.showMessage 36 | import kotlinx.coroutines.launch 37 | 38 | internal fun NavGraphBuilder.loginGraph( 39 | onNavigateToHome: (UserAccount) -> Unit = {}, 40 | onNavigateToProfileEdit: () -> Unit = {} 41 | ) { 42 | navigation( 43 | startDestination = Destinations.Login.route, 44 | route = Destinations.LoginNavigation.route 45 | ) { 46 | composable(Destinations.Login.route) { 47 | val loginViewModel: LoginViewModel = viewModel() 48 | 49 | LoginScreen( 50 | loginViewModel = loginViewModel, 51 | onAuthComplete = { 52 | it?.let { 53 | loginViewModel.getProfile( 54 | onSuccess = { userAccountOnFirebase -> 55 | if (userAccountOnFirebase.userName.isNotEmpty()) { 56 | onNavigateToHome(userAccountOnFirebase) 57 | } else { 58 | onNavigateToProfileEdit() 59 | } 60 | }, 61 | onError = { 62 | onNavigateToProfileEdit() 63 | } 64 | ) 65 | } ?: run { 66 | onNavigateToHome(SampleData().generateSampleInvitedUser()) 67 | } 68 | }, 69 | ) 70 | } 71 | 72 | composable(Destinations.ProfileEdit.route) { 73 | val loginViewModel: LoginViewModel = viewModel() 74 | val context = LocalContext.current 75 | 76 | ProfileEditScreen( 77 | userAccount = loginViewModel.getCurrentUser(), 78 | onSave = { userAccountUpdated -> 79 | loginViewModel.saveNewUser( 80 | userAccount = userAccountUpdated, 81 | onSuccess = { 82 | onNavigateToHome(userAccountUpdated) 83 | }, 84 | onError = { 85 | context.showMessage("Erro ao salvar informações do perfil") 86 | } 87 | ) 88 | } 89 | ) 90 | } 91 | } 92 | } 93 | 94 | internal fun NavGraphBuilder.homeGraph( 95 | state: State, 96 | onNavigateToInstagram: () -> Unit = {}, 97 | onBack: () -> Unit = {}, 98 | paddingValues: PaddingValues = PaddingValues(0.dp) 99 | ) { 100 | navigation( 101 | startDestination = Destinations.Feed.route, 102 | route = Destinations.HomeNavigation.route 103 | ) { 104 | composable(Destinations.Feed.route) { 105 | val postViewModel: FeedViewModel = viewModel() 106 | val postState by postViewModel.uiState.collectAsState() 107 | 108 | FeedScreen( 109 | posts = postState.posts, 110 | idCurrentUserProfile = state.value.userAccount.id, 111 | onLikeClick = { 112 | postViewModel.likePost(it) 113 | }, 114 | onReload = { 115 | postViewModel.searchNewPosts() 116 | } 117 | ) 118 | } 119 | composable(Destinations.Search.route) { SearchScreen() } 120 | composable(Destinations.Post.route) { 121 | 122 | val scope = rememberCoroutineScope() 123 | val context = LocalContext.current 124 | 125 | PostScreen( 126 | currentUser = state.value.userAccount, 127 | onBack = { 128 | onBack() 129 | }, 130 | onSendPost = { posts -> 131 | if (Firebase.auth.currentUser == null) { 132 | context.showMessage("Você precisa estar logado para publicar") 133 | return@PostScreen 134 | } 135 | context.showMessage("Publicando") 136 | val postFirestore = PostFirestore() 137 | scope.launch { 138 | postFirestore.savePost( 139 | posts = posts, 140 | onSuccess = { 141 | context.showMessage("Publicado com sucesso") 142 | onBack() 143 | }, 144 | onError = { 145 | context.showMessage("Erro ao publicar") 146 | onBack() 147 | } 148 | ) 149 | } 150 | } 151 | ) 152 | } 153 | composable(Destinations.Notifications.route) { 154 | val notificationState by remember { mutableStateOf(NotificationScreenState()) } 155 | notificationState.notifications.addAll(SampleData().notifications) 156 | 157 | val allNotifications = notificationState.notifications 158 | val notifications = allNotifications.toMutableStateList() 159 | 160 | NotificationsScreen( 161 | allNotifications = allNotifications, 162 | notifications = notifications 163 | ) 164 | } 165 | composable(Destinations.Profile.route) { 166 | 167 | val postLists = remember { SampleData().posts }.toMutableStateList() 168 | 169 | ProfileScreen( 170 | currentUser = state.value.userAccount, 171 | postLists = postLists, 172 | repliesList = postLists.shuffled().toMutableList(), 173 | modifier = Modifier.padding(paddingValues), 174 | onNavigateToInstagram = { 175 | onNavigateToInstagram() 176 | } 177 | ) 178 | } 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /app/src/main/java/com/paradoxo/threadscompose/ui/navigation/ThreadsNavHost.kt: -------------------------------------------------------------------------------- 1 | package com.paradoxo.threadscompose.ui.navigation 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.collectAsState 5 | import androidx.lifecycle.viewmodel.compose.viewModel 6 | import androidx.navigation.NavGraph.Companion.findStartDestination 7 | import androidx.navigation.NavHostController 8 | import androidx.navigation.compose.NavHost 9 | import com.paradoxo.threadscompose.ui.home.SessionViewModel 10 | 11 | @Composable 12 | fun ThreadsNavHost( 13 | navController: NavHostController, 14 | navigateToInstagram: () -> Unit = {} 15 | ) { 16 | val sessionViewModel: SessionViewModel = viewModel() 17 | val state = sessionViewModel.uiState.collectAsState() 18 | 19 | NavHost( 20 | navController = navController, 21 | startDestination = Destinations.LoginNavigation.route 22 | ) { 23 | loginGraph( 24 | onNavigateToHome = { currentUser -> 25 | sessionViewModel.setCurrentUser(currentUser) 26 | 27 | navController.navigate(Destinations.HomeNavigation.route) { 28 | popUpTo(navController.graph.findStartDestination().id) { 29 | saveState = true 30 | } 31 | launchSingleTop = true 32 | restoreState = true 33 | } 34 | }, 35 | onNavigateToProfileEdit = { 36 | navController.navigate(Destinations.ProfileEdit.route) { 37 | popUpTo(navController.graph.findStartDestination().id) { 38 | saveState = true 39 | } 40 | 41 | launchSingleTop = true 42 | restoreState = true 43 | } 44 | } 45 | ) 46 | homeGraph( 47 | state = state, 48 | onNavigateToInstagram = { 49 | navigateToInstagram() 50 | }, 51 | onBack = { 52 | navController.popBackStack() 53 | } 54 | ) 55 | } 56 | } 57 | 58 | -------------------------------------------------------------------------------- /app/src/main/java/com/paradoxo/threadscompose/ui/notification/NotificationScreenState.kt: -------------------------------------------------------------------------------- 1 | package com.paradoxo.threadscompose.ui.notification 2 | 3 | import com.paradoxo.threadscompose.model.Notification 4 | 5 | internal data class NotificationScreenState( 6 | val notifications: MutableList = mutableListOf(), 7 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/paradoxo/threadscompose/ui/notification/NotificationsScreen.kt: -------------------------------------------------------------------------------- 1 | package com.paradoxo.threadscompose.ui.notification 2 | 3 | import androidx.compose.foundation.Image 4 | import androidx.compose.foundation.background 5 | import androidx.compose.foundation.border 6 | import androidx.compose.foundation.clickable 7 | import androidx.compose.foundation.horizontalScroll 8 | import androidx.compose.foundation.layout.Arrangement 9 | import androidx.compose.foundation.layout.Box 10 | import androidx.compose.foundation.layout.Column 11 | import androidx.compose.foundation.layout.IntrinsicSize 12 | import androidx.compose.foundation.layout.Row 13 | import androidx.compose.foundation.layout.Spacer 14 | import androidx.compose.foundation.layout.fillMaxHeight 15 | import androidx.compose.foundation.layout.fillMaxSize 16 | import androidx.compose.foundation.layout.fillMaxWidth 17 | import androidx.compose.foundation.layout.height 18 | import androidx.compose.foundation.layout.offset 19 | import androidx.compose.foundation.layout.padding 20 | import androidx.compose.foundation.layout.size 21 | import androidx.compose.foundation.layout.sizeIn 22 | import androidx.compose.foundation.layout.width 23 | import androidx.compose.foundation.lazy.LazyColumn 24 | import androidx.compose.foundation.lazy.items 25 | import androidx.compose.foundation.rememberScrollState 26 | import androidx.compose.foundation.shape.CircleShape 27 | import androidx.compose.foundation.shape.RoundedCornerShape 28 | import androidx.compose.material.icons.Icons 29 | import androidx.compose.material.icons.filled.Notifications 30 | import androidx.compose.material.icons.filled.Person 31 | import androidx.compose.material3.Divider 32 | import androidx.compose.material3.ExperimentalMaterial3Api 33 | import androidx.compose.material3.FilterChip 34 | import androidx.compose.material3.FilterChipDefaults 35 | import androidx.compose.material3.Icon 36 | import androidx.compose.material3.MaterialTheme 37 | import androidx.compose.material3.Scaffold 38 | import androidx.compose.material3.Text 39 | import androidx.compose.material3.TopAppBar 40 | import androidx.compose.runtime.Composable 41 | import androidx.compose.runtime.LaunchedEffect 42 | import androidx.compose.runtime.getValue 43 | import androidx.compose.runtime.mutableIntStateOf 44 | import androidx.compose.runtime.mutableStateOf 45 | import androidx.compose.runtime.remember 46 | import androidx.compose.runtime.saveable.rememberSaveable 47 | import androidx.compose.runtime.setValue 48 | import androidx.compose.ui.Alignment 49 | import androidx.compose.ui.Modifier 50 | import androidx.compose.ui.draw.clip 51 | import androidx.compose.ui.graphics.Color 52 | import androidx.compose.ui.graphics.painter.Painter 53 | import androidx.compose.ui.graphics.vector.rememberVectorPainter 54 | import androidx.compose.ui.res.painterResource 55 | import androidx.compose.ui.text.font.FontWeight 56 | import androidx.compose.ui.text.style.TextAlign 57 | import androidx.compose.ui.tooling.preview.Preview 58 | import androidx.compose.ui.unit.dp 59 | import androidx.compose.ui.unit.sp 60 | import com.paradoxo.threadscompose.R 61 | import com.paradoxo.threadscompose.model.Notification 62 | import com.paradoxo.threadscompose.model.NotificationTypeEnum 63 | import com.paradoxo.threadscompose.sampleData.SampleData 64 | import com.paradoxo.threadscompose.ui.theme.ThreadsComposeTheme 65 | import com.paradoxo.threadscompose.utils.noRippleClickable 66 | 67 | @OptIn(ExperimentalMaterial3Api::class) 68 | @Composable 69 | fun NotificationsScreen( 70 | modifier: Modifier = Modifier, 71 | allNotifications: MutableList, 72 | notifications: MutableList 73 | ) { 74 | 75 | val tabItems = listOf("Tudo", "Respostas", "Menções", "Verificado") 76 | 77 | Scaffold( 78 | modifier = modifier, 79 | topBar = { 80 | Column { 81 | TopAppBar( 82 | title = { 83 | Text( 84 | text = "Atividade", 85 | style = MaterialTheme.typography.titleLarge.copy( 86 | fontWeight = FontWeight.Bold, 87 | fontSize = 32.sp, 88 | ), 89 | ) 90 | }) 91 | NotificationTabs( 92 | tabItems = tabItems, 93 | onItemSelected = { selectedItem -> 94 | if (selectedItem == 0) { 95 | notifications.clear() 96 | notifications.addAll(allNotifications) 97 | return@NotificationTabs 98 | } else { 99 | val filterList = allNotifications.filter { 100 | it.type == NotificationTypeEnum.values()[selectedItem] 101 | } 102 | notifications.clear() 103 | notifications.addAll(filterList) 104 | } 105 | } 106 | ) 107 | } 108 | }, 109 | ) { paddingValues -> 110 | Column( 111 | Modifier 112 | .fillMaxWidth() 113 | .padding(paddingValues = paddingValues) 114 | 115 | ) { 116 | if (notifications.isNotEmpty()) { 117 | 118 | LazyColumn( 119 | modifier = modifier 120 | .fillMaxSize() 121 | ) { 122 | items( 123 | notifications, 124 | key = { notification -> notification.id } 125 | ) { notification -> 126 | val isFollowing = rememberSaveable { 127 | mutableStateOf(notification.isFollowing) 128 | } 129 | NotificationItem( 130 | notification = notification, 131 | onFollowClick = { 132 | isFollowing.value = !isFollowing.value 133 | }, 134 | isFollowing = isFollowing.value 135 | ) 136 | } 137 | } 138 | } else { 139 | Box( 140 | modifier = Modifier 141 | .fillMaxSize() 142 | .padding(16.dp), 143 | contentAlignment = Alignment.Center 144 | ) { 145 | Text( 146 | text = "Ainda não há nada para ver aqui", 147 | color = Color.Black.copy(alpha = 0.4f), 148 | ) 149 | } 150 | } 151 | } 152 | } 153 | } 154 | 155 | @Composable 156 | @OptIn(ExperimentalMaterial3Api::class) 157 | private fun NotificationTabs( 158 | tabItems: List, 159 | onItemSelected: (Int) -> Unit = {} 160 | ) { 161 | val scrollState = rememberScrollState() 162 | var selectedItem by remember { mutableIntStateOf(0) } 163 | 164 | LaunchedEffect(selectedItem) { 165 | scrollState.animateScrollTo(selectedItem * 100) 166 | } 167 | 168 | Row( 169 | Modifier 170 | .fillMaxWidth() 171 | .horizontalScroll(scrollState), 172 | horizontalArrangement = Arrangement.spacedBy(8.dp) 173 | ) { 174 | 175 | Spacer(modifier = Modifier.width(4.dp)) 176 | tabItems.forEachIndexed { index, item -> 177 | FilterChip( 178 | selected = selectedItem == index, 179 | onClick = { 180 | selectedItem = index 181 | onItemSelected(selectedItem) 182 | }, 183 | colors = FilterChipDefaults.filterChipColors( 184 | selectedLabelColor = MaterialTheme.colorScheme.background, 185 | selectedContainerColor = MaterialTheme.colorScheme.onPrimaryContainer, 186 | ), 187 | border = FilterChipDefaults.filterChipBorder( 188 | borderColor = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.2f), 189 | ), 190 | shape = RoundedCornerShape(30), 191 | label = { 192 | Text( 193 | text = item, 194 | fontSize = 14.sp, 195 | textAlign = TextAlign.Center, 196 | modifier = Modifier 197 | .sizeIn(minWidth = 68.dp) 198 | .padding( 199 | vertical = 8.dp 200 | ) 201 | ) 202 | }, 203 | 204 | ) 205 | } 206 | Spacer(modifier = Modifier.width(4.dp)) 207 | } 208 | } 209 | 210 | @Composable 211 | private fun NotificationItem( 212 | notification: Notification, 213 | onFollowClick: (Notification) -> Unit = {}, 214 | isFollowing: Boolean = false, 215 | ) { 216 | val dividerColor = Color.Gray.copy(alpha = 0.2f) 217 | val iconSpecs = getIconByType(notification.type) 218 | 219 | Column { 220 | Row( 221 | Modifier 222 | .fillMaxWidth() 223 | .height(IntrinsicSize.Min) 224 | ) { 225 | Box( 226 | Modifier 227 | .padding(vertical = 8.dp, horizontal = 10.dp), 228 | contentAlignment = Alignment.BottomEnd, 229 | ) { 230 | Image( 231 | painter = painterResource(id = notification.image), 232 | contentDescription = "avatar", 233 | modifier = Modifier 234 | .size(42.dp) 235 | .clip(CircleShape), 236 | ) 237 | 238 | Box( 239 | modifier = Modifier 240 | .offset(x = 2.dp, y = 2.dp) 241 | .background( 242 | Color.White, 243 | CircleShape 244 | ) 245 | .padding(2.dp) 246 | ) { 247 | Box( 248 | modifier = Modifier 249 | .background( 250 | iconSpecs.second, 251 | CircleShape 252 | ) 253 | ) { 254 | Icon( 255 | painter = iconSpecs.first, 256 | contentDescription = "Action", 257 | tint = Color.White, 258 | modifier = Modifier 259 | .size(20.dp) 260 | .clip(CircleShape) 261 | .padding(5.dp) 262 | 263 | ) 264 | } 265 | } 266 | } 267 | 268 | Column( 269 | Modifier 270 | .padding(vertical = 8.dp) 271 | .weight(0.5f) 272 | ) { 273 | Row( 274 | Modifier.fillMaxWidth(), 275 | verticalAlignment = Alignment.CenterVertically 276 | ) { 277 | Text( 278 | text = notification.title, 279 | fontWeight = FontWeight.Bold, 280 | ) 281 | 282 | Text( 283 | text = " ${notification.time}", 284 | color = Color.Black.copy(alpha = 0.4f), 285 | modifier = Modifier.padding(start = 2.dp) 286 | ) 287 | } 288 | 289 | Text( 290 | text = notification.description, 291 | color = Color.Black.copy(alpha = 0.4f), 292 | ) 293 | notification.extraContent?.let { 294 | Spacer( 295 | modifier = Modifier.height(4.dp) 296 | ) 297 | Text( 298 | text = it, 299 | ) 300 | } 301 | } 302 | 303 | Row( 304 | modifier = Modifier 305 | .fillMaxWidth() 306 | .fillMaxHeight() 307 | .padding(vertical = 8.dp, horizontal = 8.dp) 308 | .weight(0.3f), 309 | verticalAlignment = Alignment.CenterVertically, 310 | ) { 311 | val textIsFollowing = if (isFollowing) { 312 | "Seguindo" 313 | } else { 314 | "Seguir" 315 | } 316 | 317 | val textColorByIsFollowState = if (notification.isFollowing) { 318 | Color.Gray 319 | } else { 320 | Color.Black 321 | } 322 | 323 | if (notification.type == NotificationTypeEnum.Follow) { 324 | Box( 325 | contentAlignment = Alignment.Center, 326 | modifier = Modifier 327 | .clickable {} 328 | .fillMaxWidth() 329 | .border( 330 | width = 1.dp, 331 | color = Color.LightGray, 332 | shape = RoundedCornerShape(10.dp) 333 | ) 334 | .clip(RoundedCornerShape(40)) 335 | .noRippleClickable { 336 | onFollowClick(notification) 337 | } 338 | ) { 339 | Text( 340 | text = textIsFollowing, 341 | fontWeight = FontWeight.Bold, 342 | color = textColorByIsFollowState, 343 | modifier = Modifier 344 | .padding(vertical = 6.dp), 345 | ) 346 | } 347 | } 348 | } 349 | } 350 | 351 | Row { 352 | Spacer( 353 | modifier = Modifier 354 | .fillMaxWidth() 355 | .weight(0.2f) 356 | 357 | ) 358 | Divider( 359 | color = dividerColor, 360 | modifier = Modifier 361 | .weight(0.9f) 362 | .height(1.dp) 363 | .fillMaxWidth() 364 | ) 365 | } 366 | } 367 | } 368 | 369 | @Composable 370 | fun getIconByType(type: NotificationTypeEnum): Pair { 371 | return when (type) { 372 | NotificationTypeEnum.Follow -> Pair( 373 | rememberVectorPainter(Icons.Default.Person), 374 | Color(0xFF6B3AEE) 375 | ) 376 | 377 | NotificationTypeEnum.Like -> Pair( 378 | painterResource(id = R.drawable.ic_heart), 379 | Color(0xFFFB0169) 380 | ) 381 | 382 | NotificationTypeEnum.Comment -> Pair( 383 | painterResource(id = R.drawable.ic_reply), 384 | Color(0xFF1FC1FC) 385 | ) 386 | 387 | NotificationTypeEnum.Mention -> Pair( 388 | painterResource(id = R.drawable.ic_attach_file), 389 | Color(0xFF18C686) 390 | ) 391 | 392 | else -> Pair(rememberVectorPainter(Icons.Default.Notifications), Color.Black) 393 | } 394 | } 395 | 396 | 397 | @Preview(showBackground = true) 398 | @Composable 399 | private fun NotificationItemPreview() { 400 | NotificationItem( 401 | SampleData().notifications.first() 402 | ) 403 | } 404 | 405 | @Preview(showBackground = true) 406 | @Composable 407 | fun NotificationsScreenPreview() { 408 | ThreadsComposeTheme { 409 | NotificationsScreen( 410 | allNotifications = mutableListOf(), 411 | notifications = mutableListOf(), 412 | ) 413 | } 414 | } -------------------------------------------------------------------------------- /app/src/main/java/com/paradoxo/threadscompose/ui/post/PostScreenState.kt: -------------------------------------------------------------------------------- 1 | package com.paradoxo.threadscompose.ui.post 2 | 3 | import com.paradoxo.threadscompose.model.Post 4 | import com.paradoxo.threadscompose.model.UserAccount 5 | import com.paradoxo.threadscompose.utils.getCurrentTime 6 | 7 | internal data class PostScreenState( 8 | val userAccount: UserAccount, 9 | var content: String, 10 | var medias: MutableList = mutableListOf(), 11 | val date: String, 12 | val isFirstPost: Boolean = false 13 | ) { 14 | fun toPost() = Post( 15 | userAccount = userAccount, 16 | date = getCurrentTime(), 17 | description = content, 18 | medias = medias, 19 | likes = emptyList(), 20 | comments = emptyList(), 21 | ) 22 | } -------------------------------------------------------------------------------- /app/src/main/java/com/paradoxo/threadscompose/ui/profile/ProfileEditScreen.kt: -------------------------------------------------------------------------------- 1 | package com.paradoxo.threadscompose.ui.profile 2 | 3 | import androidx.compose.foundation.background 4 | import androidx.compose.foundation.border 5 | import androidx.compose.foundation.layout.Arrangement 6 | import androidx.compose.foundation.layout.Box 7 | import androidx.compose.foundation.layout.Column 8 | import androidx.compose.foundation.layout.Row 9 | import androidx.compose.foundation.layout.Spacer 10 | import androidx.compose.foundation.layout.fillMaxSize 11 | import androidx.compose.foundation.layout.fillMaxWidth 12 | import androidx.compose.foundation.layout.height 13 | import androidx.compose.foundation.layout.imePadding 14 | import androidx.compose.foundation.layout.padding 15 | import androidx.compose.foundation.layout.size 16 | import androidx.compose.foundation.rememberScrollState 17 | import androidx.compose.foundation.shape.CircleShape 18 | import androidx.compose.foundation.shape.RoundedCornerShape 19 | import androidx.compose.foundation.text.BasicTextField 20 | import androidx.compose.foundation.verticalScroll 21 | import androidx.compose.material.icons.Icons 22 | import androidx.compose.material.icons.filled.Lock 23 | import androidx.compose.material3.Button 24 | import androidx.compose.material3.ButtonDefaults 25 | import androidx.compose.material3.Divider 26 | import androidx.compose.material3.Icon 27 | import androidx.compose.material3.MaterialTheme 28 | import androidx.compose.material3.Text 29 | import androidx.compose.runtime.Composable 30 | import androidx.compose.runtime.getValue 31 | import androidx.compose.runtime.mutableStateOf 32 | import androidx.compose.runtime.remember 33 | import androidx.compose.runtime.setValue 34 | import androidx.compose.ui.Alignment 35 | import androidx.compose.ui.Modifier 36 | import androidx.compose.ui.draw.clip 37 | import androidx.compose.ui.graphics.Color 38 | import androidx.compose.ui.graphics.SolidColor 39 | import androidx.compose.ui.layout.ContentScale 40 | import androidx.compose.ui.res.painterResource 41 | import androidx.compose.ui.text.TextStyle 42 | import androidx.compose.ui.text.font.FontWeight 43 | import androidx.compose.ui.tooling.preview.Preview 44 | import androidx.compose.ui.unit.dp 45 | import androidx.compose.ui.unit.sp 46 | import coil.compose.AsyncImage 47 | import com.paradoxo.threadscompose.R 48 | import com.paradoxo.threadscompose.model.UserAccount 49 | 50 | 51 | @Composable 52 | fun ProfileEditScreen( 53 | modifier: Modifier = Modifier, 54 | userAccount: UserAccount, 55 | onSave: (UserAccount) -> Unit = {} 56 | ) { 57 | var bioEditTextState by remember { 58 | mutableStateOf(userAccount.bio) 59 | } 60 | 61 | var linkEditTextState by remember { 62 | mutableStateOf(userAccount.link) 63 | } 64 | 65 | val scrollState = rememberScrollState() 66 | Column( 67 | modifier = modifier 68 | .imePadding() 69 | .fillMaxSize() 70 | .verticalScroll(scrollState) 71 | .padding(16.dp), 72 | horizontalAlignment = Alignment.CenterHorizontally, 73 | verticalArrangement = Arrangement.SpaceEvenly 74 | ) { 75 | 76 | Column( 77 | horizontalAlignment = Alignment.CenterHorizontally, 78 | ) { 79 | Text( 80 | text = "Perfil", 81 | style = MaterialTheme.typography.headlineLarge, 82 | fontWeight = FontWeight.Bold, 83 | ) 84 | Text( 85 | text = "Personalize seu perfil no Lines", 86 | color = Color.Gray.copy(alpha = 0.8f) 87 | ) 88 | } 89 | 90 | Column { 91 | Column( 92 | modifier = Modifier 93 | .border( 94 | width = 1.dp, 95 | color = Color.Black.copy(alpha = 0.15f), 96 | shape = RoundedCornerShape(12.dp) 97 | ) 98 | .background(Color.White, shape = RoundedCornerShape(12.dp)) 99 | .padding(16.dp) 100 | ) { 101 | Row( 102 | Modifier.fillMaxWidth(), 103 | horizontalArrangement = Arrangement.SpaceBetween, 104 | ) { 105 | Column { 106 | Text( 107 | text = "Nome", 108 | fontWeight = FontWeight.Bold, 109 | ) 110 | Spacer(modifier = Modifier.padding(4.dp)) 111 | Row( 112 | verticalAlignment = Alignment.CenterVertically 113 | ) { 114 | Icon( 115 | Icons.Default.Lock, 116 | contentDescription = "lock", 117 | Modifier.size(14.dp) 118 | ) 119 | 120 | Spacer(modifier = Modifier.padding(horizontal = 2.dp)) 121 | 122 | Text("${userAccount.name} (${userAccount.userName})") 123 | } 124 | } 125 | 126 | AsyncImage( 127 | model = userAccount.imageProfileUrl, 128 | placeholder = painterResource(id = R.drawable.placeholder_image), 129 | error = painterResource(id = R.drawable.placeholder_image), 130 | contentDescription = "logo imagem perfil", 131 | contentScale = ContentScale.Crop, 132 | modifier = Modifier 133 | .clip(CircleShape) 134 | .size(50.dp) 135 | ) 136 | } 137 | Row { 138 | Divider( 139 | color = Color.Gray.copy(alpha = 0.15f), 140 | modifier = Modifier.weight(0.8f) 141 | ) 142 | Spacer(modifier = Modifier.weight(0.2f)) 143 | } 144 | Spacer(modifier = Modifier.height(16.dp)) 145 | 146 | Row( 147 | Modifier.fillMaxWidth(), 148 | ) { 149 | Column { 150 | Text( 151 | text = "Bio", 152 | fontWeight = FontWeight.Bold, 153 | ) 154 | Spacer(modifier = Modifier.padding(4.dp)) 155 | 156 | BasicTextField( 157 | value = bioEditTextState, 158 | onValueChange = { bioEditTextState = it }, 159 | modifier = Modifier.fillMaxWidth(), 160 | maxLines = 3, 161 | textStyle = TextStyle.Default.copy( 162 | fontSize = 16.sp 163 | ), 164 | decorationBox = { innerValue -> 165 | Box { 166 | if (bioEditTextState.isEmpty()) { 167 | Text( 168 | text = "+Escrever bio", 169 | color = Color.Gray.copy(alpha = 0.8f) 170 | ) 171 | } 172 | innerValue() 173 | } 174 | }, 175 | cursorBrush = SolidColor(MaterialTheme.colorScheme.surfaceVariant) 176 | ) 177 | 178 | } 179 | } 180 | 181 | Divider(color = Color.Gray.copy(alpha = 0.15f)) 182 | Spacer(modifier = Modifier.height(16.dp)) 183 | 184 | Row( 185 | Modifier.fillMaxWidth(), 186 | ) { 187 | Column { 188 | Text( 189 | text = "Link", 190 | fontWeight = FontWeight.Bold, 191 | ) 192 | Spacer(modifier = Modifier.padding(4.dp)) 193 | 194 | BasicTextField( 195 | value = linkEditTextState, 196 | onValueChange = { linkEditTextState = it }, 197 | modifier = Modifier.fillMaxWidth(), 198 | maxLines = 3, 199 | textStyle = TextStyle.Default.copy( 200 | fontSize = 16.sp 201 | ), 202 | decorationBox = { innerValue -> 203 | Box { 204 | if (linkEditTextState.isEmpty()) { 205 | Text( 206 | text = "+Adicionar link", 207 | color = Color.Gray.copy(alpha = 0.8f) 208 | ) 209 | } 210 | innerValue() 211 | } 212 | }, 213 | cursorBrush = SolidColor(MaterialTheme.colorScheme.surfaceVariant) 214 | ) 215 | 216 | } 217 | } 218 | } 219 | 220 | Spacer(modifier = Modifier.height(16.dp)) 221 | Button( 222 | onClick = { 223 | onSave( 224 | userAccount.copy( 225 | bio = bioEditTextState, 226 | link = linkEditTextState 227 | ) 228 | ) 229 | }, 230 | modifier = Modifier 231 | .fillMaxWidth(), 232 | shape = RoundedCornerShape(25), 233 | colors = ButtonDefaults.buttonColors( 234 | containerColor = Color.Black, 235 | contentColor = Color.White 236 | ) 237 | ) { 238 | Text( 239 | text = "Link Start!", 240 | style = MaterialTheme.typography.titleMedium.copy( 241 | color = Color.White, 242 | fontWeight = FontWeight.Bold 243 | ), 244 | modifier = Modifier.padding(16.dp) 245 | ) 246 | } 247 | } 248 | } 249 | } 250 | 251 | @Preview(showBackground = true) 252 | @Composable 253 | fun ProfileEditScreenPreview() { 254 | ProfileEditScreen( 255 | userAccount = UserAccount( 256 | name = "Nome", 257 | bio = "Bio", 258 | link = "Link", 259 | imageProfileUrl = "https://bit.ly/43CtVSz" 260 | ) 261 | ) 262 | } -------------------------------------------------------------------------------- /app/src/main/java/com/paradoxo/threadscompose/ui/search/SearchScreen.kt: -------------------------------------------------------------------------------- 1 | package com.paradoxo.threadscompose.ui.search 2 | 3 | import android.view.ViewTreeObserver 4 | import androidx.compose.foundation.ExperimentalFoundationApi 5 | import androidx.compose.foundation.background 6 | import androidx.compose.foundation.border 7 | import androidx.compose.foundation.clickable 8 | import androidx.compose.foundation.layout.Arrangement 9 | import androidx.compose.foundation.layout.Box 10 | import androidx.compose.foundation.layout.Column 11 | import androidx.compose.foundation.layout.Row 12 | import androidx.compose.foundation.layout.Spacer 13 | import androidx.compose.foundation.layout.fillMaxSize 14 | import androidx.compose.foundation.layout.fillMaxWidth 15 | import androidx.compose.foundation.layout.height 16 | import androidx.compose.foundation.layout.offset 17 | import androidx.compose.foundation.layout.padding 18 | import androidx.compose.foundation.layout.size 19 | import androidx.compose.foundation.layout.width 20 | import androidx.compose.foundation.lazy.LazyColumn 21 | import androidx.compose.foundation.lazy.items 22 | import androidx.compose.foundation.lazy.rememberLazyListState 23 | import androidx.compose.foundation.shape.CircleShape 24 | import androidx.compose.foundation.shape.RoundedCornerShape 25 | import androidx.compose.foundation.text.BasicTextField 26 | import androidx.compose.material.icons.Icons 27 | import androidx.compose.material.icons.filled.ArrowBack 28 | import androidx.compose.material.icons.filled.Close 29 | import androidx.compose.material.icons.filled.Search 30 | import androidx.compose.material3.Divider 31 | import androidx.compose.material3.Icon 32 | import androidx.compose.material3.MaterialTheme 33 | import androidx.compose.material3.Scaffold 34 | import androidx.compose.material3.Text 35 | import androidx.compose.runtime.Composable 36 | import androidx.compose.runtime.DisposableEffect 37 | import androidx.compose.runtime.LaunchedEffect 38 | import androidx.compose.runtime.derivedStateOf 39 | import androidx.compose.runtime.getValue 40 | import androidx.compose.runtime.mutableStateOf 41 | import androidx.compose.runtime.remember 42 | import androidx.compose.runtime.setValue 43 | import androidx.compose.ui.Alignment 44 | import androidx.compose.ui.Modifier 45 | import androidx.compose.ui.draw.alpha 46 | import androidx.compose.ui.draw.clip 47 | import androidx.compose.ui.graphics.Color 48 | import androidx.compose.ui.platform.LocalSoftwareKeyboardController 49 | import androidx.compose.ui.platform.LocalView 50 | import androidx.compose.ui.res.painterResource 51 | import androidx.compose.ui.text.TextStyle 52 | import androidx.compose.ui.text.font.FontWeight 53 | import androidx.compose.ui.tooling.preview.Preview 54 | import androidx.compose.ui.unit.dp 55 | import androidx.compose.ui.unit.sp 56 | import androidx.core.view.ViewCompat 57 | import androidx.core.view.WindowInsetsCompat 58 | import coil.compose.AsyncImage 59 | import com.paradoxo.threadscompose.R 60 | import com.paradoxo.threadscompose.model.UserAccount 61 | import com.paradoxo.threadscompose.sampleData.SampleData 62 | import com.paradoxo.threadscompose.ui.theme.ThreadsComposeTheme 63 | 64 | @OptIn(ExperimentalFoundationApi::class) 65 | @Composable 66 | fun SearchScreen( 67 | modifier: Modifier = Modifier, 68 | ) { 69 | val accountLists = SampleData().userAccounts 70 | val listState = rememberLazyListState() 71 | 72 | var showBackIcon by remember { 73 | mutableStateOf(false) 74 | } 75 | 76 | var showHeaderTitle by remember { 77 | mutableStateOf(false) 78 | } 79 | 80 | LaunchedEffect(showHeaderTitle) { 81 | if (showHeaderTitle) listState.animateScrollToItem(1) else listState.animateScrollToItem(0) 82 | } 83 | 84 | val alphaHeaderTitle by remember { 85 | derivedStateOf { 86 | val isFirstItem = listState.firstVisibleItemIndex == 0 87 | val offset = listState.firstVisibleItemScrollOffset 88 | 89 | if (isFirstItem) { 90 | if (offset > 0) { 91 | 1 - (offset / 100f).coerceIn(0f, 1f) 92 | } else { 93 | 1f 94 | } 95 | } else { 96 | 0f 97 | } 98 | } 99 | } 100 | 101 | Scaffold { paddingValues -> 102 | 103 | LazyColumn( 104 | state = listState, 105 | modifier = modifier 106 | .fillMaxSize() 107 | .padding(paddingValues) 108 | ) { 109 | 110 | item { 111 | Row( 112 | modifier = Modifier 113 | .padding(horizontal = 8.dp) 114 | .alpha(alphaHeaderTitle) 115 | ) { 116 | Text( 117 | text = "Pesquisar", 118 | style = MaterialTheme.typography.titleLarge.copy( 119 | fontWeight = FontWeight.Bold, 120 | fontSize = 32.sp, 121 | ), 122 | ) 123 | } 124 | } 125 | 126 | stickyHeader { 127 | SearchBar(showBackIcon = showBackIcon, 128 | onBackClick = { 129 | showHeaderTitle = it 130 | showBackIcon = it 131 | }) 132 | } 133 | 134 | items(accountLists) { account -> 135 | AccountItem(account) 136 | } 137 | } 138 | } 139 | } 140 | 141 | @Composable 142 | private fun SearchBar( 143 | modifier: Modifier = Modifier, 144 | onBackClick: (Boolean) -> Unit = {}, 145 | showBackIcon: Boolean = false, 146 | ) { 147 | var searchEditTextState by remember { 148 | mutableStateOf("") 149 | } 150 | 151 | val keyboard = LocalSoftwareKeyboardController.current 152 | LaunchedEffect(showBackIcon) { 153 | if (!showBackIcon) { 154 | keyboard?.hide() 155 | } 156 | onBackClick(showBackIcon) 157 | } 158 | 159 | val view = LocalView.current 160 | val viewTreeObserver = view.viewTreeObserver 161 | 162 | DisposableEffect(viewTreeObserver) { 163 | val listener = ViewTreeObserver.OnGlobalLayoutListener { 164 | val isKeyboardOpen = ViewCompat.getRootWindowInsets(view) 165 | ?.isVisible(WindowInsetsCompat.Type.ime()) ?: true 166 | if (isKeyboardOpen) { 167 | onBackClick(true) 168 | } 169 | } 170 | 171 | viewTreeObserver.addOnGlobalLayoutListener(listener) 172 | 173 | onDispose { 174 | if (viewTreeObserver.isAlive) { 175 | viewTreeObserver.removeOnGlobalLayoutListener(listener) 176 | } else { 177 | view.viewTreeObserver.removeOnGlobalLayoutListener(listener) 178 | } 179 | } 180 | } 181 | 182 | Row( 183 | modifier = modifier 184 | .fillMaxWidth() 185 | .background( 186 | MaterialTheme.colorScheme.background 187 | ), 188 | verticalAlignment = Alignment.CenterVertically, 189 | ) { 190 | if (showBackIcon) { 191 | Icon( 192 | Icons.Default.ArrowBack, 193 | contentDescription = "back", 194 | modifier = Modifier 195 | .padding(horizontal = 16.dp, vertical = 8.dp) 196 | .size(24.dp) 197 | .clickable { 198 | searchEditTextState = "" 199 | onBackClick(false) 200 | }, 201 | tint = Color.Gray, 202 | ) 203 | } 204 | 205 | BasicTextField( 206 | value = searchEditTextState, 207 | onValueChange = { 208 | searchEditTextState = it 209 | }, 210 | maxLines = 1, 211 | textStyle = TextStyle.Default.copy( 212 | fontSize = 18.sp, 213 | color = MaterialTheme.colorScheme.onBackground 214 | ), 215 | decorationBox = { innerValue -> 216 | Row( 217 | modifier = Modifier 218 | .fillMaxWidth() 219 | .padding(8.dp) 220 | .clip(RoundedCornerShape(8.dp)) 221 | .background(Color.LightGray.copy(alpha = 0.4f)), 222 | verticalAlignment = Alignment.CenterVertically, 223 | horizontalArrangement = Arrangement.SpaceBetween 224 | ) { 225 | Row( 226 | verticalAlignment = Alignment.CenterVertically, 227 | ) { 228 | Icon( 229 | Icons.Default.Search, 230 | contentDescription = "search", 231 | modifier = Modifier 232 | .padding(horizontal = 16.dp, vertical = 8.dp) 233 | .size(24.dp), 234 | tint = Color.Gray.copy(alpha = 0.8f) 235 | ) 236 | 237 | Box { 238 | if (searchEditTextState.isEmpty()) { 239 | Text( 240 | text = "Pesquisar", 241 | color = Color.Gray.copy(alpha = 0.8f), 242 | ) 243 | } 244 | innerValue() 245 | } 246 | } 247 | 248 | if (searchEditTextState.isNotEmpty()) { 249 | Icon( 250 | Icons.Default.Close, 251 | contentDescription = "close", 252 | modifier = Modifier 253 | .padding(horizontal = 16.dp, vertical = 8.dp) 254 | .size(24.dp) 255 | .clickable { 256 | searchEditTextState = "" 257 | }, 258 | tint = Color.Gray, 259 | ) 260 | } 261 | } 262 | } 263 | ) 264 | } 265 | } 266 | 267 | @Composable 268 | private fun AccountItem(account: UserAccount) { 269 | val dividerColor = Color.Gray.copy(alpha = 0.2f) 270 | 271 | Row( 272 | Modifier 273 | .fillMaxWidth() 274 | ) { 275 | Column( 276 | Modifier 277 | .padding(start = 8.dp, top = 8.dp, bottom = 8.dp) 278 | .weight(0.2f), 279 | horizontalAlignment = Alignment.CenterHorizontally 280 | ) { 281 | AsyncImage( 282 | model = account.imageProfileUrl, 283 | placeholder = painterResource(id = R.drawable.placeholder_image), 284 | error = painterResource(id = R.drawable.placeholder_image), 285 | contentDescription = "avatar", 286 | modifier = Modifier 287 | .padding(horizontal = 10.dp) 288 | .clip(CircleShape) 289 | ) 290 | } 291 | 292 | Column( 293 | Modifier 294 | .padding(vertical = 8.dp) 295 | .weight(0.5f) 296 | ) { 297 | 298 | Text( 299 | text = account.userName, 300 | fontWeight = FontWeight.Bold, 301 | ) 302 | 303 | Text( 304 | text = account.name, 305 | color = Color.Gray 306 | ) 307 | 308 | Spacer( 309 | modifier = Modifier.height(8.dp) 310 | ) 311 | 312 | Row( 313 | Modifier.fillMaxWidth(), 314 | verticalAlignment = Alignment.CenterVertically 315 | ) { 316 | Row { 317 | AsyncImage( 318 | model = R.drawable.profile_pic_emoji_1, 319 | placeholder = painterResource(id = R.drawable.placeholder_image), 320 | error = painterResource(id = R.drawable.placeholder_image), 321 | contentDescription = "avatar", 322 | modifier = Modifier 323 | .size(22.dp) 324 | .offset(x = (-2).dp) 325 | .border( 326 | width = 2.dp, 327 | color = Color.White, 328 | shape = CircleShape 329 | ) 330 | .clip(CircleShape) 331 | .padding(2.dp) 332 | ) 333 | 334 | AsyncImage( 335 | model = R.drawable.profile_pic_emoji_3, 336 | placeholder = painterResource(id = R.drawable.placeholder_image), 337 | error = painterResource(id = R.drawable.placeholder_image), 338 | contentDescription = "avatar", 339 | modifier = Modifier 340 | .size(22.dp) 341 | .offset(x = (-10).dp) 342 | .border( 343 | width = 2.dp, 344 | color = Color.White, 345 | shape = CircleShape 346 | ) 347 | .clip(CircleShape) 348 | .padding(2.dp) 349 | ) 350 | } 351 | 352 | Text( 353 | text = account.followers.size.toString(), 354 | ) 355 | Spacer(modifier = Modifier.width(4.dp)) 356 | Text( 357 | text = "Seguidores", 358 | ) 359 | } 360 | } 361 | 362 | Row( 363 | modifier = Modifier 364 | .fillMaxWidth() 365 | .padding(vertical = 8.dp, horizontal = 8.dp) 366 | .weight(0.3f), 367 | verticalAlignment = Alignment.CenterVertically, 368 | horizontalArrangement = Arrangement.Center 369 | ) { 370 | val isFollowing by remember { 371 | mutableStateOf(true) 372 | } 373 | 374 | val textIsFollowing = if (isFollowing) { 375 | "Seguindo" 376 | } else { 377 | "Seguir" 378 | } 379 | 380 | val textColorByIsFollowState = if (isFollowing) { 381 | Color.Gray 382 | } else { 383 | Color.Black 384 | } 385 | 386 | Box( 387 | contentAlignment = Alignment.Center, 388 | modifier = Modifier 389 | .fillMaxWidth() 390 | .background( 391 | color = Color.Transparent, 392 | shape = RoundedCornerShape(10.dp) 393 | ) 394 | .border( 395 | width = 1.dp, 396 | color = Color.LightGray, 397 | shape = RoundedCornerShape(10.dp) 398 | ) 399 | .clip(RoundedCornerShape(40)) 400 | 401 | ) { 402 | Text( 403 | text = textIsFollowing, 404 | fontWeight = FontWeight.Bold, 405 | color = textColorByIsFollowState, 406 | modifier = Modifier 407 | .padding(vertical = 6.dp), 408 | ) 409 | } 410 | } 411 | } 412 | 413 | Divider( 414 | color = dividerColor, 415 | modifier = Modifier 416 | .fillMaxWidth() 417 | ) 418 | } 419 | 420 | @Preview(showBackground = true) 421 | @Composable 422 | fun SearchScreenPreview() { 423 | ThreadsComposeTheme { 424 | SearchScreen() 425 | } 426 | } 427 | 428 | @Preview(showBackground = true) 429 | @Composable 430 | private fun AccountItemPreview() { 431 | AccountItem( 432 | SampleData().userAccounts.first() 433 | ) 434 | } -------------------------------------------------------------------------------- /app/src/main/java/com/paradoxo/threadscompose/ui/theme/Color.kt: -------------------------------------------------------------------------------- 1 | package com.paradoxo.threadscompose.ui.theme 2 | 3 | import androidx.compose.ui.graphics.Color 4 | 5 | val Purple80 = Color(0xFFD0BCFF) 6 | val PurpleGrey80 = Color(0xFFCCC2DC) 7 | val Pink80 = Color(0xFFEFB8C8) 8 | 9 | val Purple40 = Color(0xFF6650a4) 10 | val PurpleGrey40 = Color(0xFF625b71) 11 | val Pink40 = Color(0xFF7D5260) -------------------------------------------------------------------------------- /app/src/main/java/com/paradoxo/threadscompose/ui/theme/Theme.kt: -------------------------------------------------------------------------------- 1 | package com.paradoxo.threadscompose.ui.theme 2 | 3 | import android.app.Activity 4 | import android.os.Build 5 | import androidx.compose.material3.MaterialTheme 6 | import androidx.compose.material3.darkColorScheme 7 | import androidx.compose.material3.dynamicDarkColorScheme 8 | import androidx.compose.material3.dynamicLightColorScheme 9 | import androidx.compose.material3.lightColorScheme 10 | import androidx.compose.runtime.Composable 11 | import androidx.compose.runtime.SideEffect 12 | import androidx.compose.ui.platform.LocalContext 13 | import androidx.compose.ui.platform.LocalView 14 | import androidx.core.view.WindowCompat 15 | 16 | private val DarkColorScheme = darkColorScheme( 17 | primary = Purple80, 18 | secondary = PurpleGrey80, 19 | tertiary = Pink80 20 | ) 21 | 22 | private val LightColorScheme = lightColorScheme( 23 | primary = Purple40, 24 | secondary = PurpleGrey40, 25 | tertiary = Pink40 26 | 27 | /* Other default colors to override 28 | background = Color(0xFFFFFBFE), 29 | surface = Color(0xFFFFFBFE), 30 | onPrimary = Color.White, 31 | onSecondary = Color.White, 32 | onTertiary = Color.White, 33 | onBackground = Color(0xFF1C1B1F), 34 | onSurface = Color(0xFF1C1B1F), 35 | */ 36 | ) 37 | 38 | @Composable 39 | fun ThreadsComposeTheme( 40 | // darkTheme: Boolean = isSystemInDarkTheme(), 41 | darkTheme: Boolean = false, 42 | // Dynamic color is available on Android 12+ 43 | dynamicColor: Boolean = true, 44 | content: @Composable () -> Unit 45 | ) { 46 | val colorScheme = when { 47 | dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { 48 | val context = LocalContext.current 49 | if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) 50 | } 51 | 52 | darkTheme -> DarkColorScheme 53 | else -> LightColorScheme 54 | } 55 | val view = LocalView.current 56 | if (!view.isInEditMode) { 57 | SideEffect { 58 | val window = (view.context as Activity).window 59 | WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = true 60 | WindowCompat.setDecorFitsSystemWindows(window, false) 61 | } 62 | } 63 | 64 | MaterialTheme( 65 | colorScheme = colorScheme, 66 | typography = Typography, 67 | content = content 68 | ) 69 | } -------------------------------------------------------------------------------- /app/src/main/java/com/paradoxo/threadscompose/ui/theme/Type.kt: -------------------------------------------------------------------------------- 1 | package com.paradoxo.threadscompose.ui.theme 2 | 3 | import androidx.compose.material3.Typography 4 | import androidx.compose.ui.text.TextStyle 5 | import androidx.compose.ui.text.font.FontFamily 6 | import androidx.compose.ui.text.font.FontWeight 7 | import androidx.compose.ui.unit.sp 8 | 9 | // Set of Material typography styles to start with 10 | val Typography = Typography( 11 | bodyLarge = TextStyle( 12 | fontFamily = FontFamily.Default, 13 | fontWeight = FontWeight.Normal, 14 | fontSize = 16.sp, 15 | lineHeight = 24.sp, 16 | letterSpacing = 0.5.sp 17 | ) 18 | /* Other default text styles to override 19 | titleLarge = TextStyle( 20 | fontFamily = FontFamily.Default, 21 | fontWeight = FontWeight.Normal, 22 | fontSize = 22.sp, 23 | lineHeight = 28.sp, 24 | letterSpacing = 0.sp 25 | ), 26 | labelSmall = TextStyle( 27 | fontFamily = FontFamily.Default, 28 | fontWeight = FontWeight.Medium, 29 | fontSize = 11.sp, 30 | lineHeight = 16.sp, 31 | letterSpacing = 0.5.sp 32 | ) 33 | */ 34 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/paradoxo/threadscompose/utils/Extensions.kt: -------------------------------------------------------------------------------- 1 | package com.paradoxo.threadscompose.utils 2 | 3 | import android.content.Context 4 | import android.widget.Toast 5 | import androidx.compose.foundation.clickable 6 | import androidx.compose.foundation.interaction.MutableInteractionSource 7 | import androidx.compose.runtime.remember 8 | import androidx.compose.ui.Modifier 9 | import androidx.compose.ui.composed 10 | 11 | fun Context.showMessage(message: String) { 12 | Toast.makeText(this, message, Toast.LENGTH_SHORT).show() 13 | } 14 | 15 | fun Modifier.noRippleClickable(onClick: () -> Unit): Modifier = composed { 16 | clickable(indication = null, 17 | interactionSource = remember { MutableInteractionSource() }) { 18 | onClick() 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /app/src/main/java/com/paradoxo/threadscompose/utils/Utils.kt: -------------------------------------------------------------------------------- 1 | package com.paradoxo.threadscompose.utils 2 | 3 | import java.time.LocalDateTime 4 | import java.time.ZoneOffset 5 | 6 | fun getCurrentTime(): Long { 7 | return LocalDateTime.now().toEpochSecond(ZoneOffset.UTC) 8 | } 9 | 10 | fun formatTimeElapsed(start: Long, end: Long): String { 11 | val elapsedSeconds = end - start 12 | 13 | val secondsInMinute = 60 14 | val secondsInHour = 60 * secondsInMinute 15 | val secondsInDay = 24 * secondsInHour 16 | val secondsInWeek = 7 * secondsInDay 17 | val secondsInMonth = 30 * secondsInDay 18 | 19 | return when { 20 | elapsedSeconds < secondsInHour -> "${elapsedSeconds / secondsInMinute} min" 21 | elapsedSeconds < secondsInDay -> "${elapsedSeconds / secondsInHour} h" 22 | elapsedSeconds < secondsInWeek -> "${elapsedSeconds / secondsInDay} d" 23 | elapsedSeconds < secondsInMonth -> "${elapsedSeconds / secondsInWeek} sem" 24 | elapsedSeconds < secondsInMonth * 12 -> "${elapsedSeconds / secondsInMonth} m" 25 | else -> "${elapsedSeconds / (secondsInMonth * 12)} a" 26 | } 27 | } -------------------------------------------------------------------------------- /app/src/main/res/drawable-v24/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 15 | 18 | 21 | 22 | 23 | 24 | 30 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/bg_lines_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/git-jr/Threads-Jetpack-Compose/6d5ee19ce593bdcdecda8eb4b4dd1252da50bed4/app/src/main/res/drawable/bg_lines_1.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_attach_file.xml: -------------------------------------------------------------------------------- 1 | 7 | 8 | 11 | 12 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_circle.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_facebook.xml: -------------------------------------------------------------------------------- 1 | 6 | 13 | 20 | 27 | 34 | 41 | 48 | 55 | 62 | 69 | 76 | 83 | 90 | 97 | 104 | 111 | 118 | 125 | 132 | 139 | 146 | 153 | 160 | 167 | 174 | 181 | 188 | 195 | 202 | 209 | 216 | 223 | 230 | 237 | 244 | 251 | 258 | 265 | 272 | 279 | 284 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_heart.xml: -------------------------------------------------------------------------------- 1 | 6 | 13 | 20 | 27 | 34 | 41 | 48 | 55 | 62 | 69 | 76 | 83 | 90 | 97 | 104 | 111 | 118 | 125 | 132 | 139 | 146 | 153 | 160 | 167 | 174 | 181 | 188 | 195 | 200 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_insta.xml: -------------------------------------------------------------------------------- 1 | 6 | 13 | 20 | 27 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 10 | 15 | 20 | 25 | 30 | 35 | 40 | 45 | 50 | 55 | 60 | 65 | 70 | 75 | 80 | 85 | 90 | 95 | 100 | 105 | 110 | 115 | 120 | 125 | 130 | 135 | 140 | 145 | 150 | 155 | 160 | 165 | 170 | 171 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_menu_config.xml: -------------------------------------------------------------------------------- 1 | 6 | 13 | 20 | 27 | 34 | 41 | 48 | 55 | 62 | 69 | 76 | 83 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_reply.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/placeholder_image.xml: -------------------------------------------------------------------------------- 1 | 6 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/git-jr/Threads-Jetpack-Compose/6d5ee19ce593bdcdecda8eb4b4dd1252da50bed4/app/src/main/res/mipmap-hdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/git-jr/Threads-Jetpack-Compose/6d5ee19ce593bdcdecda8eb4b4dd1252da50bed4/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/git-jr/Threads-Jetpack-Compose/6d5ee19ce593bdcdecda8eb4b4dd1252da50bed4/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/git-jr/Threads-Jetpack-Compose/6d5ee19ce593bdcdecda8eb4b4dd1252da50bed4/app/src/main/res/mipmap-mdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/git-jr/Threads-Jetpack-Compose/6d5ee19ce593bdcdecda8eb4b4dd1252da50bed4/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/git-jr/Threads-Jetpack-Compose/6d5ee19ce593bdcdecda8eb4b4dd1252da50bed4/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/git-jr/Threads-Jetpack-Compose/6d5ee19ce593bdcdecda8eb4b4dd1252da50bed4/app/src/main/res/mipmap-xhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/git-jr/Threads-Jetpack-Compose/6d5ee19ce593bdcdecda8eb4b4dd1252da50bed4/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/git-jr/Threads-Jetpack-Compose/6d5ee19ce593bdcdecda8eb4b4dd1252da50bed4/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/git-jr/Threads-Jetpack-Compose/6d5ee19ce593bdcdecda8eb4b4dd1252da50bed4/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/git-jr/Threads-Jetpack-Compose/6d5ee19ce593bdcdecda8eb4b4dd1252da50bed4/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/git-jr/Threads-Jetpack-Compose/6d5ee19ce593bdcdecda8eb4b4dd1252da50bed4/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/git-jr/Threads-Jetpack-Compose/6d5ee19ce593bdcdecda8eb4b4dd1252da50bed4/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/git-jr/Threads-Jetpack-Compose/6d5ee19ce593bdcdecda8eb4b4dd1252da50bed4/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/git-jr/Threads-Jetpack-Compose/6d5ee19ce593bdcdecda8eb4b4dd1252da50bed4/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #FFBB86FC 4 | #FF6200EE 5 | #FF3700B3 6 | #FF03DAC5 7 | #FF018786 8 | #FF000000 9 | #FFFFFFFF 10 | -------------------------------------------------------------------------------- /app/src/main/res/values/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #000000 4 | -------------------------------------------------------------------------------- /app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | Threads Compose 3 | -------------------------------------------------------------------------------- /app/src/main/res/values/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 8 | -------------------------------------------------------------------------------- /app/src/main/res/xml/backup_rules.xml: -------------------------------------------------------------------------------- 1 | 8 | 9 | 13 | -------------------------------------------------------------------------------- /app/src/main/res/xml/data_extraction_rules.xml: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | 12 | 13 | 19 | -------------------------------------------------------------------------------- /app/src/test/java/com/paradoxo/threadscompose/ExampleUnitTest.kt: -------------------------------------------------------------------------------- 1 | package com.paradoxo.threadscompose 2 | 3 | import org.junit.Test 4 | 5 | import org.junit.Assert.* 6 | 7 | /** 8 | * Example local unit test, which will execute on the development machine (host). 9 | * 10 | * See [testing documentation](http://d.android.com/tools/testing). 11 | */ 12 | class ExampleUnitTest { 13 | @Test 14 | fun addition_isCorrect() { 15 | assertEquals(4, 2 + 2) 16 | } 17 | } -------------------------------------------------------------------------------- /build.gradle.kts: -------------------------------------------------------------------------------- 1 | // Top-level build file where you can add configuration options common to all sub-projects/modules. 2 | @Suppress("DSL_SCOPE_VIOLATION") // TODO: Remove once KTIJ-19369 is fixed 3 | plugins { 4 | alias(libs.plugins.androidApplication) apply false 5 | alias(libs.plugins.kotlinAndroid) apply false 6 | alias(libs.plugins.googleServices) apply false 7 | } 8 | true // Needed to make the Suppress annotation work for the plugins block -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | # Project-wide Gradle settings. 2 | # IDE (e.g. Android Studio) users: 3 | # Gradle settings configured through the IDE *will override* 4 | # any settings specified in this file. 5 | # For more details on how to configure your build environment visit 6 | # http://www.gradle.org/docs/current/userguide/build_environment.html 7 | # Specifies the JVM arguments used for the daemon process. 8 | # The setting is particularly useful for tweaking memory settings. 9 | org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 10 | # When configured, Gradle will run in incubating parallel mode. 11 | # This option should only be used with decoupled projects. More details, visit 12 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects 13 | # org.gradle.parallel=true 14 | # AndroidX package structure to make it clearer which packages are bundled with the 15 | # Android operating system, and which are packaged with your app's APK 16 | # https://developer.android.com/topic/libraries/support-library/androidx-rn 17 | android.useAndroidX=true 18 | # Kotlin code style for this project: "official" or "obsolete": 19 | kotlin.code.style=official 20 | # Enables namespacing of each library's R class so that its R class includes only the 21 | # resources declared in the library itself and none from the library's dependencies, 22 | # thereby reducing the size of the R class for that library 23 | android.nonTransitiveRClass=true -------------------------------------------------------------------------------- /gradle/libs.versions.toml: -------------------------------------------------------------------------------- 1 | [versions] 2 | agp = "8.2.0-alpha11" 3 | desugar_jdk_libs = "2.0.3" 4 | foundation = "1.6.0-alpha02" 5 | google-services = "4.3.15" 6 | kotlin = "1.8.10" 7 | core-ktx = "1.10.1" 8 | junit = "4.13.2" 9 | androidx-test-ext-junit = "1.1.5" 10 | espresso-core = "3.5.1" 11 | lifecycle-runtime-ktx = "2.6.1" 12 | activity-compose = "1.7.2" 13 | compose-bom = "2023.06.01" 14 | navigation-compose = "2.7.0-rc01" 15 | viewmodel-compose = "2.6.1" 16 | firebase-auth-ktx = "22.1.0" 17 | facebook-android-sdk = "16.1.3" 18 | coil-compose = "2.4.0" 19 | firebase-bom = "32.2.0" 20 | firebase-firestore-ktx = "24.7.0" 21 | firebase-storage-ktx = "20.2.1" 22 | lottie-compose = "6.1.0" 23 | play-services-auth = "20.7.0" 24 | 25 | 26 | [libraries] 27 | androidx-foundation = { module = "androidx.compose.foundation:foundation", version.ref = "foundation" } 28 | coil-compose = { module = "io.coil-kt:coil-compose", version.ref = "coil-compose" } 29 | firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebase-bom" } 30 | facebook-android-sdk = { module = "com.facebook.android:facebook-android-sdk", version.ref = "facebook-android-sdk" } 31 | google-services = { module = "com.google.gms:google-services", version.ref = "google-services" } 32 | 33 | 34 | lottie-compose = { module = "com.airbnb.android:lottie-compose", version.ref = "lottie-compose" } 35 | navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigation-compose" } 36 | lifecycle-viewmodel = { group = "androidx.lifecycle", name = "lifecycle-viewmodel", version.ref = "viewmodel-compose" } 37 | core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "core-ktx" } 38 | desugar_jdk_libs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugar_jdk_libs" } 39 | junit = { group = "junit", name = "junit", version.ref = "junit" } 40 | androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidx-test-ext-junit" } 41 | espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso-core" } 42 | lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle-runtime-ktx" } 43 | activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activity-compose" } 44 | compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" } 45 | ui = { group = "androidx.compose.ui", name = "ui" } 46 | ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } 47 | ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } 48 | ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } 49 | ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } 50 | ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } 51 | material3 = { group = "androidx.compose.material3", name = "material3" } 52 | firebase-auth-ktx = { group = "com.google.firebase", name = "firebase-auth-ktx", version.ref = "firebase-auth-ktx" } 53 | firebase-firestore-ktx = { group = "com.google.firebase", name = "firebase-firestore-ktx", version.ref = "firebase-firestore-ktx" } 54 | firebase-storage-ktx = { group = "com.google.firebase", name = "firebase-storage-ktx", version.ref = "firebase-storage-ktx" } 55 | play-services-auth = { group = "com.google.android.gms", name = "play-services-auth", version.ref = "play-services-auth" } 56 | 57 | 58 | 59 | [plugins] 60 | androidApplication = { id = "com.android.application", version.ref = "agp" } 61 | kotlinAndroid = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } 62 | googleServices = { id = "com.google.gms.google-services", version.ref = "google-services" } 63 | [bundles] 64 | 65 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/git-jr/Threads-Jetpack-Compose/6d5ee19ce593bdcdecda8eb4b4dd1252da50bed4/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Tue Jul 11 23:21:02 BRT 2023 2 | distributionBase=GRADLE_USER_HOME 3 | distributionPath=wrapper/dists 4 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.1-bin.zip 5 | zipStoreBase=GRADLE_USER_HOME 6 | zipStorePath=wrapper/dists 7 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | # 4 | # Copyright 2015 the original author or authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | ## 21 | ## Gradle start up script for UN*X 22 | ## 23 | ############################################################################## 24 | 25 | # Attempt to set APP_HOME 26 | # Resolve links: $0 may be a link 27 | PRG="$0" 28 | # Need this for relative symlinks. 29 | while [ -h "$PRG" ] ; do 30 | ls=`ls -ld "$PRG"` 31 | link=`expr "$ls" : '.*-> \(.*\)$'` 32 | if expr "$link" : '/.*' > /dev/null; then 33 | PRG="$link" 34 | else 35 | PRG=`dirname "$PRG"`"/$link" 36 | fi 37 | done 38 | SAVED="`pwd`" 39 | cd "`dirname \"$PRG\"`/" >/dev/null 40 | APP_HOME="`pwd -P`" 41 | cd "$SAVED" >/dev/null 42 | 43 | APP_NAME="Gradle" 44 | APP_BASE_NAME=`basename "$0"` 45 | 46 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 47 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 48 | 49 | # Use the maximum available, or set MAX_FD != -1 to use that value. 50 | MAX_FD="maximum" 51 | 52 | warn () { 53 | echo "$*" 54 | } 55 | 56 | die () { 57 | echo 58 | echo "$*" 59 | echo 60 | exit 1 61 | } 62 | 63 | # OS specific support (must be 'true' or 'false'). 64 | cygwin=false 65 | msys=false 66 | darwin=false 67 | nonstop=false 68 | case "`uname`" in 69 | CYGWIN* ) 70 | cygwin=true 71 | ;; 72 | Darwin* ) 73 | darwin=true 74 | ;; 75 | MINGW* ) 76 | msys=true 77 | ;; 78 | NONSTOP* ) 79 | nonstop=true 80 | ;; 81 | esac 82 | 83 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 84 | 85 | 86 | # Determine the Java command to use to start the JVM. 87 | if [ -n "$JAVA_HOME" ] ; then 88 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 89 | # IBM's JDK on AIX uses strange locations for the executables 90 | JAVACMD="$JAVA_HOME/jre/sh/java" 91 | else 92 | JAVACMD="$JAVA_HOME/bin/java" 93 | fi 94 | if [ ! -x "$JAVACMD" ] ; then 95 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 96 | 97 | Please set the JAVA_HOME variable in your environment to match the 98 | location of your Java installation." 99 | fi 100 | else 101 | JAVACMD="java" 102 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 103 | 104 | Please set the JAVA_HOME variable in your environment to match the 105 | location of your Java installation." 106 | fi 107 | 108 | # Increase the maximum file descriptors if we can. 109 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 110 | MAX_FD_LIMIT=`ulimit -H -n` 111 | if [ $? -eq 0 ] ; then 112 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 113 | MAX_FD="$MAX_FD_LIMIT" 114 | fi 115 | ulimit -n $MAX_FD 116 | if [ $? -ne 0 ] ; then 117 | warn "Could not set maximum file descriptor limit: $MAX_FD" 118 | fi 119 | else 120 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 121 | fi 122 | fi 123 | 124 | # For Darwin, add options to specify how the application appears in the dock 125 | if $darwin; then 126 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 127 | fi 128 | 129 | # For Cygwin or MSYS, switch paths to Windows format before running java 130 | if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then 131 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 132 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 133 | 134 | JAVACMD=`cygpath --unix "$JAVACMD"` 135 | 136 | # We build the pattern for arguments to be converted via cygpath 137 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 138 | SEP="" 139 | for dir in $ROOTDIRSRAW ; do 140 | ROOTDIRS="$ROOTDIRS$SEP$dir" 141 | SEP="|" 142 | done 143 | OURCYGPATTERN="(^($ROOTDIRS))" 144 | # Add a user-defined pattern to the cygpath arguments 145 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 146 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 147 | fi 148 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 149 | i=0 150 | for arg in "$@" ; do 151 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 152 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 153 | 154 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 155 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 156 | else 157 | eval `echo args$i`="\"$arg\"" 158 | fi 159 | i=`expr $i + 1` 160 | done 161 | case $i in 162 | 0) set -- ;; 163 | 1) set -- "$args0" ;; 164 | 2) set -- "$args0" "$args1" ;; 165 | 3) set -- "$args0" "$args1" "$args2" ;; 166 | 4) set -- "$args0" "$args1" "$args2" "$args3" ;; 167 | 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 168 | 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 169 | 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 170 | 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 171 | 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 172 | esac 173 | fi 174 | 175 | # Escape application args 176 | save () { 177 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 178 | echo " " 179 | } 180 | APP_ARGS=`save "$@"` 181 | 182 | # Collect all arguments for the java command, following the shell quoting and substitution rules 183 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 184 | 185 | exec "$JAVACMD" "$@" 186 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%" == "" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%" == "" set DIRNAME=. 29 | set APP_BASE_NAME=%~n0 30 | set APP_HOME=%DIRNAME% 31 | 32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 34 | 35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 37 | 38 | @rem Find java.exe 39 | if defined JAVA_HOME goto findJavaFromJavaHome 40 | 41 | set JAVA_EXE=java.exe 42 | %JAVA_EXE% -version >NUL 2>&1 43 | if "%ERRORLEVEL%" == "0" goto execute 44 | 45 | echo. 46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 47 | echo. 48 | echo Please set the JAVA_HOME variable in your environment to match the 49 | echo location of your Java installation. 50 | 51 | goto fail 52 | 53 | :findJavaFromJavaHome 54 | set JAVA_HOME=%JAVA_HOME:"=% 55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 56 | 57 | if exist "%JAVA_EXE%" goto execute 58 | 59 | echo. 60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 61 | echo. 62 | echo Please set the JAVA_HOME variable in your environment to match the 63 | echo location of your Java installation. 64 | 65 | goto fail 66 | 67 | :execute 68 | @rem Setup the command line 69 | 70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 71 | 72 | 73 | @rem Execute Gradle 74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 75 | 76 | :end 77 | @rem End local scope for the variables with windows NT shell 78 | if "%ERRORLEVEL%"=="0" goto mainEnd 79 | 80 | :fail 81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 82 | rem the _cmd.exe /c_ return code! 83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 84 | exit /b 1 85 | 86 | :mainEnd 87 | if "%OS%"=="Windows_NT" endlocal 88 | 89 | :omega 90 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | pluginManagement { 2 | repositories { 3 | google() 4 | mavenCentral() 5 | gradlePluginPortal() 6 | } 7 | } 8 | dependencyResolutionManagement { 9 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) 10 | repositories { 11 | google() 12 | mavenCentral() 13 | } 14 | } 15 | 16 | rootProject.name = "Threads Compose" 17 | include(":app") 18 | --------------------------------------------------------------------------------