├── .gitignore ├── .idea ├── .gitignore ├── .name ├── codeStyles │ ├── Project.xml │ └── codeStyleConfig.xml ├── compiler.xml ├── google-java-format.xml ├── gradle.xml ├── inspectionProfiles │ └── Project_Default.xml ├── jarRepositories.xml ├── misc.xml ├── render.experimental.xml ├── runConfigurations.xml └── vcs.xml ├── README.md ├── api ├── .gitignore ├── build.gradle └── src │ ├── main │ └── java │ │ └── io │ │ └── realworld │ │ └── api │ │ ├── ConduitClient.kt │ │ ├── models │ │ ├── entities │ │ │ ├── Article.kt │ │ │ ├── ArticleData.kt │ │ │ ├── Comment.kt │ │ │ ├── Errors.kt │ │ │ ├── LoginData.kt │ │ │ ├── Profile.kt │ │ │ ├── SignupData.kt │ │ │ ├── User.kt │ │ │ └── UserUpdateData.kt │ │ ├── requests │ │ │ ├── CreateArticleRequest.kt │ │ │ ├── LoginRequest.kt │ │ │ ├── SignupRequest.kt │ │ │ ├── UpsertArticleRequest.kt │ │ │ └── UserUpdateRequest.kt │ │ └── responses │ │ │ ├── ArticleResponse.kt │ │ │ ├── ArticlesResponse.kt │ │ │ ├── CommentResponse.kt │ │ │ ├── CommentsResponse.kt │ │ │ ├── ErrorResponse.kt │ │ │ ├── ProfileResponse.kt │ │ │ ├── TagsResponse.kt │ │ │ └── UserResponse.kt │ │ └── services │ │ ├── ConduitAPI.kt │ │ └── ConduitAuthAPI.kt │ └── test │ └── java │ └── io │ └── realworld │ └── api │ └── ConduitClientTests.kt ├── app ├── .gitignore ├── build.gradle ├── proguard-rules.pro └── src │ ├── androidTest │ └── java │ │ └── io │ │ └── realworld │ │ └── android │ │ └── ExampleInstrumentedTest.kt │ ├── main │ ├── AndroidManifest.xml │ ├── java │ │ └── io │ │ │ └── realworld │ │ │ └── android │ │ │ ├── AuthViewModel.kt │ │ │ ├── MainActivity.kt │ │ │ ├── data │ │ │ ├── ArticlesRepo.kt │ │ │ └── UserRepo.kt │ │ │ ├── extensions │ │ │ ├── ImageView.kt │ │ │ └── TextView.kt │ │ │ └── ui │ │ │ ├── article │ │ │ ├── ArticleFragment.kt │ │ │ ├── ArticleViewModel.kt │ │ │ └── CreateArticleFragment.kt │ │ │ ├── auth │ │ │ ├── AuthFragment.kt │ │ │ ├── LoginFragment.kt │ │ │ └── SignupFragment.kt │ │ │ ├── feed │ │ │ ├── ArticleFeedAdapter.kt │ │ │ ├── FeedViewModel.kt │ │ │ ├── GlobalFeedFragment.kt │ │ │ └── MyFeedFragment.kt │ │ │ └── settings │ │ │ └── SettingsFragment.kt │ └── res │ │ ├── drawable-v24 │ │ └── ic_launcher_foreground.xml │ │ ├── drawable │ │ ├── ic_create_article.xml │ │ ├── ic_launcher_background.xml │ │ ├── ic_menu_auth.xml │ │ ├── ic_menu_feed.xml │ │ ├── ic_menu_login.xml │ │ ├── ic_menu_settings.xml │ │ ├── ic_menu_signup.xml │ │ ├── ic_my_feed.xml │ │ └── side_nav_bar.xml │ │ ├── font │ │ ├── source_sans_pro_light.xml │ │ ├── source_sans_pro_semibold.xml │ │ └── titillium_web_bold.xml │ │ ├── layout │ │ ├── activity_main.xml │ │ ├── app_bar_main.xml │ │ ├── content_main.xml │ │ ├── fragment_article.xml │ │ ├── fragment_auth.xml │ │ ├── fragment_create_article.xml │ │ ├── fragment_feed.xml │ │ ├── fragment_login_signup.xml │ │ ├── fragment_settings.xml │ │ ├── list_item_article.xml │ │ └── nav_header_main.xml │ │ ├── menu │ │ ├── main.xml │ │ ├── menu_main_guest.xml │ │ └── menu_main_user.xml │ │ ├── mipmap-anydpi-v26 │ │ ├── ic_launcher.xml │ │ └── ic_launcher_round.xml │ │ ├── mipmap-hdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-mdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xxhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xxxhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── navigation │ │ ├── navigation_auth.xml │ │ └── navigation_main.xml │ │ ├── values-night │ │ └── themes.xml │ │ └── values │ │ ├── colors.xml │ │ ├── dimens.xml │ │ ├── font_certs.xml │ │ ├── preloaded_fonts.xml │ │ ├── strings.xml │ │ └── themes.xml │ └── test │ └── java │ └── io │ └── realworld │ └── android │ └── ExampleUnitTest.kt ├── build.gradle ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat └── settings.gradle /.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 | -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | -------------------------------------------------------------------------------- /.idea/.name: -------------------------------------------------------------------------------- 1 | Conduit -------------------------------------------------------------------------------- /.idea/codeStyles/Project.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 11 | 20 | 22 | 23 | 25 | 26 | 27 | 28 |
29 | 30 | 31 | 32 | xmlns:android 33 | 34 | ^$ 35 | 36 | 37 | 38 |
39 |
40 | 41 | 42 | 43 | xmlns:.* 44 | 45 | ^$ 46 | 47 | 48 | BY_NAME 49 | 50 |
51 |
52 | 53 | 54 | 55 | .*:id 56 | 57 | http://schemas.android.com/apk/res/android 58 | 59 | 60 | 61 |
62 |
63 | 64 | 65 | 66 | .*:name 67 | 68 | http://schemas.android.com/apk/res/android 69 | 70 | 71 | 72 |
73 |
74 | 75 | 76 | 77 | name 78 | 79 | ^$ 80 | 81 | 82 | 83 |
84 |
85 | 86 | 87 | 88 | style 89 | 90 | ^$ 91 | 92 | 93 | 94 |
95 |
96 | 97 | 98 | 99 | .* 100 | 101 | ^$ 102 | 103 | 104 | BY_NAME 105 | 106 |
107 |
108 | 109 | 110 | 111 | .* 112 | 113 | http://schemas.android.com/apk/res/android 114 | 115 | 116 | ANDROID_ATTRIBUTE_ORDER 117 | 118 |
119 |
120 | 121 | 122 | 123 | .* 124 | 125 | .* 126 | 127 | 128 | BY_NAME 129 | 130 |
131 |
132 |
133 |
134 | 135 | 137 |
138 |
-------------------------------------------------------------------------------- /.idea/codeStyles/codeStyleConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /.idea/compiler.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /.idea/google-java-format.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /.idea/gradle.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | -------------------------------------------------------------------------------- /.idea/jarRepositories.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 10 | 14 | 15 | 19 | 20 | 24 | 25 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 9 | -------------------------------------------------------------------------------- /.idea/render.experimental.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /.idea/runConfigurations.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 10 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![](https://cloud.githubusercontent.com/assets/556934/25672246/9a20e960-2fe7-11e7-99d3-23652878a2c2.png) 2 | 3 | > ### Android/Kotlin codebase containing real world examples (CRUD, auth, advanced patterns, etc) that adheres to the [RealWorld](https://github.com/gothinkster/realworld) spec and API. 4 | 5 | This codebase was created to demonstrate a fully fledged fullstack application built 6 | with **Kotlin** including CRUD operations, authentication, routing, pagination, and more. 7 | 8 | See how a Medium.com clone (called Conduit) is built using Kotlin in Android to connect 9 | to any other backend from https://realworld.io/. 10 | 11 | For more information on how to this works with other backends, head over to 12 | the [RealWorld](https://github.com/gothinkster/realworld) repo. 13 | 14 | I've gone to great lengths to adhere to the latest community styleguides & 15 | best practices but had to adapt between the RealWorld specification 16 | and general mobile layout of Medium.com. 17 | 18 | ### Development 19 | This project has been developed with [Android Studio](https://developer.android.com/studio/) 20 | 21 | ### Concepts 22 | This RealWorld app tries to show the following Android concepts: 23 | * 100% Kotlin Codebase 24 | * MVVM (Model View ViewModel) Architecture 25 | * LiveData 26 | * Kotlin Coroutines 27 | * Jetpack Navigation Architecture 28 | 29 | ### Architecture 30 | The project follows the general MVVM structure without any specifics. 31 | 32 | There are two _modules_ in the project 33 | 34 | * `app` - The UI of the app. The main project that forms the APK 35 | * `api` - The REST API consumption library. Pure JVM library not Android-specific 36 | 37 | ### Other Backends 38 | Obviously, this RealWorld app is a frontend app. But it can connect to all backends implementing the [RealWorld](https://github.com/gothinkster/realworld) spec and API. To test you own backend implementation just change the URL in the settings dialog. 39 | 40 | ## Testing 41 | This project has been manually tested against 42 | * Emulator 43 | * Pixel 2 Android SDK 23 44 | * Devices 45 | * Samsung S8 Android 8.0.0 46 | 47 | ### Automated tests 48 | The project contains an example e2e test to illustrate an end-to-end test case. 49 | 50 | ## License & Credits 51 | Credits have to go out to [Thinkster](https://thinkster.io/) with their awesome [RealWorld](https://github.com/gothinkster/realworld) 52 | 53 | This project is licensed under the MIT license. 54 | 55 | ## Disclaimer 56 | This source and the whole package comes without warranty. It may or may not harm your computer or cell phone. Please use with care. Any damage cannot be related back to the author. The source has been tested on a virtual environment and scanned for viruses and has passed all tests. 57 | -------------------------------------------------------------------------------- /api/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /api/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id 'java-library' 3 | id 'kotlin' 4 | id 'kotlin-kapt' 5 | } 6 | 7 | java { 8 | sourceCompatibility = JavaVersion.VERSION_11 9 | targetCompatibility = JavaVersion.VERSION_11 10 | } 11 | 12 | dependencies { 13 | implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" 14 | implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.4.2" 15 | api "com.squareup.retrofit2:retrofit:$retrofit_version" 16 | implementation("com.squareup.okhttp3:okhttp:4.9.0") 17 | implementation "com.squareup.retrofit2:converter-moshi:$retrofit_version" 18 | implementation "com.squareup.moshi:moshi:$moshi_version" 19 | kapt "com.squareup.moshi:moshi-kotlin-codegen:$moshi_version" 20 | 21 | // test 22 | testImplementation 'junit:junit:4.13.1' 23 | 24 | } -------------------------------------------------------------------------------- /api/src/main/java/io/realworld/api/ConduitClient.kt: -------------------------------------------------------------------------------- 1 | package io.realworld.api 2 | 3 | import io.realworld.api.services.ConduitAPI 4 | import io.realworld.api.services.ConduitAuthAPI 5 | import okhttp3.Interceptor 6 | import okhttp3.OkHttpClient 7 | import retrofit2.Retrofit 8 | import retrofit2.converter.moshi.MoshiConverterFactory 9 | import java.util.concurrent.TimeUnit 10 | 11 | object ConduitClient { 12 | 13 | var authToken: String? = null 14 | 15 | private val authInterceptor = Interceptor { chain -> 16 | var req = chain.request() 17 | authToken?.let { 18 | req = req.newBuilder() 19 | .header("Authorization", "Token $it") 20 | .build() 21 | } 22 | chain.proceed(req) 23 | } 24 | 25 | val okHttpBuilder = OkHttpClient.Builder() 26 | .readTimeout(5, TimeUnit.SECONDS) 27 | .connectTimeout(2, TimeUnit.SECONDS) 28 | 29 | val retrofitBuilder = Retrofit.Builder() 30 | .baseUrl("https://conduit.productionready.io/api/") 31 | .addConverterFactory(MoshiConverterFactory.create()) 32 | 33 | val publicApi = retrofitBuilder 34 | .client(okHttpBuilder.build()) 35 | .build() 36 | .create(ConduitAPI::class.java) 37 | 38 | val authApi = retrofitBuilder 39 | .client(okHttpBuilder.addInterceptor(authInterceptor).build()) 40 | .build() 41 | .create(ConduitAuthAPI::class.java) 42 | 43 | } -------------------------------------------------------------------------------- /api/src/main/java/io/realworld/api/models/entities/Article.kt: -------------------------------------------------------------------------------- 1 | package io.realworld.api.models.entities 2 | 3 | 4 | import com.squareup.moshi.Json 5 | import com.squareup.moshi.JsonClass 6 | 7 | @JsonClass(generateAdapter = true) 8 | data class Article( 9 | @Json(name = "author") 10 | val author: Profile, 11 | @Json(name = "body") 12 | val body: String, 13 | @Json(name = "createdAt") 14 | val createdAt: String, 15 | @Json(name = "description") 16 | val description: String, 17 | @Json(name = "favorited") 18 | val favorited: Boolean, 19 | @Json(name = "favoritesCount") 20 | val favoritesCount: Int, 21 | @Json(name = "slug") 22 | val slug: String, 23 | @Json(name = "tagList") 24 | val tagList: List, 25 | @Json(name = "title") 26 | val title: String, 27 | @Json(name = "updatedAt") 28 | val updatedAt: String 29 | ) -------------------------------------------------------------------------------- /api/src/main/java/io/realworld/api/models/entities/ArticleData.kt: -------------------------------------------------------------------------------- 1 | package io.realworld.api.models.entities 2 | 3 | 4 | import com.squareup.moshi.Json 5 | import com.squareup.moshi.JsonClass 6 | 7 | @JsonClass(generateAdapter = true) 8 | data class ArticleData( 9 | @Json(name = "body") 10 | val body: String?=null, 11 | @Json(name = "description") 12 | val description: String?=null, 13 | @Json(name = "tagList") 14 | val tagList: List?=null, 15 | @Json(name = "title") 16 | val title: String?=null 17 | ) -------------------------------------------------------------------------------- /api/src/main/java/io/realworld/api/models/entities/Comment.kt: -------------------------------------------------------------------------------- 1 | package io.realworld.api.models.entities 2 | 3 | 4 | import com.squareup.moshi.Json 5 | import com.squareup.moshi.JsonClass 6 | 7 | @JsonClass(generateAdapter = true) 8 | data class Comment( 9 | @Json(name = "author") 10 | val author: Profile, 11 | @Json(name = "body") 12 | val body: String, 13 | @Json(name = "createdAt") 14 | val createdAt: String, 15 | @Json(name = "id") 16 | val id: Int, 17 | @Json(name = "updatedAt") 18 | val updatedAt: String 19 | ) -------------------------------------------------------------------------------- /api/src/main/java/io/realworld/api/models/entities/Errors.kt: -------------------------------------------------------------------------------- 1 | package io.realworld.api.models.entities 2 | 3 | 4 | import com.squareup.moshi.Json 5 | import com.squareup.moshi.JsonClass 6 | 7 | @JsonClass(generateAdapter = true) 8 | data class Errors( 9 | @Json(name = "body") 10 | val body: List 11 | ) -------------------------------------------------------------------------------- /api/src/main/java/io/realworld/api/models/entities/LoginData.kt: -------------------------------------------------------------------------------- 1 | package io.realworld.api.models.entities 2 | 3 | 4 | import com.squareup.moshi.Json 5 | import com.squareup.moshi.JsonClass 6 | 7 | @JsonClass(generateAdapter = true) 8 | data class LoginData( 9 | @Json(name = "email") 10 | val email: String, 11 | @Json(name = "password") 12 | val password: String, 13 | ) -------------------------------------------------------------------------------- /api/src/main/java/io/realworld/api/models/entities/Profile.kt: -------------------------------------------------------------------------------- 1 | package io.realworld.api.models.entities 2 | 3 | 4 | import com.squareup.moshi.Json 5 | import com.squareup.moshi.JsonClass 6 | 7 | @JsonClass(generateAdapter = true) 8 | data class Profile( 9 | @Json(name = "bio") 10 | val bio: String?, 11 | @Json(name = "following") 12 | val following: Boolean, 13 | @Json(name = "image") 14 | val image: String, 15 | @Json(name = "username") 16 | val username: String 17 | ) -------------------------------------------------------------------------------- /api/src/main/java/io/realworld/api/models/entities/SignupData.kt: -------------------------------------------------------------------------------- 1 | package io.realworld.api.models.entities 2 | 3 | 4 | import com.squareup.moshi.Json 5 | import com.squareup.moshi.JsonClass 6 | 7 | @JsonClass(generateAdapter = true) 8 | data class SignupData( 9 | @Json(name = "email") 10 | val email: String, 11 | @Json(name = "password") 12 | val password: String, 13 | @Json(name = "username") 14 | val username: String 15 | ) -------------------------------------------------------------------------------- /api/src/main/java/io/realworld/api/models/entities/User.kt: -------------------------------------------------------------------------------- 1 | package io.realworld.api.models.entities 2 | 3 | 4 | import com.squareup.moshi.Json 5 | import com.squareup.moshi.JsonClass 6 | 7 | @JsonClass(generateAdapter = true) 8 | data class User( 9 | @Json(name = "bio") 10 | val bio: String?, 11 | @Json(name = "email") 12 | val email: String, 13 | @Json(name = "image") 14 | val image: String?, 15 | @Json(name = "token") 16 | val token: String, 17 | @Json(name = "username") 18 | val username: String 19 | ) -------------------------------------------------------------------------------- /api/src/main/java/io/realworld/api/models/entities/UserUpdateData.kt: -------------------------------------------------------------------------------- 1 | package io.realworld.api.models.entities 2 | 3 | 4 | import com.squareup.moshi.Json 5 | import com.squareup.moshi.JsonClass 6 | 7 | @JsonClass(generateAdapter = true) 8 | data class UserUpdateData( 9 | @Json(name = "bio") 10 | val bio: String?, 11 | @Json(name = "email") 12 | val email: String?, 13 | @Json(name = "image") 14 | val image: String?, 15 | @Json(name = "username") 16 | val username: String?, 17 | @Json(name = "password") 18 | val password: String? 19 | ) -------------------------------------------------------------------------------- /api/src/main/java/io/realworld/api/models/requests/CreateArticleRequest.kt: -------------------------------------------------------------------------------- 1 | package io.realworld.api.models.requests 2 | 3 | 4 | import com.squareup.moshi.Json 5 | import com.squareup.moshi.JsonClass 6 | import io.realworld.api.models.entities.ArticleData 7 | 8 | @JsonClass(generateAdapter = true) 9 | data class CreateArticleRequest( 10 | @Json(name = "article") 11 | val article: ArticleData 12 | ) -------------------------------------------------------------------------------- /api/src/main/java/io/realworld/api/models/requests/LoginRequest.kt: -------------------------------------------------------------------------------- 1 | package io.realworld.api.models.requests 2 | 3 | 4 | import com.squareup.moshi.Json 5 | import com.squareup.moshi.JsonClass 6 | import io.realworld.api.models.entities.LoginData 7 | import io.realworld.api.models.entities.SignupData 8 | 9 | @JsonClass(generateAdapter = true) 10 | data class LoginRequest( 11 | @Json(name = "user") 12 | val user: LoginData 13 | ) -------------------------------------------------------------------------------- /api/src/main/java/io/realworld/api/models/requests/SignupRequest.kt: -------------------------------------------------------------------------------- 1 | package io.realworld.api.models.requests 2 | 3 | 4 | import com.squareup.moshi.Json 5 | import com.squareup.moshi.JsonClass 6 | import io.realworld.api.models.entities.SignupData 7 | 8 | @JsonClass(generateAdapter = true) 9 | data class SignupRequest( 10 | @Json(name = "user") 11 | val user: SignupData 12 | ) -------------------------------------------------------------------------------- /api/src/main/java/io/realworld/api/models/requests/UpsertArticleRequest.kt: -------------------------------------------------------------------------------- 1 | package io.realworld.api.models.requests 2 | 3 | import com.squareup.moshi.Json 4 | import com.squareup.moshi.JsonClass 5 | import io.realworld.api.models.entities.Article 6 | import io.realworld.api.models.entities.ArticleData 7 | 8 | 9 | @JsonClass(generateAdapter = true) 10 | data class UpsertArticleRequest( 11 | @Json(name ="article") 12 | val article : ArticleData 13 | ) -------------------------------------------------------------------------------- /api/src/main/java/io/realworld/api/models/requests/UserUpdateRequest.kt: -------------------------------------------------------------------------------- 1 | package io.realworld.api.models.requests 2 | 3 | import com.squareup.moshi.Json 4 | import com.squareup.moshi.JsonClass 5 | import io.realworld.api.models.entities.UserUpdateData 6 | 7 | @JsonClass(generateAdapter = true) 8 | data class UserUpdateRequest( 9 | @Json(name = "user") 10 | val user: UserUpdateData 11 | ) -------------------------------------------------------------------------------- /api/src/main/java/io/realworld/api/models/responses/ArticleResponse.kt: -------------------------------------------------------------------------------- 1 | package io.realworld.api.models.responses 2 | 3 | 4 | import com.squareup.moshi.Json 5 | import com.squareup.moshi.JsonClass 6 | import io.realworld.api.models.entities.Article 7 | 8 | @JsonClass(generateAdapter = true) 9 | data class ArticleResponse( 10 | @Json(name = "article") 11 | val article: Article 12 | ) -------------------------------------------------------------------------------- /api/src/main/java/io/realworld/api/models/responses/ArticlesResponse.kt: -------------------------------------------------------------------------------- 1 | package io.realworld.api.models.responses 2 | 3 | 4 | import com.squareup.moshi.Json 5 | import com.squareup.moshi.JsonClass 6 | import io.realworld.api.models.entities.Article 7 | 8 | @JsonClass(generateAdapter = true) 9 | data class ArticlesResponse( 10 | @Json(name = "articles") 11 | val articles: List
, 12 | @Json(name = "articlesCount") 13 | val articlesCount: Int 14 | ) -------------------------------------------------------------------------------- /api/src/main/java/io/realworld/api/models/responses/CommentResponse.kt: -------------------------------------------------------------------------------- 1 | package io.realworld.api.models.responses 2 | 3 | 4 | import com.squareup.moshi.Json 5 | import com.squareup.moshi.JsonClass 6 | import io.realworld.api.models.entities.Comment 7 | 8 | @JsonClass(generateAdapter = true) 9 | data class CommentResponse( 10 | @Json(name = "comment") 11 | val comments: Comment 12 | ) -------------------------------------------------------------------------------- /api/src/main/java/io/realworld/api/models/responses/CommentsResponse.kt: -------------------------------------------------------------------------------- 1 | package io.realworld.api.models.responses 2 | 3 | 4 | import com.squareup.moshi.Json 5 | import com.squareup.moshi.JsonClass 6 | import io.realworld.api.models.entities.Comment 7 | 8 | @JsonClass(generateAdapter = true) 9 | data class CommentsResponse( 10 | @Json(name = "comments") 11 | val comments: List 12 | ) -------------------------------------------------------------------------------- /api/src/main/java/io/realworld/api/models/responses/ErrorResponse.kt: -------------------------------------------------------------------------------- 1 | package io.realworld.api.models.responses 2 | 3 | 4 | import com.squareup.moshi.Json 5 | import com.squareup.moshi.JsonClass 6 | import io.realworld.api.models.entities.Errors 7 | 8 | @JsonClass(generateAdapter = true) 9 | data class ErrorResponse( 10 | @Json(name = "errors") 11 | val errors: Errors 12 | ) -------------------------------------------------------------------------------- /api/src/main/java/io/realworld/api/models/responses/ProfileResponse.kt: -------------------------------------------------------------------------------- 1 | package io.realworld.api.models.responses 2 | 3 | 4 | import com.squareup.moshi.Json 5 | import com.squareup.moshi.JsonClass 6 | import io.realworld.api.models.entities.Profile 7 | 8 | @JsonClass(generateAdapter = true) 9 | data class ProfileResponse( 10 | @Json(name = "profile") 11 | val profile: Profile 12 | ) -------------------------------------------------------------------------------- /api/src/main/java/io/realworld/api/models/responses/TagsResponse.kt: -------------------------------------------------------------------------------- 1 | package io.realworld.api.models.responses 2 | 3 | 4 | import com.squareup.moshi.Json 5 | import com.squareup.moshi.JsonClass 6 | 7 | @JsonClass(generateAdapter = true) 8 | data class TagsResponse( 9 | @Json(name = "tags") 10 | val tags: List 11 | ) -------------------------------------------------------------------------------- /api/src/main/java/io/realworld/api/models/responses/UserResponse.kt: -------------------------------------------------------------------------------- 1 | package io.realworld.api.models.responses 2 | 3 | 4 | import com.squareup.moshi.Json 5 | import com.squareup.moshi.JsonClass 6 | import io.realworld.api.models.entities.User 7 | 8 | @JsonClass(generateAdapter = true) 9 | data class UserResponse( 10 | @Json(name = "user") 11 | val user: User 12 | ) -------------------------------------------------------------------------------- /api/src/main/java/io/realworld/api/services/ConduitAPI.kt: -------------------------------------------------------------------------------- 1 | package io.realworld.api.services 2 | 3 | import io.realworld.api.models.requests.LoginRequest 4 | import io.realworld.api.models.requests.SignupRequest 5 | import io.realworld.api.models.responses.ArticleResponse 6 | import io.realworld.api.models.responses.ArticlesResponse 7 | import io.realworld.api.models.responses.TagsResponse 8 | import io.realworld.api.models.responses.UserResponse 9 | import retrofit2.Response 10 | import retrofit2.http.* 11 | 12 | interface ConduitAPI { 13 | 14 | @POST("users") 15 | suspend fun signupUser( 16 | @Body userCreds: SignupRequest 17 | ): Response 18 | 19 | @POST("users/login") 20 | suspend fun loginUser( 21 | @Body userCreds: LoginRequest 22 | ): Response 23 | 24 | @GET("articles") 25 | suspend fun getArticles( 26 | @Query("author") author: String? = null, 27 | @Query("favourited") favourited: String? = null, 28 | @Query("tag") tag: String? = null 29 | ): Response 30 | 31 | @GET("articles/{slug}") 32 | suspend fun getArticleBySlug( 33 | @Path("slug") slug: String 34 | ): Response 35 | 36 | @GET("tags") 37 | suspend fun getTags(): Response 38 | 39 | } -------------------------------------------------------------------------------- /api/src/main/java/io/realworld/api/services/ConduitAuthAPI.kt: -------------------------------------------------------------------------------- 1 | package io.realworld.api.services 2 | 3 | import io.realworld.api.models.requests.UpsertArticleRequest 4 | import io.realworld.api.models.requests.UserUpdateRequest 5 | import io.realworld.api.models.responses.ArticleResponse 6 | import io.realworld.api.models.responses.ArticlesResponse 7 | import io.realworld.api.models.responses.ProfileResponse 8 | import io.realworld.api.models.responses.UserResponse 9 | import retrofit2.Response 10 | import retrofit2.http.* 11 | 12 | interface ConduitAuthAPI { 13 | 14 | @GET("user") 15 | suspend fun getCurrentUser(): Response 16 | 17 | @PUT("user") 18 | suspend fun updateCurrentUser( 19 | @Body userUpdateRequest: UserUpdateRequest 20 | ): Response 21 | 22 | @GET("profiles/{username}") 23 | suspend fun getProfile( 24 | @Path("username") username: String 25 | ): Response 26 | 27 | @POST("profiles/{username}/follow") 28 | suspend fun followProfile( 29 | @Path("username") username: String 30 | ): Response 31 | 32 | @DELETE("profiles/{username}/follow") 33 | suspend fun unfollowProfile( 34 | @Path("username") username: String 35 | ): Response 36 | 37 | @GET("articles/feed") 38 | suspend fun getFeedArticles(): Response 39 | 40 | @POST("articles/{slug}/favorite") 41 | suspend fun favoriteArticle( 42 | @Path("slug") slug: String 43 | ): Response 44 | 45 | @DELETE("articles/{slug}/favorite") 46 | suspend fun unfavoriteArticle( 47 | @Path("slug") slug: String 48 | ): Response 49 | 50 | @POST("articles") 51 | suspend fun createArticle( 52 | @Body article: UpsertArticleRequest 53 | ) :Response 54 | } -------------------------------------------------------------------------------- /api/src/test/java/io/realworld/api/ConduitClientTests.kt: -------------------------------------------------------------------------------- 1 | package io.realworld.api 2 | 3 | import io.realworld.api.models.entities.SignupData 4 | import io.realworld.api.models.requests.SignupRequest 5 | import kotlinx.coroutines.runBlocking 6 | import org.junit.Assert.assertEquals 7 | import org.junit.Assert.assertNotNull 8 | import org.junit.Test 9 | import kotlin.random.Random 10 | 11 | class ConduitClientTests { 12 | 13 | 14 | @Test 15 | fun `GET articles`() { 16 | runBlocking { 17 | val articles = ConduitClient.publicApi.getArticles() 18 | assertNotNull(articles.body()?.articles) 19 | } 20 | } 21 | 22 | @Test 23 | fun `GET articles by author`() { 24 | runBlocking { 25 | val articles = ConduitClient.publicApi.getArticles(author = "444") 26 | assertNotNull(articles.body()?.articles) 27 | } 28 | } 29 | 30 | @Test 31 | fun `GET articles by tags`() { 32 | runBlocking { 33 | val articles = ConduitClient.publicApi.getArticles(tag = "dragons") 34 | assertNotNull(articles.body()?.articles) 35 | } 36 | } 37 | 38 | @Test 39 | fun `POST users - create user`() { 40 | val userCreds = SignupData( 41 | email = "testemail${Random.nextInt(999, 9999)}@test.com", 42 | password = "pass${Random.nextInt(9999, 999999)}", 43 | username = "rand_user_${Random.nextInt(99, 999)}" 44 | ) 45 | runBlocking { 46 | val resp = ConduitClient.publicApi.signupUser(SignupRequest(userCreds)) 47 | assertEquals(userCreds.username, resp.body()?.user?.username) 48 | } 49 | } 50 | } -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /app/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id 'com.android.application' 3 | id 'kotlin-android' 4 | } 5 | 6 | android { 7 | compileSdkVersion 30 8 | 9 | defaultConfig { 10 | applicationId "io.realworld.android" 11 | minSdkVersion 24 12 | targetSdkVersion 30 13 | versionCode 1 14 | versionName "1.0" 15 | 16 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" 17 | } 18 | 19 | buildTypes { 20 | release { 21 | minifyEnabled false 22 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' 23 | } 24 | } 25 | compileOptions { 26 | sourceCompatibility JavaVersion.VERSION_11 27 | targetCompatibility JavaVersion.VERSION_11 28 | } 29 | kotlinOptions { 30 | jvmTarget = '11' 31 | } 32 | buildFeatures { 33 | viewBinding true 34 | } 35 | } 36 | 37 | 38 | 39 | dependencies { 40 | 41 | implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" 42 | implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.4.2" 43 | implementation 'androidx.core:core-ktx:1.3.2' 44 | implementation 'androidx.appcompat:appcompat:1.2.0' 45 | implementation 'com.google.android.material:material:1.2.1' 46 | implementation 'androidx.constraintlayout:constraintlayout:2.0.3' 47 | implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.2.0' 48 | implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.2.0' 49 | implementation 'androidx.navigation:navigation-fragment-ktx:2.3.1' 50 | implementation 'androidx.navigation:navigation-ui-ktx:2.3.1' 51 | implementation "com.github.bumptech.glide:glide:4.11.0" 52 | 53 | implementation project(":api") 54 | 55 | 56 | // unit testing 57 | testImplementation 'junit:junit:4.+' 58 | 59 | // instrumentation testing 60 | androidTestImplementation 'androidx.test.ext:junit:1.1.2' 61 | androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0' 62 | } -------------------------------------------------------------------------------- /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/src/androidTest/java/io/realworld/android/ExampleInstrumentedTest.kt: -------------------------------------------------------------------------------- 1 | package io.realworld.android 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("io.realworld.android", appContext.packageName) 23 | } 24 | } -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 14 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /app/src/main/java/io/realworld/android/AuthViewModel.kt: -------------------------------------------------------------------------------- 1 | package io.realworld.android 2 | 3 | import androidx.lifecycle.LiveData 4 | import androidx.lifecycle.MutableLiveData 5 | import androidx.lifecycle.ViewModel 6 | import androidx.lifecycle.viewModelScope 7 | import io.realworld.android.data.UserRepo 8 | import io.realworld.api.models.entities.User 9 | import kotlinx.coroutines.launch 10 | 11 | class AuthViewModel : ViewModel() { 12 | private val _user = MutableLiveData() 13 | val user: LiveData = _user 14 | 15 | fun getCurrentUser(token: String) = viewModelScope.launch { 16 | UserRepo.getCurrentUser(token)?.let { 17 | _user.postValue(it) 18 | } 19 | } 20 | 21 | fun login(email: String, password: String) = viewModelScope.launch { 22 | UserRepo.login(email, password)?.let { 23 | _user.postValue(it) 24 | } 25 | } 26 | 27 | fun signup(username: String, email: String, password: String) = viewModelScope.launch { 28 | UserRepo.signup(username, email, password)?.let { 29 | _user.postValue(it) 30 | } 31 | } 32 | 33 | fun logout() { 34 | _user.postValue(null) 35 | } 36 | 37 | fun update( 38 | bio: String?, 39 | username: String?, 40 | image: String?, 41 | email: String?, 42 | password: String? 43 | ) = viewModelScope.launch { 44 | UserRepo.updateUser(bio, username, image, email, password)?.let { 45 | _user.postValue(it) 46 | } 47 | } 48 | } -------------------------------------------------------------------------------- /app/src/main/java/io/realworld/android/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package io.realworld.android 2 | 3 | import android.content.Context 4 | import android.content.SharedPreferences 5 | import android.os.Bundle 6 | import android.view.Menu 7 | import android.view.MenuItem 8 | import androidx.appcompat.app.AppCompatActivity 9 | import androidx.core.content.edit 10 | import androidx.drawerlayout.widget.DrawerLayout 11 | import androidx.lifecycle.ViewModelProvider 12 | import androidx.navigation.findNavController 13 | import androidx.navigation.ui.AppBarConfiguration 14 | import androidx.navigation.ui.navigateUp 15 | import androidx.navigation.ui.setupActionBarWithNavController 16 | import androidx.navigation.ui.setupWithNavController 17 | import com.google.android.material.navigation.NavigationView 18 | import io.realworld.android.databinding.ActivityMainBinding 19 | import io.realworld.api.models.entities.User 20 | 21 | class MainActivity : AppCompatActivity() { 22 | 23 | companion object { 24 | const val PREFS_FILE_AUTH = "prefs_auth" 25 | const val PREFS_KEY_TOKEN = "token" 26 | } 27 | 28 | private lateinit var appBarConfiguration: AppBarConfiguration 29 | private lateinit var binding: ActivityMainBinding 30 | private lateinit var authViewModel: AuthViewModel 31 | private lateinit var sharedPreferences: SharedPreferences 32 | 33 | override fun onCreate(savedInstanceState: Bundle?) { 34 | super.onCreate(savedInstanceState) 35 | 36 | sharedPreferences = getSharedPreferences(PREFS_FILE_AUTH, Context.MODE_PRIVATE) 37 | authViewModel = ViewModelProvider(this).get(AuthViewModel::class.java) 38 | binding = ActivityMainBinding.inflate(layoutInflater) 39 | 40 | setContentView(binding.root) 41 | 42 | setSupportActionBar(binding.appBarMain.toolbar) 43 | 44 | val drawerLayout: DrawerLayout = binding.drawerLayout 45 | val navView: NavigationView = binding.navView 46 | val navController = findNavController(R.id.nav_host_fragment_content_main) 47 | // Passing each menu ID as a set of Ids because each 48 | // menu should be considered as top level destinations. 49 | appBarConfiguration = AppBarConfiguration( 50 | setOf( 51 | R.id.nav_feed, 52 | R.id.nav_my_feed, 53 | R.id.nav_auth 54 | ), drawerLayout 55 | ) 56 | setupActionBarWithNavController(navController, appBarConfiguration) 57 | navView.setupWithNavController(navController) 58 | 59 | sharedPreferences.getString(PREFS_KEY_TOKEN, null)?.let { t -> 60 | authViewModel.getCurrentUser(t) 61 | } 62 | 63 | authViewModel.user.observe({ lifecycle }) { 64 | updateMenu(it) 65 | it?.token?.let { t -> 66 | sharedPreferences.edit { 67 | putString(PREFS_KEY_TOKEN, t) 68 | } 69 | } ?: run { 70 | sharedPreferences.edit { 71 | remove(PREFS_KEY_TOKEN) 72 | } 73 | } 74 | navController.navigateUp() 75 | } 76 | 77 | 78 | } 79 | 80 | private fun updateMenu(user: User?) { 81 | when (user) { 82 | is User -> { 83 | binding.navView.menu.clear() 84 | binding.navView.inflateMenu(R.menu.menu_main_user) 85 | } 86 | else -> { 87 | binding.navView.menu.clear() 88 | binding.navView.inflateMenu(R.menu.menu_main_guest) 89 | } 90 | } 91 | 92 | } 93 | 94 | override fun onOptionsItemSelected(item: MenuItem): Boolean { 95 | when (item.itemId) { 96 | R.id.action_logout -> { 97 | authViewModel.logout() 98 | return true 99 | } 100 | } 101 | return super.onOptionsItemSelected(item) 102 | } 103 | 104 | override fun onCreateOptionsMenu(menu: Menu): Boolean { 105 | // Inflate the menu; this adds items to the action bar if it is present. 106 | menuInflater.inflate(R.menu.main, menu) 107 | return true 108 | } 109 | 110 | override fun onSupportNavigateUp(): Boolean { 111 | val navController = findNavController(R.id.nav_host_fragment_content_main) 112 | return navController.navigateUp(appBarConfiguration) || super.onSupportNavigateUp() 113 | } 114 | } -------------------------------------------------------------------------------- /app/src/main/java/io/realworld/android/data/ArticlesRepo.kt: -------------------------------------------------------------------------------- 1 | package io.realworld.android.data 2 | 3 | import io.realworld.api.ConduitClient 4 | import io.realworld.api.models.entities.Article 5 | import io.realworld.api.models.entities.ArticleData 6 | import io.realworld.api.models.requests.UpsertArticleRequest 7 | 8 | object ArticlesRepo { 9 | val api = ConduitClient.publicApi 10 | val authApi = ConduitClient.authApi 11 | 12 | suspend fun getGlobalFeed() = api.getArticles().body()?.articles 13 | suspend fun getMyFeed() = authApi.getFeedArticles().body()?.articles 14 | 15 | suspend fun createArticle( 16 | title:String?, 17 | description:String?, 18 | body:String?, 19 | tagList:List?=null 20 | ) : Article? { 21 | val response =authApi.createArticle( 22 | UpsertArticleRequest( 23 | ArticleData( 24 | title=title, 25 | description = description, 26 | body = body, 27 | tagList = tagList 28 | ) 29 | ) 30 | ) 31 | 32 | return response.body()?.article 33 | } 34 | } -------------------------------------------------------------------------------- /app/src/main/java/io/realworld/android/data/UserRepo.kt: -------------------------------------------------------------------------------- 1 | package io.realworld.android.data 2 | 3 | import io.realworld.api.ConduitClient 4 | import io.realworld.api.models.entities.LoginData 5 | import io.realworld.api.models.entities.SignupData 6 | import io.realworld.api.models.entities.User 7 | import io.realworld.api.models.entities.UserUpdateData 8 | import io.realworld.api.models.requests.LoginRequest 9 | import io.realworld.api.models.requests.SignupRequest 10 | import io.realworld.api.models.requests.UserUpdateRequest 11 | import io.realworld.api.models.responses.UserResponse 12 | 13 | object UserRepo { 14 | val api = ConduitClient.publicApi 15 | val authAPI = ConduitClient.authApi 16 | 17 | suspend fun login(email: String, password: String): User? { 18 | val response = api.loginUser(LoginRequest(LoginData(email, password))) 19 | 20 | // TODO: Save it in SharedPreferences 21 | ConduitClient.authToken = response.body()?.user?.token 22 | 23 | return response.body()?.user 24 | } 25 | 26 | suspend fun signup(username: String, email: String, password: String): User? { 27 | val response = api.signupUser(SignupRequest(SignupData( 28 | email, password, username 29 | ))) 30 | 31 | // TODO: Save it in SharedPreferences 32 | ConduitClient.authToken = response.body()?.user?.token 33 | 34 | return response.body()?.user 35 | 36 | } 37 | 38 | suspend fun getCurrentUser(token: String): User? { 39 | ConduitClient.authToken = token 40 | return authAPI.getCurrentUser().body()?.user 41 | } 42 | 43 | suspend fun updateUser( 44 | bio: String?, 45 | username: String?, 46 | image: String?, 47 | email: String?, 48 | password: String? 49 | ): User? { 50 | val response = authAPI.updateCurrentUser(UserUpdateRequest(UserUpdateData( 51 | bio, email, image, username, password 52 | ))) 53 | 54 | return response.body()?.user 55 | } 56 | 57 | suspend fun getUserProfile() = authAPI.getCurrentUser().body()?.user 58 | 59 | } -------------------------------------------------------------------------------- /app/src/main/java/io/realworld/android/extensions/ImageView.kt: -------------------------------------------------------------------------------- 1 | package io.realworld.android.extensions 2 | 3 | import android.widget.ImageView 4 | import com.bumptech.glide.Glide 5 | 6 | fun ImageView.loadImage(uri: String, circleCrop: Boolean = false) { 7 | if (circleCrop) { 8 | Glide.with(this).load(uri).circleCrop().into(this) 9 | } else { 10 | Glide.with(this).load(uri).into(this) 11 | } 12 | } -------------------------------------------------------------------------------- /app/src/main/java/io/realworld/android/extensions/TextView.kt: -------------------------------------------------------------------------------- 1 | package io.realworld.android.extensions 2 | 3 | import android.annotation.SuppressLint 4 | import android.icu.text.SimpleDateFormat 5 | import android.widget.TextView 6 | import java.util.* 7 | 8 | @SuppressLint("ConstantLocale") 9 | val isoDateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.getDefault()) 10 | 11 | @SuppressLint("ConstantLocale") 12 | val appDateFormat = SimpleDateFormat("MMMM dd, yyyy", Locale.getDefault()) 13 | 14 | var TextView.timeStamp: String 15 | set(value) { 16 | val date = isoDateFormat.parse(value) 17 | text = appDateFormat.format(date) 18 | } 19 | get() { 20 | val date = appDateFormat.parse(text.toString()) 21 | return isoDateFormat.format(date) 22 | } -------------------------------------------------------------------------------- /app/src/main/java/io/realworld/android/ui/article/ArticleFragment.kt: -------------------------------------------------------------------------------- 1 | package io.realworld.android.ui.article 2 | 3 | import android.os.Bundle 4 | import android.view.LayoutInflater 5 | import android.view.View 6 | import android.view.ViewGroup 7 | import androidx.fragment.app.Fragment 8 | import androidx.lifecycle.ViewModelProvider 9 | import io.realworld.android.R 10 | import io.realworld.android.databinding.FragmentArticleBinding 11 | import io.realworld.android.extensions.loadImage 12 | import io.realworld.android.extensions.timeStamp 13 | 14 | class ArticleFragment : Fragment() { 15 | 16 | private var _binding: FragmentArticleBinding? = null 17 | lateinit var articleViewModel: ArticleViewModel 18 | private var articleId: String? = null 19 | 20 | override fun onCreateView( 21 | inflater: LayoutInflater, 22 | container: ViewGroup?, 23 | savedInstanceState: Bundle? 24 | ): View? { 25 | articleViewModel = ViewModelProvider(this).get(ArticleViewModel::class.java) 26 | _binding = FragmentArticleBinding.inflate(inflater, container, false) 27 | 28 | arguments?.let { 29 | articleId = it.getString(resources.getString(R.string.arg_article_id)) 30 | } 31 | 32 | articleId?.let { articleViewModel.fetchArticle(it) } 33 | 34 | return _binding?.root 35 | } 36 | 37 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 38 | super.onViewCreated(view, savedInstanceState) 39 | 40 | articleViewModel.article.observe({ lifecycle }) { 41 | _binding?.apply { 42 | titleTextView.text = it.title 43 | bodyTextView.text = it.body 44 | authorTextView.text = it.author.username 45 | dateTextView.timeStamp = it.createdAt 46 | avatarImageView.loadImage(it.author.image, true) 47 | } 48 | } 49 | 50 | 51 | } 52 | 53 | override fun onDestroyView() { 54 | super.onDestroyView() 55 | _binding = null 56 | } 57 | } -------------------------------------------------------------------------------- /app/src/main/java/io/realworld/android/ui/article/ArticleViewModel.kt: -------------------------------------------------------------------------------- 1 | package io.realworld.android.ui.article 2 | 3 | import androidx.lifecycle.LiveData 4 | import androidx.lifecycle.MutableLiveData 5 | import androidx.lifecycle.ViewModel 6 | import androidx.lifecycle.viewModelScope 7 | import io.realworld.android.data.ArticlesRepo 8 | import io.realworld.api.ConduitClient 9 | import io.realworld.api.models.entities.Article 10 | import kotlinx.coroutines.launch 11 | 12 | class ArticleViewModel : ViewModel() { 13 | val api = ConduitClient.publicApi 14 | 15 | private val _article = MutableLiveData
() 16 | val article: LiveData
= _article 17 | 18 | fun fetchArticle(slug: String) = viewModelScope.launch { 19 | val response = api.getArticleBySlug(slug) 20 | 21 | response.body()?.article.let { _article.postValue(it) } 22 | 23 | } 24 | 25 | 26 | fun createArticle( 27 | title:String?, 28 | description:String?, 29 | body:String?, 30 | tagList:List?=null 31 | ) =viewModelScope.launch { 32 | val article = ArticlesRepo.createArticle( 33 | title=title, 34 | description = description, 35 | body=body, 36 | tagList = tagList 37 | ) 38 | } 39 | 40 | 41 | } -------------------------------------------------------------------------------- /app/src/main/java/io/realworld/android/ui/article/CreateArticleFragment.kt: -------------------------------------------------------------------------------- 1 | package io.realworld.android.ui.article 2 | 3 | import android.os.Bundle 4 | import android.view.LayoutInflater 5 | import android.view.View 6 | import android.view.ViewGroup 7 | import android.widget.Toast 8 | import androidx.fragment.app.Fragment 9 | import androidx.lifecycle.ViewModelProvider 10 | import io.realworld.android.databinding.FragmentCreateArticleBinding 11 | 12 | class CreateArticleFragment: Fragment() { 13 | 14 | 15 | private var _binding:FragmentCreateArticleBinding?= null 16 | private lateinit var articleViewModel:ArticleViewModel 17 | 18 | override fun onCreateView( 19 | inflater: LayoutInflater, 20 | container: ViewGroup?, 21 | savedInstanceState: Bundle? 22 | ): View? { 23 | _binding= FragmentCreateArticleBinding.inflate(layoutInflater,container,false) 24 | articleViewModel= ViewModelProvider(this).get(ArticleViewModel::class.java) 25 | 26 | 27 | return _binding?.root 28 | } 29 | 30 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 31 | super.onViewCreated(view, savedInstanceState) 32 | 33 | _binding?.apply { 34 | submitButton.setOnClickListener{ 35 | articleViewModel.createArticle( 36 | title=articleTitleTv.text.toString().takeIf { it.isNotBlank() }, 37 | description = articleDesciptionTv.text.toString().takeIf { it.isNotBlank() }, 38 | body = articleBodyTv.text.toString().takeIf{it.isNotBlank()}, 39 | tagList = articleTagTv.text.toString().split("\\s".toRegex()) 40 | ) 41 | Toast.makeText(requireContext(),"Article Published",Toast.LENGTH_SHORT).show() 42 | } 43 | } 44 | } 45 | 46 | } -------------------------------------------------------------------------------- /app/src/main/java/io/realworld/android/ui/auth/AuthFragment.kt: -------------------------------------------------------------------------------- 1 | package io.realworld.android.ui.auth 2 | 3 | import android.os.Bundle 4 | import android.view.LayoutInflater 5 | import android.view.View 6 | import android.view.ViewGroup 7 | import androidx.fragment.app.Fragment 8 | import androidx.navigation.NavController 9 | import androidx.navigation.NavHost 10 | import androidx.navigation.NavHostController 11 | import androidx.navigation.Navigation 12 | import androidx.navigation.fragment.NavHostFragment 13 | import com.google.android.material.tabs.TabLayout 14 | import io.realworld.android.R 15 | import io.realworld.android.databinding.FragmentAuthBinding 16 | 17 | class AuthFragment : Fragment() { 18 | 19 | private var _binding: FragmentAuthBinding? = null 20 | private var navController: NavController? = null 21 | 22 | override fun onCreateView( 23 | inflater: LayoutInflater, 24 | container: ViewGroup?, 25 | savedInstanceState: Bundle? 26 | ): View? { 27 | _binding = FragmentAuthBinding.inflate(inflater, container, false) 28 | return _binding?.root 29 | } 30 | 31 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 32 | super.onViewCreated(view, savedInstanceState) 33 | 34 | navController = _binding?.let { Navigation.findNavController(it.root.findViewById(R.id.authFragmentNavHost)) } 35 | _binding?.authTabLayout?.addOnTabSelectedListener(object: TabLayout.OnTabSelectedListener { 36 | override fun onTabSelected(tab: TabLayout.Tab?) { 37 | when(tab?.position) { 38 | 0 -> { 39 | navController?.navigate(R.id.gotoLoginFragment) 40 | } 41 | 1 -> { 42 | navController?.navigate(R.id.gotoSignupFragment) 43 | } 44 | } 45 | } 46 | 47 | override fun onTabUnselected(tab: TabLayout.Tab?) { 48 | } 49 | 50 | override fun onTabReselected(tab: TabLayout.Tab?) { 51 | } 52 | 53 | }) 54 | } 55 | 56 | override fun onDestroyView() { 57 | super.onDestroyView() 58 | _binding = null 59 | } 60 | } -------------------------------------------------------------------------------- /app/src/main/java/io/realworld/android/ui/auth/LoginFragment.kt: -------------------------------------------------------------------------------- 1 | package io.realworld.android.ui.auth 2 | 3 | import android.os.Bundle 4 | import android.view.LayoutInflater 5 | import android.view.View 6 | import android.view.ViewGroup 7 | import androidx.core.view.isVisible 8 | import androidx.fragment.app.Fragment 9 | import androidx.fragment.app.activityViewModels 10 | import io.realworld.android.AuthViewModel 11 | import io.realworld.android.databinding.FragmentLoginSignupBinding 12 | 13 | class LoginFragment : Fragment() { 14 | 15 | private var _binding: FragmentLoginSignupBinding? = null 16 | val authViewModel: AuthViewModel by activityViewModels() 17 | 18 | override fun onCreateView( 19 | inflater: LayoutInflater, 20 | container: ViewGroup?, 21 | savedInstanceState: Bundle? 22 | ): View? { 23 | _binding = FragmentLoginSignupBinding.inflate(inflater, container, false) 24 | _binding?.usernameEditText?.isVisible = false 25 | 26 | return _binding?.root 27 | } 28 | 29 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 30 | super.onViewCreated(view, savedInstanceState) 31 | 32 | _binding?.apply { 33 | submitButton.setOnClickListener { 34 | authViewModel.login( 35 | emailEditText.text.toString(), 36 | passwordEditText.text.toString() 37 | ) 38 | } 39 | 40 | } 41 | } 42 | 43 | override fun onDestroyView() { 44 | super.onDestroyView() 45 | _binding = null 46 | } 47 | } -------------------------------------------------------------------------------- /app/src/main/java/io/realworld/android/ui/auth/SignupFragment.kt: -------------------------------------------------------------------------------- 1 | package io.realworld.android.ui.auth 2 | 3 | import android.os.Bundle 4 | import android.view.LayoutInflater 5 | import android.view.View 6 | import android.view.ViewGroup 7 | import androidx.core.view.isVisible 8 | import androidx.fragment.app.Fragment 9 | import androidx.fragment.app.activityViewModels 10 | import io.realworld.android.AuthViewModel 11 | import io.realworld.android.databinding.FragmentLoginSignupBinding 12 | 13 | class SignupFragment : Fragment() { 14 | 15 | private var _binding: FragmentLoginSignupBinding? = null 16 | val authViewModel: AuthViewModel by activityViewModels() 17 | 18 | override fun onCreateView( 19 | inflater: LayoutInflater, 20 | container: ViewGroup?, 21 | savedInstanceState: Bundle? 22 | ): View? { 23 | _binding = FragmentLoginSignupBinding.inflate(inflater, container, false) 24 | _binding?.usernameEditText?.isVisible = true 25 | 26 | return _binding?.root 27 | } 28 | 29 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 30 | super.onViewCreated(view, savedInstanceState) 31 | 32 | _binding?.apply { 33 | submitButton.setOnClickListener { 34 | authViewModel.signup( 35 | usernameEditText.text.toString(), 36 | emailEditText.text.toString(), 37 | passwordEditText.text.toString() 38 | ) 39 | } 40 | } 41 | } 42 | 43 | override fun onDestroyView() { 44 | super.onDestroyView() 45 | _binding = null 46 | } 47 | } -------------------------------------------------------------------------------- /app/src/main/java/io/realworld/android/ui/feed/ArticleFeedAdapter.kt: -------------------------------------------------------------------------------- 1 | package io.realworld.android.ui.feed 2 | 3 | import android.view.LayoutInflater 4 | import android.view.View 5 | import android.view.ViewGroup 6 | import androidx.recyclerview.widget.DiffUtil 7 | import androidx.recyclerview.widget.ListAdapter 8 | import androidx.recyclerview.widget.RecyclerView 9 | import io.realworld.android.R 10 | import io.realworld.android.databinding.ListItemArticleBinding 11 | import io.realworld.android.extensions.loadImage 12 | import io.realworld.android.extensions.timeStamp 13 | import io.realworld.api.models.entities.Article 14 | 15 | class ArticleFeedAdapter(val onArticleClicked: (slug: String) -> Unit) : 16 | ListAdapter( 17 | object : DiffUtil.ItemCallback
() { 18 | override fun areItemsTheSame(oldItem: Article, newItem: Article): Boolean { 19 | return oldItem == newItem 20 | } 21 | 22 | override fun areContentsTheSame(oldItem: Article, newItem: Article): Boolean { 23 | return oldItem.toString() == newItem.toString() 24 | } 25 | } 26 | ) { 27 | inner class ArticleViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) 28 | 29 | override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ArticleViewHolder { 30 | return ArticleViewHolder( 31 | parent.context.getSystemService(LayoutInflater::class.java).inflate( 32 | R.layout.list_item_article, 33 | parent, 34 | false 35 | ) 36 | ) 37 | } 38 | 39 | override fun onBindViewHolder(holder: ArticleViewHolder, position: Int) { 40 | ListItemArticleBinding.bind(holder.itemView).apply { 41 | val article = getItem(position) 42 | 43 | authorTextView.text = article.author.username 44 | titleTextView.text = article.title 45 | bodySnippetTextView.text = article.body 46 | dateTextView.timeStamp = article.createdAt 47 | avatarImageView.loadImage(article.author.image, true) 48 | 49 | root.setOnClickListener { onArticleClicked(article.slug) } 50 | 51 | } 52 | 53 | } 54 | 55 | 56 | } -------------------------------------------------------------------------------- /app/src/main/java/io/realworld/android/ui/feed/FeedViewModel.kt: -------------------------------------------------------------------------------- 1 | package io.realworld.android.ui.feed 2 | 3 | import android.util.Log 4 | import androidx.lifecycle.LiveData 5 | import androidx.lifecycle.MutableLiveData 6 | import androidx.lifecycle.ViewModel 7 | import androidx.lifecycle.viewModelScope 8 | import io.realworld.android.data.ArticlesRepo 9 | import io.realworld.api.models.entities.Article 10 | import kotlinx.coroutines.launch 11 | 12 | class FeedViewModel : ViewModel() { 13 | 14 | 15 | private val _feed = MutableLiveData>() 16 | val feed: LiveData> = _feed 17 | 18 | fun fetchGlobalFeed() = viewModelScope.launch { 19 | ArticlesRepo.getGlobalFeed()?.let { 20 | _feed.postValue(it) 21 | } 22 | 23 | } 24 | 25 | fun fetchMyFeed() = viewModelScope.launch { 26 | ArticlesRepo.getMyFeed()?.let { 27 | _feed.postValue(it) 28 | } 29 | } 30 | 31 | } 32 | -------------------------------------------------------------------------------- /app/src/main/java/io/realworld/android/ui/feed/GlobalFeedFragment.kt: -------------------------------------------------------------------------------- 1 | package io.realworld.android.ui.feed 2 | 3 | import android.os.Bundle 4 | import android.view.LayoutInflater 5 | import android.view.View 6 | import android.view.ViewGroup 7 | import androidx.core.os.bundleOf 8 | import androidx.fragment.app.Fragment 9 | import androidx.lifecycle.ViewModelProvider 10 | import androidx.navigation.fragment.findNavController 11 | import androidx.recyclerview.widget.LinearLayoutManager 12 | import io.realworld.android.R 13 | import io.realworld.android.databinding.FragmentFeedBinding 14 | 15 | class GlobalFeedFragment : Fragment() { 16 | private var _binding: FragmentFeedBinding? = null 17 | private lateinit var viewModel: FeedViewModel 18 | private lateinit var feedAdapter: ArticleFeedAdapter 19 | 20 | override fun onCreateView( 21 | inflater: LayoutInflater, 22 | container: ViewGroup?, 23 | savedInstanceState: Bundle? 24 | ): View? { 25 | viewModel = ViewModelProvider(this).get(FeedViewModel::class.java) 26 | feedAdapter = ArticleFeedAdapter { openArticle(it) } 27 | 28 | _binding = FragmentFeedBinding.inflate(inflater, container, false) 29 | _binding?.feedRecyclerView?.layoutManager = LinearLayoutManager(context) 30 | _binding?.feedRecyclerView?.adapter = feedAdapter 31 | return _binding?.root 32 | } 33 | 34 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 35 | super.onViewCreated(view, savedInstanceState) 36 | viewModel.fetchGlobalFeed() 37 | viewModel.feed.observe({ lifecycle }) { 38 | feedAdapter.submitList(it) 39 | } 40 | } 41 | 42 | fun openArticle(articleId: String) { 43 | findNavController().navigate( 44 | R.id.action_globalFeed_openArticle, 45 | bundleOf( 46 | resources.getString(R.string.arg_article_id) to articleId 47 | ) 48 | ) 49 | } 50 | 51 | override fun onDestroyView() { 52 | super.onDestroyView() 53 | _binding = null 54 | } 55 | } -------------------------------------------------------------------------------- /app/src/main/java/io/realworld/android/ui/feed/MyFeedFragment.kt: -------------------------------------------------------------------------------- 1 | package io.realworld.android.ui.feed 2 | 3 | import android.os.Bundle 4 | import android.view.LayoutInflater 5 | import android.view.View 6 | import android.view.ViewGroup 7 | import androidx.core.os.bundleOf 8 | import androidx.fragment.app.Fragment 9 | import androidx.lifecycle.ViewModelProvider 10 | import androidx.navigation.fragment.findNavController 11 | import androidx.recyclerview.widget.LinearLayoutManager 12 | import io.realworld.android.R 13 | import io.realworld.android.databinding.FragmentFeedBinding 14 | 15 | class MyFeedFragment : Fragment() { 16 | 17 | private var _binding: FragmentFeedBinding? = null 18 | private lateinit var viewModel: FeedViewModel 19 | private lateinit var feedAdapter: ArticleFeedAdapter 20 | 21 | override fun onCreateView( 22 | inflater: LayoutInflater, 23 | container: ViewGroup?, 24 | savedInstanceState: Bundle? 25 | ): View? { 26 | viewModel = ViewModelProvider(this).get(FeedViewModel::class.java) 27 | feedAdapter = ArticleFeedAdapter { openArticle(it) } 28 | 29 | _binding = FragmentFeedBinding.inflate(inflater, container, false) 30 | _binding?.feedRecyclerView?.layoutManager = LinearLayoutManager(context) 31 | _binding?.feedRecyclerView?.adapter = feedAdapter 32 | return _binding?.root 33 | } 34 | 35 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 36 | super.onViewCreated(view, savedInstanceState) 37 | viewModel.fetchMyFeed() 38 | viewModel.feed.observe({ lifecycle }) { 39 | feedAdapter.submitList(it) 40 | } 41 | } 42 | 43 | fun openArticle(articleId: String) { 44 | findNavController().navigate( 45 | R.id.action_globalFeed_openArticle, 46 | bundleOf( 47 | resources.getString(R.string.arg_article_id) to articleId 48 | ) 49 | ) 50 | } 51 | override fun onDestroyView() { 52 | super.onDestroyView() 53 | _binding = null 54 | } 55 | } -------------------------------------------------------------------------------- /app/src/main/java/io/realworld/android/ui/settings/SettingsFragment.kt: -------------------------------------------------------------------------------- 1 | package io.realworld.android.ui.settings 2 | 3 | import android.os.Bundle 4 | import android.view.LayoutInflater 5 | import android.view.View 6 | import android.view.ViewGroup 7 | import androidx.fragment.app.Fragment 8 | import androidx.fragment.app.activityViewModels 9 | import io.realworld.android.AuthViewModel 10 | import io.realworld.android.databinding.FragmentSettingsBinding 11 | 12 | class SettingsFragment : Fragment() { 13 | 14 | private var _binding: FragmentSettingsBinding? = null 15 | private val authViewModel by activityViewModels() 16 | 17 | override fun onCreateView( 18 | inflater: LayoutInflater, 19 | container: ViewGroup?, 20 | savedInstanceState: Bundle? 21 | ): View? { 22 | _binding = FragmentSettingsBinding.inflate(inflater, container, false) 23 | 24 | return _binding?.root 25 | } 26 | 27 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 28 | super.onViewCreated(view, savedInstanceState) 29 | 30 | authViewModel.user.observe({ lifecycle }) { 31 | _binding?.apply { 32 | bioEditText.setText(it?.bio ?: "") 33 | emailEditText.setText(it?.email ?: "") 34 | usernameEditText.setText(it?.username ?: "") 35 | imageEditText.setText(it?.image ?: "") 36 | } 37 | } 38 | 39 | _binding?.apply { 40 | submitButton.setOnClickListener { 41 | authViewModel.update( 42 | bio = bioEditText.text.toString(), 43 | username = usernameEditText.text.toString().takeIf { it.isNotBlank() }, 44 | image = imageEditText.text.toString(), 45 | email = emailEditText.text.toString().takeIf { it.isNotBlank() }, 46 | password = passwordEditText.text.toString().takeIf { it.isNotBlank() } 47 | ) 48 | } 49 | } 50 | 51 | } 52 | 53 | override fun onDestroyView() { 54 | super.onDestroyView() 55 | } 56 | } -------------------------------------------------------------------------------- /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/ic_create_article.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /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_auth.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_menu_feed.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_menu_login.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_menu_settings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_menu_signup.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_my_feed.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/side_nav_bar.xml: -------------------------------------------------------------------------------- 1 | 3 | 4 | -------------------------------------------------------------------------------- /app/src/main/res/font/source_sans_pro_light.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | -------------------------------------------------------------------------------- /app/src/main/res/font/source_sans_pro_semibold.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /app/src/main/res/font/titillium_web_bold.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_main.xml: -------------------------------------------------------------------------------- 1 | 2 | 10 | 11 | 16 | 17 | 25 | -------------------------------------------------------------------------------- /app/src/main/res/layout/app_bar_main.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 13 | 14 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /app/src/main/res/layout/content_main.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 20 | -------------------------------------------------------------------------------- /app/src/main/res/layout/fragment_article.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 17 | 18 | 23 | 24 | 29 | 30 | 35 | 36 | 42 | 43 | 49 | 50 | 51 | 52 | 60 | 61 | 62 | -------------------------------------------------------------------------------- /app/src/main/res/layout/fragment_auth.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 13 | 14 | 19 | 20 | 25 | 26 | 31 | 32 | -------------------------------------------------------------------------------- /app/src/main/res/layout/fragment_create_article.xml: -------------------------------------------------------------------------------- 1 | 7 | 13 | 18 | 25 | 31 | 32 |