├── .gitignore ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── android_glide_lint.xml ├── app ├── .gitignore ├── build.gradle ├── google-services.json ├── proguard-rules.pro └── src │ ├── androidTest │ └── java │ │ └── com │ │ └── josycom │ │ └── mayorjay │ │ └── flowoverstack │ │ ├── ExampleInstrumentedTest.kt │ │ └── ScreenNavigationTest.kt │ ├── main │ ├── AndroidManifest.xml │ ├── java │ │ └── com │ │ │ └── josycom │ │ │ └── mayorjay │ │ │ └── flowoverstack │ │ │ ├── MyApplication.kt │ │ │ ├── data │ │ │ ├── mapper │ │ │ │ └── Mappers.kt │ │ │ ├── model │ │ │ │ ├── Answer.kt │ │ │ │ ├── Owner.kt │ │ │ │ ├── Question.kt │ │ │ │ └── Tag.kt │ │ │ ├── remote │ │ │ │ ├── datasource │ │ │ │ │ ├── AnswerPagingSource.kt │ │ │ │ │ ├── QuestionPagingSource.kt │ │ │ │ │ └── TagPagingSource.kt │ │ │ │ ├── model │ │ │ │ │ ├── AnswerRemote.kt │ │ │ │ │ ├── AnswerResponse.kt │ │ │ │ │ ├── OwnerRemote.kt │ │ │ │ │ ├── QuestionRemote.kt │ │ │ │ │ ├── QuestionsResponse.kt │ │ │ │ │ ├── SearchResponse.kt │ │ │ │ │ ├── TagRemote.kt │ │ │ │ │ └── TagsResponse.kt │ │ │ │ └── service │ │ │ │ │ └── ApiService.kt │ │ │ └── repository │ │ │ │ ├── AnswerRepository.kt │ │ │ │ ├── PreferenceRepository.kt │ │ │ │ ├── PreferenceRepositoryImpl.kt │ │ │ │ ├── QuestionRepository.kt │ │ │ │ ├── SearchRepository.kt │ │ │ │ └── TagRepository.kt │ │ │ ├── di │ │ │ ├── ApiModule.kt │ │ │ ├── DataStoreModule.kt │ │ │ └── RepositoryModule.kt │ │ │ ├── util │ │ │ ├── AppConstants.kt │ │ │ ├── AppLogger.kt │ │ │ ├── AppUtils.kt │ │ │ └── ThreadExecutor.kt │ │ │ ├── view │ │ │ ├── answer │ │ │ │ ├── AnswerActivity.kt │ │ │ │ ├── AnswerAdapter.kt │ │ │ │ └── WebViewActivity.kt │ │ │ ├── home │ │ │ │ ├── PagingLoadStateAdapter.kt │ │ │ │ ├── QuestionActivity.kt │ │ │ │ ├── QuestionAdapter.kt │ │ │ │ └── QuestionsFragment.kt │ │ │ ├── init │ │ │ │ └── SplashActivity.kt │ │ │ ├── ocr │ │ │ │ └── OcrActivity.kt │ │ │ ├── search │ │ │ │ ├── SearchActivity.kt │ │ │ │ └── SearchAdapter.kt │ │ │ └── tag │ │ │ │ ├── TagsAdapter.kt │ │ │ │ └── TagsDialogFragment.kt │ │ │ └── viewmodel │ │ │ ├── AnswerViewModel.kt │ │ │ ├── QuestionActivityViewModel.kt │ │ │ ├── QuestionViewModel.kt │ │ │ ├── SearchViewModel.kt │ │ │ └── TagsDialogViewModel.kt │ └── res │ │ ├── anim │ │ ├── fab_close.xml │ │ ├── fab_open.xml │ │ ├── fade_in_anim.xml │ │ ├── fade_out_anim.xml │ │ ├── slide_in_left.xml │ │ ├── slide_in_right.xml │ │ ├── slide_out_left.xml │ │ └── slide_out_right.xml │ │ ├── animator │ │ └── search_button_state_list_anim.xml │ │ ├── drawable-anydpi │ │ ├── ic_action_scroll_up.xml │ │ └── ic_info.xml │ │ ├── drawable-hdpi │ │ ├── ic_action_scroll_up.png │ │ └── ic_info.png │ │ ├── drawable-mdpi │ │ ├── ic_action_scroll_up.png │ │ └── ic_info.png │ │ ├── drawable-xhdpi │ │ ├── ic_action_scroll_up.png │ │ ├── ic_info.png │ │ └── loading.png │ │ ├── drawable-xxhdpi │ │ ├── ic_action_scroll_up.png │ │ └── ic_info.png │ │ ├── drawable │ │ ├── app_icon.png │ │ ├── app_iconn.png │ │ ├── button_background.xml │ │ ├── ic_baseline_verified_answer.xml │ │ ├── ic_camera.xml │ │ ├── ic_clear.xml │ │ ├── ic_close.xml │ │ ├── ic_date.xml │ │ ├── ic_email.xml │ │ ├── ic_keyboard.xml │ │ ├── ic_refresh.xml │ │ ├── ic_search.xml │ │ ├── ic_share.xml │ │ ├── ic_share_green.xml │ │ ├── ic_tag.xml │ │ ├── ic_twitter.xml │ │ ├── ic_view.xml │ │ ├── ic_vote.xml │ │ ├── splash_screen.xml │ │ └── tag_bg.xml │ │ ├── font │ │ ├── magra.xml │ │ ├── montserrat.xml │ │ ├── montserrat_bold.xml │ │ ├── montserrat_light.otf │ │ ├── poppins_bold.ttf │ │ ├── poppins_light.ttf │ │ └── poppins_semibold.ttf │ │ ├── layout │ │ ├── activity_answer.xml │ │ ├── activity_ocr.xml │ │ ├── activity_question.xml │ │ ├── activity_search.xml │ │ ├── activity_web_view.xml │ │ ├── answer_item.xml │ │ ├── content_main.xml │ │ ├── fragment_questions.xml │ │ ├── layout_info_dialog.xml │ │ ├── paging_load_state_footer_view_item.xml │ │ ├── question_item.xml │ │ ├── tag_item.xml │ │ └── tags_dialog_fragment.xml │ │ ├── menu │ │ ├── menu_main.xml │ │ └── menu_webview.xml │ │ ├── mipmap-hdpi │ │ └── app_icon.png │ │ ├── mipmap-mdpi │ │ └── app_icon.png │ │ ├── mipmap-xhdpi │ │ └── app_icon.png │ │ ├── mipmap-xxhdpi │ │ └── app_icon.png │ │ ├── mipmap-xxxhdpi │ │ └── app_icon.png │ │ ├── values-sw600dp │ │ └── attrs.xml │ │ ├── values-xlarge │ │ └── attrs.xml │ │ ├── values │ │ ├── attrs.xml │ │ ├── colors.xml │ │ ├── dimens.xml │ │ ├── font_certs.xml │ │ ├── plurals.xml │ │ ├── preloaded_fonts.xml │ │ ├── strings.xml │ │ └── styles.xml │ │ └── xml │ │ └── provider_path.xml │ └── test │ └── java │ └── com │ └── josycom │ └── mayorjay │ └── flowoverstack │ ├── ExampleUnitTest.kt │ ├── testdata │ └── FakePreferenceRepository.kt │ ├── util │ └── AppUtilsTest.kt │ └── viewmodel │ └── QuestionActivityViewModelTest.kt ├── app_icon.png ├── 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 | .DS_Store 5 | /build 6 | /captures 7 | .externalNativeBuild 8 | .cxx 9 | /.idea 10 | /fos-keystore -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | This document contains information and guidelines about contributing to the [FlowOver Stack](https://github.com/mayorJAY/FlowOverStack) project. Please read it before you start participating. 4 | 5 | ## Contributing Code 6 | 7 | To start contributing to the codebase, first fork the repository. Branches are made off of `development` with each feature or issue per branch. Once done, take a look at open [Issues](https://github.com/mayorJAY/FlowOverStack/issues), pick anyone you're comfortable with, code it out and submit a Pull Request. I will review the code and respond if changes should be made or just merge if it looks great! 8 | 9 | ## Submitting an Issue 10 | 11 | Please feel free to open an issue if you find something that make you unhappy with the application. It can as well be a feature request - you think there is a particular feature that should be on the application, just pen it down [here](https://github.com/mayorJAY/FlowOverStack/issues). 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Joseph Olugbohunmi 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

4 | 5 | # StackOverflow - Community Version 6 | 7 | An Android StackOverflow client application implemented using the MVVM architecture, Retrofit2, LiveData, Flow, ViewModel, Repository pattern, View Binding. Users can get to view Questions which have been asked on Stack Overflow; picking a particular Question makes the user view it in detail as well as the Answers provided. These Questions can be filtered by any of these four categories; Active, Recent, Hot or Voted. Questions that have an accepted Answer are easily identified. 8 | Users can also search for a particular problem they are having by typing in a search query or by capturing an image and performing an OCR on it. Questions are curated based on the search query and presented to the user; again, the user can pick a particular Question to view the provided Answers. Users also have the options to keep a tab on a Tag, filter out questions by Tags, search for any Tag of interest, share questions and answers with themselves or other developers. 9 | # Tech Stack 10 | 11 | * [Retrofit](https://square.github.io/retrofit/) which is a type-safe REST client for Android which makes it easier to consume RESTful web services 12 | * [Paging Library](https://developer.android.com/topic/libraries/architecture/paging) which helps to load and display small chunks of data at a time 13 | * [ViewModel](https://developer.android.com/topic/libraries/architecture/viewmodel) used to store and manage UI-related data in a lifecycle conscious way 14 | * [LiveData](https://developer.android.com/topic/libraries/architecture/livedata) which is an observable data holder class used to handle data in a lifecycle-aware manner 15 | * [Glide](https://github.com/bumptech/glide) which is an image loading and caching library for Android 16 | * [Material Design](https://material.io/develop/android/docs/getting-started/) which is an adaptable system that guides in maintaining principles and best practices of contemporary UI 17 | * [View Binding](https://developer.android.com/topic/libraries/view-binding) used to easily write code that interacts with views by referencing them directly 18 | * [Espresso](https://developer.android.com/training/testing/espresso) used to write concise, beautiful, and reliable Android UI tests 19 | * [MarkdownView](https://github.com/mukeshsolanki/MarkdownView-Android) which is a simple library that helps to display Markdown text or files on Android as a HTML page just like GitHub 20 | * [ML Kit OCR](https://developers.google.com/ml-kit/vision/text-recognition) which is a library that recognizes text in any captured image 21 | * [Android Image Cropper](https://github.com/ArthurHub/Android-Image-Cropper) which is an image cropping library for Android, optimized for camera and gallery. 22 | * [Dagger](https://developer.android.com/training/dependency-injection/dagger-android) for Dependency Injection 23 | * [Kotlin Coroutines](https://developer.android.com/kotlin/coroutines) for executing network calls asynchronously 24 | * [Kotlin flow](https://developer.android.com/kotlin/flow) for emitting live updates from a network call sequentially 25 | * [Preferences DataStore](https://developer.android.com/topic/libraries/architecture/datastore) for storing and retrieving key-value pairs of primitive data types 26 | # Installation 27 | 28 | This App requires a minimum API level of 26. Clone the repository. You will need an API key from [Stack Exchange API](https://api.stackexchange.com/) to receive a higher request quota. Locate the StringConstants.java file and edit the following line to add your API key: 29 | 30 | ```` 31 | API_KEY = "YOUR_API_KEY" 32 | ```` 33 | 34 | # Contribution 35 | All contributions are welcome. See the [CONTRIBUTING](https://github.com/mayorJAY/FlowOverStack/blob/feat/ocr/CONTRIBUTING.md) file for guidelines on contributing 36 | 37 | Get it on Google Play 38 | 39 | ## LICENSE 40 | ``` 41 | MIT License 42 | Copyright (c) 2021 Joseph Olugbohunmi 43 | 44 | Permission is hereby granted, free of charge, to any person obtaining a copy 45 | of this software and associated documentation files (the "Software"), to deal 46 | in the Software without restriction, including without limitation the rights 47 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 48 | copies of the Software, and to permit persons to whom the Software is 49 | furnished to do so, subject to the following conditions: 50 | 51 | The above copyright notice and this permission notice shall be included in all 52 | copies or substantial portions of the Software. 53 | 54 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 55 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 56 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 57 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 58 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 59 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 60 | SOFTWARE. 61 | ``` 62 | -------------------------------------------------------------------------------- /android_glide_lint.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | /debug 3 | /release -------------------------------------------------------------------------------- /app/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'com.android.application' 2 | apply plugin: 'kotlin-android' 3 | apply plugin: 'com.google.gms.google-services' 4 | apply plugin: 'kotlin-kapt' 5 | apply plugin: 'com.google.dagger.hilt.android' 6 | 7 | android { 8 | namespace 'com.josycom.mayorjay.flowoverstack' 9 | compileSdk 34 10 | 11 | defaultConfig { 12 | applicationId "com.josycom.mayorjay.flowoverstack" 13 | minSdk 26 14 | targetSdk 34 15 | versionCode 200 16 | versionName "2.00" 17 | multiDexEnabled true 18 | 19 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" 20 | } 21 | 22 | signingConfigs { 23 | Properties keyProps = new Properties() 24 | keyProps.load(new FileInputStream(project.rootProject.file('local.properties'))) 25 | debug { 26 | storeFile file(keyProps.getProperty("STORE_FILE")) 27 | storePassword keyProps.getProperty("STORE_PASSWORD", "") 28 | keyAlias keyProps.getProperty("KEY_ALIAS", "") 29 | keyPassword keyProps.getProperty("KEY_PASSWORD", "") 30 | } 31 | release { 32 | storeFile file(keyProps.getProperty("STORE_FILE")) 33 | storePassword keyProps.getProperty("STORE_PASSWORD", "") 34 | keyAlias keyProps.getProperty("KEY_ALIAS", "") 35 | keyPassword keyProps.getProperty("KEY_PASSWORD", "") 36 | } 37 | } 38 | 39 | buildTypes { 40 | debug { 41 | signingConfig signingConfigs.debug 42 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' 43 | } 44 | release { 45 | signingConfig signingConfigs.release 46 | minifyEnabled false 47 | shrinkResources false 48 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' 49 | } 50 | } 51 | 52 | buildFeatures { 53 | viewBinding true 54 | buildConfig true 55 | } 56 | 57 | compileOptions { 58 | sourceCompatibility = 17 59 | targetCompatibility = 17 60 | } 61 | 62 | kotlinOptions { 63 | jvmTarget = JavaVersion.VERSION_17 64 | freeCompilerArgs += [ 65 | "-Xjvm-default=all", 66 | ] 67 | } 68 | 69 | kapt { 70 | correctErrorTypes true 71 | } 72 | 73 | lint { 74 | // https://github.com/bumptech/glide/issues/4940 75 | lintConfig = file("$rootDir/android_glide_lint.xml") 76 | } 77 | 78 | } 79 | 80 | dependencies { 81 | implementation fileTree(dir: 'libs', include: ['*.jar']) 82 | 83 | implementation 'androidx.appcompat:appcompat:1.3.1' 84 | implementation 'com.google.android.material:material:1.4.0' 85 | implementation 'androidx.constraintlayout:constraintlayout:2.1.1' 86 | implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.4.0' 87 | implementation 'androidx.legacy:legacy-support-v4:1.0.0' 88 | implementation 'androidx.recyclerview:recyclerview:1.2.1' 89 | implementation 'android.arch.lifecycle:extensions:1.1.1' 90 | implementation 'org.jsoup:jsoup:1.13.1' 91 | implementation 'androidx.fragment:fragment-ktx:1.5.3' 92 | 93 | implementation 'com.intuit.sdp:sdp-android:1.0.6' 94 | implementation 'com.intuit.ssp:ssp-android:1.0.6' 95 | 96 | // Timber 97 | implementation 'com.jakewharton.timber:timber:5.0.1' 98 | 99 | // Retrofit 100 | implementation 'com.squareup.retrofit2:retrofit:2.9.0' 101 | implementation 'com.squareup.retrofit2:converter-gson:2.9.0' 102 | implementation 'com.squareup.retrofit2:adapter-rxjava2:2.3.0' 103 | 104 | // Glide 105 | implementation 'com.github.bumptech.glide:glide:4.12.0' 106 | 107 | // Logging 108 | implementation 'com.squareup.okhttp3:logging-interceptor:4.7.2' 109 | implementation 'com.squareup.okhttp3:okhttp:4.7.2' 110 | 111 | // Paging library 112 | implementation 'androidx.paging:paging-runtime:3.1.0' 113 | 114 | // ViewModel 115 | implementation 'android.arch.lifecycle:viewmodel:1.1.1' 116 | 117 | // LiveData 118 | implementation "androidx.lifecycle:lifecycle-livedata:2.4.0" 119 | 120 | // SwipeRefreshLayout 121 | implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0' 122 | 123 | // Firebase analytics 124 | implementation 'com.google.firebase:firebase-analytics:20.0.0' 125 | 126 | // Firebase crashlytics 127 | apply plugin: 'com.google.firebase.crashlytics' 128 | implementation 'com.google.firebase:firebase-crashlytics:18.2.4' 129 | 130 | // MarkdownView 131 | implementation 'com.github.mukeshsolanki:MarkdownView-Android:1.0.8' 132 | 133 | // Image cropper 134 | implementation 'com.vanniktech:android-image-cropper:4.3.3' 135 | 136 | // ML Kit 137 | implementation 'com.google.android.gms:play-services-mlkit-text-recognition:17.0.0' 138 | 139 | // Hilt 140 | implementation "com.google.dagger:hilt-android:$hilt_version" 141 | kapt "com.google.dagger:hilt-compiler:$hilt_version" 142 | 143 | // Play core 144 | implementation 'com.google.android.play:asset-delivery:2.2.2' 145 | implementation 'com.google.android.play:feature-delivery:2.1.0' 146 | implementation 'com.google.android.play:app-update:2.1.0' 147 | 148 | // Kotlin 149 | implementation 'androidx.core:core-ktx:1.6.0' 150 | implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" 151 | 152 | // Kotlin Coroutines 153 | implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.2' 154 | 155 | // Preferences DataStore 156 | implementation 'androidx.datastore:datastore-preferences:1.0.0' 157 | 158 | // Unit tests 159 | testImplementation 'junit:junit:4.13.2' 160 | testImplementation 'androidx.test:core:1.4.0' 161 | testImplementation 'org.robolectric:robolectric:4.3.1' 162 | testImplementation "org.mockito:mockito-core:3.12.4" 163 | testImplementation 'androidx.arch.core:core-testing:2.1.0' 164 | testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.5.2' 165 | 166 | // Instrumentation tests 167 | androidTestImplementation 'androidx.test.ext:junit:1.1.3' 168 | androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' 169 | androidTestImplementation 'org.hamcrest:hamcrest-library:1.3' 170 | androidTestImplementation 'com.android.support:support-annotations:28.0.0' 171 | androidTestImplementation 'androidx.test:runner:1.4.0' 172 | androidTestImplementation 'androidx.test:rules:1.4.0' 173 | } 174 | -------------------------------------------------------------------------------- /app/google-services.json: -------------------------------------------------------------------------------- 1 | { 2 | "project_info": { 3 | "project_number": "138698715536", 4 | "firebase_url": "https://flowoverstack-484af.firebaseio.com", 5 | "project_id": "flowoverstack-484af", 6 | "storage_bucket": "flowoverstack-484af.appspot.com" 7 | }, 8 | "client": [ 9 | { 10 | "client_info": { 11 | "mobilesdk_app_id": "1:138698715536:android:4c7287ffe6cbac6ad64315", 12 | "android_client_info": { 13 | "package_name": "com.josycom.mayorjay.flowoverstack" 14 | } 15 | }, 16 | "oauth_client": [ 17 | { 18 | "client_id": "138698715536-phpumr32ftc1af20cmfjj0tcag7idsj3.apps.googleusercontent.com", 19 | "client_type": 3 20 | } 21 | ], 22 | "api_key": [ 23 | { 24 | "current_key": "AIzaSyDgJojmGJflNkPj1rcklwkUD-8jkM74Q6c" 25 | } 26 | ], 27 | "services": { 28 | "appinvite_service": { 29 | "other_platform_oauth_client": [ 30 | { 31 | "client_id": "138698715536-phpumr32ftc1af20cmfjj0tcag7idsj3.apps.googleusercontent.com", 32 | "client_type": 3 33 | } 34 | ] 35 | } 36 | } 37 | } 38 | ], 39 | "configuration_version": "1" 40 | } -------------------------------------------------------------------------------- /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 22 | -------------------------------------------------------------------------------- /app/src/androidTest/java/com/josycom/mayorjay/flowoverstack/ExampleInstrumentedTest.kt: -------------------------------------------------------------------------------- 1 | package com.josycom.mayorjay.flowoverstack 2 | 3 | import androidx.test.ext.junit.runners.AndroidJUnit4 4 | import androidx.test.platform.app.InstrumentationRegistry 5 | import org.junit.Assert 6 | import org.junit.Test 7 | import org.junit.runner.RunWith 8 | 9 | /** 10 | * Instrumented test, which will execute on an Android device. 11 | * 12 | * @see [Testing documentation](http://d.android.com/tools/testing) 13 | */ 14 | @RunWith(AndroidJUnit4::class) 15 | class ExampleInstrumentedTest { 16 | @Test 17 | fun useAppContext() { 18 | // Context of the app under test. 19 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext 20 | Assert.assertEquals("com.josycom.mayorjay.flowoverstack", appContext.packageName) 21 | } 22 | } -------------------------------------------------------------------------------- /app/src/androidTest/java/com/josycom/mayorjay/flowoverstack/ScreenNavigationTest.kt: -------------------------------------------------------------------------------- 1 | package com.josycom.mayorjay.flowoverstack 2 | 3 | import androidx.test.core.app.ActivityScenario 4 | import androidx.test.core.app.ApplicationProvider 5 | import androidx.test.espresso.Espresso 6 | import androidx.test.espresso.action.ViewActions 7 | import androidx.test.espresso.assertion.ViewAssertions 8 | import androidx.test.espresso.matcher.ViewMatchers 9 | import androidx.test.ext.junit.rules.ActivityScenarioRule 10 | import androidx.test.ext.junit.runners.AndroidJUnit4 11 | import com.josycom.mayorjay.flowoverstack.view.home.QuestionActivity 12 | import org.junit.After 13 | import org.junit.Before 14 | import org.junit.Rule 15 | import org.junit.Test 16 | import org.junit.runner.RunWith 17 | 18 | @RunWith(AndroidJUnit4::class) 19 | class ScreenNavigationTest { 20 | 21 | @get:Rule 22 | val scenarioRule = ActivityScenarioRule(QuestionActivity::class.java) 23 | private var scenario: ActivityScenario? = null 24 | 25 | @Before 26 | fun setup() { 27 | scenario = scenarioRule.scenario 28 | } 29 | 30 | @After 31 | fun tearDown() { 32 | scenario?.close() 33 | } 34 | 35 | @Test 36 | fun test_clickMenuOption_displayQuestionsFilteredByCreation() { 37 | Espresso.openActionBarOverflowOrOptionsMenu(ApplicationProvider.getApplicationContext()) 38 | Espresso.onView(ViewMatchers.withText(R.string.action_filter_by_recency)).perform(ViewActions.click()) 39 | Espresso.onView(ViewMatchers.withText(R.string.recent_questions)).check(ViewAssertions.matches(ViewMatchers.isDisplayed())) 40 | } 41 | 42 | @Test 43 | fun test_clickMenuOption_displayQuestionsFilteredByHot() { 44 | Espresso.openActionBarOverflowOrOptionsMenu(ApplicationProvider.getApplicationContext()) 45 | Espresso.onView(ViewMatchers.withText(R.string.action_filter_by_hot)).perform(ViewActions.click()) 46 | Espresso.onView(ViewMatchers.withText(R.string.hot_questions)).check(ViewAssertions.matches(ViewMatchers.isDisplayed())) 47 | } 48 | 49 | @Test 50 | fun test_clickMenuOption_displayQuestionsFilteredByVotes() { 51 | Espresso.openActionBarOverflowOrOptionsMenu(ApplicationProvider.getApplicationContext()) 52 | Espresso.onView(ViewMatchers.withText(R.string.action_filter_by_vote)).perform(ViewActions.click()) 53 | Espresso.onView(ViewMatchers.withText(R.string.voted_questions)).check(ViewAssertions.matches(ViewMatchers.isDisplayed())) 54 | } 55 | 56 | @Test 57 | fun test_clickMenuOption_displayPopularTagsFragment() { 58 | Espresso.openActionBarOverflowOrOptionsMenu(ApplicationProvider.getApplicationContext()) 59 | Espresso.onView(ViewMatchers.withText(R.string.filter_by_popular_tags)).perform(ViewActions.click()) 60 | Espresso.onView(ViewMatchers.withText(R.string.select_a_tag_to_view_the_related_questions)).check(ViewAssertions.matches(ViewMatchers.isDisplayed())) 61 | } 62 | 63 | @Test 64 | fun test_clickMenuOption_displaySearchTagsFragment() { 65 | Espresso.openActionBarOverflowOrOptionsMenu(ApplicationProvider.getApplicationContext()) 66 | Espresso.onView(ViewMatchers.withText(R.string.filter_by_tag_name)).perform(ViewActions.click()) 67 | Espresso.onView(ViewMatchers.withId(R.id.search_text_input_layout)).check(ViewAssertions.matches(ViewMatchers.isDisplayed())) 68 | } 69 | 70 | @Test 71 | fun test_clickSearchFab_launchSearchActivity() { 72 | Espresso.onView(ViewMatchers.withId(R.id.search_fab)).perform(ViewActions.click()) 73 | Espresso.onView(ViewMatchers.withId(R.id.type_to_search)).perform(ViewActions.click()) 74 | Espresso.onView(ViewMatchers.withId(R.id.search_text_input_layout)).check(ViewAssertions.matches(ViewMatchers.isDisplayed())) 75 | } 76 | } -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 22 | 26 | 27 | 28 | 29 | 30 | 31 | 35 | 38 | 39 | 43 | 46 | 47 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 62 | 63 | 64 | 65 | 66 | 69 | 70 | 71 | 72 | -------------------------------------------------------------------------------- /app/src/main/java/com/josycom/mayorjay/flowoverstack/MyApplication.kt: -------------------------------------------------------------------------------- 1 | package com.josycom.mayorjay.flowoverstack 2 | 3 | import android.app.Application 4 | import com.josycom.mayorjay.flowoverstack.util.AppLogger 5 | import dagger.hilt.android.HiltAndroidApp 6 | 7 | @HiltAndroidApp 8 | class MyApplication : Application() { 9 | 10 | override fun onCreate() { 11 | super.onCreate() 12 | AppLogger.init() 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /app/src/main/java/com/josycom/mayorjay/flowoverstack/data/mapper/Mappers.kt: -------------------------------------------------------------------------------- 1 | package com.josycom.mayorjay.flowoverstack.data.mapper 2 | 3 | import com.josycom.mayorjay.flowoverstack.data.model.Answer 4 | import com.josycom.mayorjay.flowoverstack.data.model.Owner 5 | import com.josycom.mayorjay.flowoverstack.data.model.Question 6 | import com.josycom.mayorjay.flowoverstack.data.model.Tag 7 | import com.josycom.mayorjay.flowoverstack.data.remote.model.AnswerRemote 8 | import com.josycom.mayorjay.flowoverstack.data.remote.model.OwnerRemote 9 | import com.josycom.mayorjay.flowoverstack.data.remote.model.QuestionRemote 10 | import com.josycom.mayorjay.flowoverstack.data.remote.model.TagRemote 11 | 12 | fun QuestionRemote.toQuestion(): Question { 13 | return Question( 14 | tags = this.tags ?: emptyList(), 15 | body = this.body ?: "", 16 | owner = this.owner?.toOwner() ?: Owner(), 17 | isAnswered = this.isAnswered ?: false, 18 | viewCount = this.viewCount ?: 0, 19 | acceptedAnswerId = this.acceptedAnswerId ?: 0, 20 | answerCount = this.answerCount ?: 0, 21 | score = this.score ?: 0, 22 | lastActivityDate = this.lastActivityDate ?: 0, 23 | creationDate = this.creationDate ?: 0, 24 | lastEditDate = this.lastEditDate ?: 0, 25 | questionId = this.questionId ?: 0, 26 | link = this.link ?: "", 27 | title = this.title ?: "" 28 | ) 29 | } 30 | 31 | fun OwnerRemote.toOwner(): Owner { 32 | return Owner( 33 | reputation = this.reputation ?: 0, 34 | userId = this.userId ?: 0, 35 | userType = this.userType ?: "", 36 | acceptRate = this.acceptRate ?: 0, 37 | profileImage = this.profileImage ?: "", 38 | displayName = this.displayName ?: "", 39 | link = this.link ?: "" 40 | ) 41 | } 42 | 43 | fun AnswerRemote.toAnswer(): Answer { 44 | return Answer( 45 | owner = this.owner?.toOwner() ?: Owner(), 46 | isAccepted = this.isAccepted ?: false, 47 | score = this.score ?: 0, 48 | lastActivityDate = this.lastActivityDate ?: 0, 49 | creationDate = this.creationDate ?: 0, 50 | lastEditDate = this.lastEditDate ?: 0, 51 | answerId = this.answerId ?: 0, 52 | questionId = this.questionId ?: 0, 53 | body = this.body ?: "", 54 | ) 55 | } 56 | 57 | fun TagRemote.toTag(): Tag { 58 | return Tag( 59 | name = this.name ?: "", 60 | count = this.count ?: 0L, 61 | hasSynonyms = this.hasSynonyms ?: false, 62 | isModeratorOnly = this.isModeratorOnly ?: false, 63 | isRequired = this.isRequired ?: false 64 | ) 65 | } -------------------------------------------------------------------------------- /app/src/main/java/com/josycom/mayorjay/flowoverstack/data/model/Answer.kt: -------------------------------------------------------------------------------- 1 | package com.josycom.mayorjay.flowoverstack.data.model 2 | 3 | import java.io.Serializable 4 | 5 | data class Answer( 6 | val owner: Owner = Owner(), 7 | val isAccepted: Boolean = false, 8 | val score: Int = 0, 9 | val lastActivityDate: Int = 0, 10 | val lastEditDate: Int = 0, 11 | val creationDate: Int = 0, 12 | val answerId: Int = 0, 13 | val questionId: Int = 0, 14 | val body: String = "" 15 | ) : Serializable -------------------------------------------------------------------------------- /app/src/main/java/com/josycom/mayorjay/flowoverstack/data/model/Owner.kt: -------------------------------------------------------------------------------- 1 | package com.josycom.mayorjay.flowoverstack.data.model 2 | 3 | import java.io.Serializable 4 | 5 | data class Owner( 6 | val reputation: Int = 0, 7 | val userId: Int = 0, 8 | val userType: String = "", 9 | val acceptRate: Int = 0, 10 | val profileImage: String = "", 11 | val displayName: String = "", 12 | val link: String = "" 13 | ) : Serializable -------------------------------------------------------------------------------- /app/src/main/java/com/josycom/mayorjay/flowoverstack/data/model/Question.kt: -------------------------------------------------------------------------------- 1 | package com.josycom.mayorjay.flowoverstack.data.model 2 | 3 | import java.io.Serializable 4 | 5 | data class Question( 6 | val tags: List = emptyList(), 7 | val body: String = "", 8 | val owner: Owner = Owner(), 9 | val isAnswered: Boolean = false, 10 | val viewCount: Int = 0, 11 | val acceptedAnswerId: Int = 0, 12 | val answerCount: Int = 0, 13 | val score: Int = 0, 14 | val lastActivityDate: Int = 0, 15 | val creationDate: Int = 0, 16 | val lastEditDate: Int = 0, 17 | val questionId: Int = 0, 18 | val link: String = "", 19 | val title: String = "" 20 | ) : Serializable -------------------------------------------------------------------------------- /app/src/main/java/com/josycom/mayorjay/flowoverstack/data/model/Tag.kt: -------------------------------------------------------------------------------- 1 | package com.josycom.mayorjay.flowoverstack.data.model 2 | 3 | import java.io.Serializable 4 | 5 | class Tag( 6 | val name: String = "", 7 | val count: Long = 0L, 8 | val hasSynonyms: Boolean = false, 9 | val isModeratorOnly: Boolean = false, 10 | val isRequired: Boolean = false 11 | ) : Serializable -------------------------------------------------------------------------------- /app/src/main/java/com/josycom/mayorjay/flowoverstack/data/remote/datasource/AnswerPagingSource.kt: -------------------------------------------------------------------------------- 1 | package com.josycom.mayorjay.flowoverstack.data.remote.datasource 2 | 3 | import androidx.paging.PagingSource 4 | import androidx.paging.PagingState 5 | import com.josycom.mayorjay.flowoverstack.data.mapper.toAnswer 6 | import com.josycom.mayorjay.flowoverstack.data.model.Answer 7 | import com.josycom.mayorjay.flowoverstack.data.remote.service.ApiService 8 | import com.josycom.mayorjay.flowoverstack.util.AppConstants 9 | import retrofit2.HttpException 10 | import java.io.IOException 11 | 12 | class AnswerPagingSource( 13 | private val questionId: Int, 14 | private val order: String, 15 | private val sortCondition: String, 16 | private val site: String, 17 | private val filter: String, 18 | private val siteKey: String, 19 | private val apiService: ApiService 20 | ) : PagingSource() { 21 | 22 | override fun getRefreshKey(state: PagingState): Int? { 23 | return state.anchorPosition?.let { anchorPosition -> 24 | state.closestPageToPosition(anchorPosition)?.prevKey?.plus(1) 25 | ?: state.closestPageToPosition(anchorPosition)?.nextKey?.minus(1) 26 | } 27 | } 28 | 29 | override suspend fun load(params: LoadParams): LoadResult { 30 | val position = params.key ?: AppConstants.FIRST_PAGE 31 | return try { 32 | val response = apiService.getAnswersToQuestion( 33 | questionId, 34 | order, 35 | sortCondition, 36 | site, 37 | filter, 38 | siteKey 39 | ) 40 | val responseItems: List = response.items.map { it.toAnswer() } 41 | val nextKey = 42 | if (responseItems.isEmpty() || responseItems.size <= AppConstants.PAGE_SIZE) { 43 | null 44 | } else { 45 | position + (params.loadSize / AppConstants.PAGE_SIZE) 46 | } 47 | LoadResult.Page( 48 | data = responseItems, 49 | prevKey = if (position == AppConstants.FIRST_PAGE) null else position - 1, 50 | nextKey = nextKey 51 | ) 52 | } catch (exception: IOException) { 53 | LoadResult.Error(exception) 54 | } catch (exception: HttpException) { 55 | LoadResult.Error(exception) 56 | } 57 | } 58 | } -------------------------------------------------------------------------------- /app/src/main/java/com/josycom/mayorjay/flowoverstack/data/remote/datasource/QuestionPagingSource.kt: -------------------------------------------------------------------------------- 1 | package com.josycom.mayorjay.flowoverstack.data.remote.datasource 2 | 3 | import androidx.paging.PagingSource 4 | import androidx.paging.PagingState 5 | import com.josycom.mayorjay.flowoverstack.data.mapper.toQuestion 6 | import com.josycom.mayorjay.flowoverstack.data.model.Question 7 | import com.josycom.mayorjay.flowoverstack.data.remote.service.ApiService 8 | import retrofit2.HttpException 9 | import java.io.IOException 10 | 11 | class QuestionPagingSource( 12 | private val page: Int, 13 | private val pageSize: Int, 14 | private val order: String, 15 | private val sortCondition: String, 16 | private val site: String, 17 | private val tagged: String, 18 | private val filter: String, 19 | private val siteKey: String, 20 | private val apiService: ApiService 21 | ) : PagingSource() { 22 | 23 | override fun getRefreshKey(state: PagingState): Int? { 24 | return state.anchorPosition?.let { anchorPosition -> 25 | state.closestPageToPosition(anchorPosition)?.prevKey?.plus(1) 26 | ?: state.closestPageToPosition(anchorPosition)?.nextKey?.minus(1) 27 | } 28 | } 29 | 30 | override suspend fun load(params: LoadParams): LoadResult { 31 | val position = params.key ?: page 32 | return try { 33 | val response = apiService.getQuestionsForAll( 34 | position, 35 | pageSize, 36 | order, 37 | sortCondition, 38 | site, 39 | tagged, 40 | filter, 41 | siteKey 42 | ) 43 | val responseItems: List = response.items.map { it.toQuestion() } 44 | val nextKey = if (responseItems.isEmpty()) { 45 | null 46 | } else { 47 | position + (params.loadSize / pageSize) 48 | } 49 | LoadResult.Page( 50 | data = responseItems, 51 | prevKey = if (position == page) null else position - 1, 52 | nextKey = nextKey 53 | ) 54 | } catch (exception: IOException) { 55 | LoadResult.Error(exception) 56 | } catch (exception: HttpException) { 57 | LoadResult.Error(exception) 58 | } 59 | } 60 | } -------------------------------------------------------------------------------- /app/src/main/java/com/josycom/mayorjay/flowoverstack/data/remote/datasource/TagPagingSource.kt: -------------------------------------------------------------------------------- 1 | package com.josycom.mayorjay.flowoverstack.data.remote.datasource 2 | 3 | import androidx.paging.PagingSource 4 | import androidx.paging.PagingState 5 | import com.josycom.mayorjay.flowoverstack.data.mapper.toTag 6 | import com.josycom.mayorjay.flowoverstack.data.model.Tag 7 | import com.josycom.mayorjay.flowoverstack.data.remote.service.ApiService 8 | import retrofit2.HttpException 9 | import java.io.IOException 10 | 11 | class TagPagingSource(private val page: Int, private val pageSize: Int, private val inName: String, private val siteKey: String, private val apiService: ApiService) : PagingSource() { 12 | 13 | override fun getRefreshKey(state: PagingState): Int? { 14 | return state.anchorPosition?.let { anchorPosition -> 15 | state.closestPageToPosition(anchorPosition)?.prevKey?.plus(1) 16 | ?: state.closestPageToPosition(anchorPosition)?.nextKey?.minus(1) 17 | } 18 | } 19 | 20 | override suspend fun load(params: LoadParams): LoadResult { 21 | val position = params.key ?: page 22 | return try { 23 | val response = apiService.getAllPopularTags(position, pageSize, inName, siteKey) 24 | val responseItems: List = response.items.map { it.toTag() } 25 | val nextKey = if (responseItems.isEmpty()) { 26 | null 27 | } else { 28 | position + (params.loadSize / pageSize) 29 | } 30 | LoadResult.Page( 31 | data = responseItems, 32 | prevKey = if (position == page) null else position - 1, 33 | nextKey = nextKey 34 | ) 35 | } catch (exception: IOException) { 36 | LoadResult.Error(exception) 37 | } catch (exception: HttpException) { 38 | LoadResult.Error(exception) 39 | } 40 | } 41 | } -------------------------------------------------------------------------------- /app/src/main/java/com/josycom/mayorjay/flowoverstack/data/remote/model/AnswerRemote.kt: -------------------------------------------------------------------------------- 1 | package com.josycom.mayorjay.flowoverstack.data.remote.model 2 | 3 | import com.google.gson.annotations.SerializedName 4 | 5 | data class AnswerRemote( 6 | @SerializedName("owner") 7 | val owner: OwnerRemote? = null, 8 | 9 | @SerializedName("is_accepted") 10 | val isAccepted: Boolean? = null, 11 | 12 | @SerializedName("score") 13 | val score: Int? = null, 14 | 15 | @SerializedName("last_activity_date") 16 | val lastActivityDate: Int? = null, 17 | 18 | @SerializedName("last_edit_date") 19 | val lastEditDate: Int? = null, 20 | 21 | @SerializedName("creation_date") 22 | val creationDate: Int? = null, 23 | 24 | @SerializedName("answer_id") 25 | val answerId: Int? = null, 26 | 27 | @SerializedName("question_id") 28 | val questionId: Int? = null, 29 | 30 | @SerializedName("body") 31 | val body: String? = null 32 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/josycom/mayorjay/flowoverstack/data/remote/model/AnswerResponse.kt: -------------------------------------------------------------------------------- 1 | package com.josycom.mayorjay.flowoverstack.data.remote.model 2 | 3 | import com.google.gson.annotations.SerializedName 4 | 5 | data class AnswerResponse( 6 | @SerializedName("has_more") 7 | val hasMore: Boolean? = null, 8 | 9 | @SerializedName("items") 10 | val items: List = emptyList(), 11 | 12 | @SerializedName("quota_max") 13 | val quotaMax: Int? = null, 14 | 15 | @SerializedName("quota_remaining") 16 | val quotaRemaining: Int? = null 17 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/josycom/mayorjay/flowoverstack/data/remote/model/OwnerRemote.kt: -------------------------------------------------------------------------------- 1 | package com.josycom.mayorjay.flowoverstack.data.remote.model 2 | 3 | import com.google.gson.annotations.SerializedName 4 | 5 | data class OwnerRemote( 6 | @SerializedName("reputation") 7 | val reputation: Int? = null, 8 | 9 | @SerializedName("user_id") 10 | val userId: Int? = null, 11 | 12 | @SerializedName("user_type") 13 | val userType: String? = null, 14 | 15 | @SerializedName("accept_rate") 16 | val acceptRate: Int? = null, 17 | 18 | @SerializedName("profile_image") 19 | val profileImage: String? = null, 20 | 21 | @SerializedName("display_name") 22 | val displayName: String? = null, 23 | 24 | @SerializedName("link") 25 | val link: String? = null 26 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/josycom/mayorjay/flowoverstack/data/remote/model/QuestionRemote.kt: -------------------------------------------------------------------------------- 1 | package com.josycom.mayorjay.flowoverstack.data.remote.model 2 | 3 | import com.google.gson.annotations.SerializedName 4 | 5 | data class QuestionRemote( 6 | @SerializedName("tags") 7 | val tags: List? = null, 8 | 9 | @SerializedName("body") 10 | val body: String? = null, 11 | 12 | @SerializedName("owner") 13 | val owner: OwnerRemote? = null, 14 | 15 | @SerializedName("is_answered") 16 | val isAnswered: Boolean? = null, 17 | 18 | @SerializedName("view_count") 19 | val viewCount: Int? = null, 20 | 21 | @SerializedName("accepted_answer_id") 22 | val acceptedAnswerId: Int? = null, 23 | 24 | @SerializedName("answer_count") 25 | val answerCount: Int? = null, 26 | 27 | @SerializedName("score") 28 | val score: Int? = null, 29 | 30 | @SerializedName("last_activity_date") 31 | val lastActivityDate: Int? = null, 32 | 33 | @SerializedName("creation_date") 34 | val creationDate: Int? = null, 35 | 36 | @SerializedName("last_edit_date") 37 | val lastEditDate: Int? = null, 38 | 39 | @SerializedName("question_id") 40 | val questionId: Int? = null, 41 | 42 | @SerializedName("link") 43 | val link: String? = null, 44 | 45 | @SerializedName("title") 46 | val title: String? = null 47 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/josycom/mayorjay/flowoverstack/data/remote/model/QuestionsResponse.kt: -------------------------------------------------------------------------------- 1 | package com.josycom.mayorjay.flowoverstack.data.remote.model 2 | 3 | import com.google.gson.annotations.SerializedName 4 | 5 | data class QuestionsResponse( 6 | @SerializedName("has_more") 7 | val hasMore: Boolean? = null, 8 | 9 | @SerializedName("items") 10 | val items: List = emptyList(), 11 | 12 | @SerializedName("quota_max") 13 | val quotaMax: Int? = null, 14 | 15 | @SerializedName("quota_remaining") 16 | val quotaRemaining: Int? = null 17 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/josycom/mayorjay/flowoverstack/data/remote/model/SearchResponse.kt: -------------------------------------------------------------------------------- 1 | package com.josycom.mayorjay.flowoverstack.data.remote.model 2 | 3 | import com.josycom.mayorjay.flowoverstack.data.model.Question 4 | 5 | data class SearchResponse( 6 | val networkState: String = "", 7 | val questions: List? = null 8 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/josycom/mayorjay/flowoverstack/data/remote/model/TagRemote.kt: -------------------------------------------------------------------------------- 1 | package com.josycom.mayorjay.flowoverstack.data.remote.model 2 | 3 | import com.google.gson.annotations.SerializedName 4 | 5 | data class TagRemote( 6 | @SerializedName("name") 7 | val name: String? = null, 8 | 9 | @SerializedName("count") 10 | val count: Long? = null, 11 | 12 | @SerializedName("has_synonyms") 13 | val hasSynonyms: Boolean? = null, 14 | 15 | @SerializedName("is_moderator_only") 16 | val isModeratorOnly: Boolean? = null, 17 | 18 | @SerializedName("is_required") 19 | val isRequired: Boolean? = null 20 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/josycom/mayorjay/flowoverstack/data/remote/model/TagsResponse.kt: -------------------------------------------------------------------------------- 1 | package com.josycom.mayorjay.flowoverstack.data.remote.model 2 | 3 | import com.google.gson.annotations.SerializedName 4 | 5 | data class TagsResponse( 6 | @SerializedName("items") 7 | val items: List = emptyList(), 8 | 9 | @SerializedName("has_more") 10 | val hasMore: Boolean? = null, 11 | 12 | @SerializedName("quota_max") 13 | val quotaMax: Int? = null, 14 | 15 | @SerializedName("quota_remaining") 16 | val quotaRemaining: Int? = null 17 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/josycom/mayorjay/flowoverstack/data/remote/service/ApiService.kt: -------------------------------------------------------------------------------- 1 | package com.josycom.mayorjay.flowoverstack.data.remote.service 2 | 3 | import com.josycom.mayorjay.flowoverstack.data.remote.model.AnswerResponse 4 | import com.josycom.mayorjay.flowoverstack.data.remote.model.QuestionsResponse 5 | import com.josycom.mayorjay.flowoverstack.data.remote.model.TagsResponse 6 | import com.josycom.mayorjay.flowoverstack.util.AppConstants 7 | import retrofit2.Call 8 | import retrofit2.http.GET 9 | import retrofit2.http.Path 10 | import retrofit2.http.Query 11 | 12 | interface ApiService { 13 | 14 | @GET(AppConstants.QUESTIONS_END_POINT) 15 | suspend fun getQuestionsForAll( 16 | @Query("page") page: Int, 17 | @Query("pagesize") pageSize: Int, 18 | @Query("order") order: String?, 19 | @Query("sort") sortCondition: String?, 20 | @Query("site") site: String?, 21 | @Query("tagged") tagged: String?, 22 | @Query(value = "filter", encoded = true) filter: String?, 23 | @Query("key", encoded = true) siteKey: String?): QuestionsResponse 24 | 25 | @GET(AppConstants.ANSWERS_END_POINT) 26 | suspend fun getAnswersToQuestion( 27 | @Path("question_id") id: Int, 28 | @Query("order") order: String?, 29 | @Query("sort") sortCondition: String?, 30 | @Query("site") site: String?, 31 | @Query(value = "filter", encoded = true) filter: String?, 32 | @Query("key") siteKey: String?): AnswerResponse 33 | 34 | @GET(AppConstants.SEARCH_END_POINT) 35 | fun getQuestionsWithTextInTitle( 36 | @Query("intitle") inTitle: String?, 37 | @Query("page") page: Int, 38 | @Query("pagesize") pageSize: Int, 39 | @Query("key") siteKey: String? = AppConstants.API_KEY 40 | ): Call 41 | 42 | @GET(AppConstants.TAGS_END_POINT) 43 | suspend fun getAllPopularTags( 44 | @Query("page") page: Int, 45 | @Query("pagesize") pageSize: Int, 46 | @Query("inname") inName: String?, 47 | @Query("key") siteKey: String?): TagsResponse 48 | } -------------------------------------------------------------------------------- /app/src/main/java/com/josycom/mayorjay/flowoverstack/data/repository/AnswerRepository.kt: -------------------------------------------------------------------------------- 1 | package com.josycom.mayorjay.flowoverstack.data.repository 2 | 3 | import androidx.paging.Pager 4 | import androidx.paging.PagingConfig 5 | import androidx.paging.PagingData 6 | import com.josycom.mayorjay.flowoverstack.data.model.Answer 7 | import com.josycom.mayorjay.flowoverstack.data.remote.datasource.AnswerPagingSource 8 | import com.josycom.mayorjay.flowoverstack.data.remote.service.ApiService 9 | import com.josycom.mayorjay.flowoverstack.util.AppConstants 10 | import kotlinx.coroutines.flow.Flow 11 | import javax.inject.Inject 12 | import javax.inject.Singleton 13 | 14 | @Singleton 15 | class AnswerRepository @Inject constructor(private val apiService: ApiService) { 16 | 17 | var answerDataFlow: Flow>? = null 18 | 19 | fun init(questionId: Int, order: String, sortCondition: String, site: String, filter: String, siteKey: String) { 20 | answerDataFlow = Pager(PagingConfig(AppConstants.PAGE_SIZE, enablePlaceholders = false), 21 | pagingSourceFactory = { 22 | AnswerPagingSource( 23 | questionId, 24 | order, 25 | sortCondition, 26 | site, 27 | filter, 28 | siteKey, 29 | apiService 30 | ) 31 | }).flow 32 | } 33 | } -------------------------------------------------------------------------------- /app/src/main/java/com/josycom/mayorjay/flowoverstack/data/repository/PreferenceRepository.kt: -------------------------------------------------------------------------------- 1 | package com.josycom.mayorjay.flowoverstack.data.repository 2 | 3 | import kotlinx.coroutines.flow.Flow 4 | 5 | interface PreferenceRepository { 6 | 7 | fun getIntPreferenceFlow(key: String): Flow 8 | suspend fun setIntPreference(key: String, value: Int) 9 | suspend fun deleteAllPreferences() 10 | suspend fun contains(key: String): Boolean 11 | suspend fun isEmpty(): Boolean 12 | } -------------------------------------------------------------------------------- /app/src/main/java/com/josycom/mayorjay/flowoverstack/data/repository/PreferenceRepositoryImpl.kt: -------------------------------------------------------------------------------- 1 | package com.josycom.mayorjay.flowoverstack.data.repository 2 | 3 | import androidx.datastore.core.DataStore 4 | import androidx.datastore.preferences.core.Preferences 5 | import androidx.datastore.preferences.core.edit 6 | import androidx.datastore.preferences.core.emptyPreferences 7 | import androidx.datastore.preferences.core.intPreferencesKey 8 | import kotlinx.coroutines.flow.Flow 9 | import kotlinx.coroutines.flow.catch 10 | import kotlinx.coroutines.flow.map 11 | import timber.log.Timber 12 | import javax.inject.Inject 13 | 14 | class PreferenceRepositoryImpl @Inject constructor(private val dataStore: DataStore) : 15 | PreferenceRepository { 16 | 17 | override fun getIntPreferenceFlow(key: String): Flow { 18 | val prefKey: Preferences.Key = intPreferencesKey(key) 19 | return dataStore.data 20 | .catch { exception -> 21 | Timber.e(exception) 22 | emit(emptyPreferences()) 23 | } 24 | .map { preferences -> 25 | preferences[prefKey] ?: 0 26 | } 27 | } 28 | 29 | override suspend fun setIntPreference( 30 | key: String, 31 | value: Int 32 | ) { 33 | val prefKey: Preferences.Key = intPreferencesKey(key) 34 | dataStore.edit { preferences -> 35 | preferences[prefKey] = value 36 | } 37 | } 38 | 39 | override suspend fun deleteAllPreferences() { 40 | dataStore.edit { preferences -> 41 | preferences.clear() 42 | } 43 | } 44 | 45 | override suspend fun contains(key: String): Boolean { 46 | return dataStore.edit { }.contains(intPreferencesKey(key)) 47 | } 48 | 49 | override suspend fun isEmpty(): Boolean { 50 | return dataStore.edit { }.asMap().isEmpty() 51 | } 52 | } -------------------------------------------------------------------------------- /app/src/main/java/com/josycom/mayorjay/flowoverstack/data/repository/QuestionRepository.kt: -------------------------------------------------------------------------------- 1 | package com.josycom.mayorjay.flowoverstack.data.repository 2 | 3 | import androidx.paging.Pager 4 | import androidx.paging.PagingConfig 5 | import androidx.paging.PagingData 6 | import com.josycom.mayorjay.flowoverstack.data.model.Question 7 | import com.josycom.mayorjay.flowoverstack.data.remote.datasource.QuestionPagingSource 8 | import com.josycom.mayorjay.flowoverstack.data.remote.service.ApiService 9 | import kotlinx.coroutines.flow.Flow 10 | import javax.inject.Inject 11 | import javax.inject.Singleton 12 | 13 | @Singleton 14 | class QuestionRepository @Inject constructor(private val apiService: ApiService) { 15 | 16 | var questionDataFlow: Flow>? = null 17 | 18 | fun init(page: Int, pageSize: Int, order: String, sortCondition: String, site: String, tagged: String, filter: String, siteKey: String) { 19 | questionDataFlow = Pager(PagingConfig(pageSize, enablePlaceholders = false), 20 | pagingSourceFactory = { QuestionPagingSource(page, pageSize, order, sortCondition, site, tagged, filter, siteKey, apiService) }).flow 21 | } 22 | } -------------------------------------------------------------------------------- /app/src/main/java/com/josycom/mayorjay/flowoverstack/data/repository/SearchRepository.kt: -------------------------------------------------------------------------------- 1 | package com.josycom.mayorjay.flowoverstack.data.repository 2 | 3 | import androidx.lifecycle.MutableLiveData 4 | import com.josycom.mayorjay.flowoverstack.data.mapper.toQuestion 5 | import com.josycom.mayorjay.flowoverstack.data.remote.model.QuestionsResponse 6 | import com.josycom.mayorjay.flowoverstack.data.remote.model.SearchResponse 7 | import com.josycom.mayorjay.flowoverstack.data.remote.service.ApiService 8 | import com.josycom.mayorjay.flowoverstack.util.AppConstants 9 | import com.josycom.mayorjay.flowoverstack.util.ThreadExecutor 10 | import retrofit2.Call 11 | import retrofit2.Callback 12 | import retrofit2.Response 13 | import timber.log.Timber 14 | import javax.inject.Inject 15 | import javax.inject.Singleton 16 | 17 | @Singleton 18 | class SearchRepository @Inject constructor(private val apiService: ApiService) { 19 | 20 | val searchResponse = MutableLiveData() 21 | 22 | private fun getQuestionsWithTextInTitle(inTitle: String?, page: Int, pageSize: Int) { 23 | searchResponse.postValue(SearchResponse(AppConstants.LOADING, null)) 24 | val call = apiService.getQuestionsWithTextInTitle(inTitle, page, pageSize) 25 | call.enqueue(object : Callback { 26 | override fun onResponse(call: Call, response: Response) { 27 | val questionsResponse = response.body() 28 | if (questionsResponse != null) { 29 | if (questionsResponse.items.isNotEmpty()) { 30 | searchResponse.setValue( 31 | SearchResponse( 32 | AppConstants.LOADED, 33 | questionsResponse.items.map { it.toQuestion() }) 34 | ) 35 | } else { 36 | searchResponse.setValue(SearchResponse(AppConstants.NO_MATCHING_RESULT, null)) 37 | } 38 | } 39 | } 40 | 41 | override fun onFailure(call: Call, t: Throwable) { 42 | Timber.e(t) 43 | searchResponse.value = SearchResponse(AppConstants.FAILED, null) 44 | } 45 | }) 46 | } 47 | 48 | fun performSearch(inTitle: String?, page: Int, pageSize: Int) { 49 | ThreadExecutor.mExecutor.execute { getQuestionsWithTextInTitle(inTitle, page, pageSize) } 50 | } 51 | } -------------------------------------------------------------------------------- /app/src/main/java/com/josycom/mayorjay/flowoverstack/data/repository/TagRepository.kt: -------------------------------------------------------------------------------- 1 | package com.josycom.mayorjay.flowoverstack.data.repository 2 | 3 | import androidx.paging.Pager 4 | import androidx.paging.PagingConfig 5 | import androidx.paging.PagingData 6 | import com.josycom.mayorjay.flowoverstack.data.model.Tag 7 | import com.josycom.mayorjay.flowoverstack.data.remote.datasource.TagPagingSource 8 | import com.josycom.mayorjay.flowoverstack.data.remote.service.ApiService 9 | import kotlinx.coroutines.flow.Flow 10 | import javax.inject.Inject 11 | import javax.inject.Singleton 12 | 13 | @Singleton 14 | class TagRepository @Inject constructor(private val apiService: ApiService) { 15 | 16 | var tagDataFlow: Flow>? = null 17 | 18 | fun init(page: Int, pageSize: Int, inName: String, siteKey: String) { 19 | tagDataFlow = Pager(PagingConfig(pageSize, enablePlaceholders = false), pagingSourceFactory = { TagPagingSource(page, pageSize, inName, siteKey, apiService) }).flow 20 | } 21 | } -------------------------------------------------------------------------------- /app/src/main/java/com/josycom/mayorjay/flowoverstack/di/ApiModule.kt: -------------------------------------------------------------------------------- 1 | package com.josycom.mayorjay.flowoverstack.di 2 | 3 | import com.google.gson.Gson 4 | import com.google.gson.GsonBuilder 5 | import com.josycom.mayorjay.flowoverstack.BuildConfig 6 | import com.josycom.mayorjay.flowoverstack.data.remote.service.ApiService 7 | import com.josycom.mayorjay.flowoverstack.util.AppConstants 8 | import dagger.Module 9 | import dagger.Provides 10 | import dagger.hilt.InstallIn 11 | import dagger.hilt.components.SingletonComponent 12 | import okhttp3.OkHttpClient 13 | import okhttp3.logging.HttpLoggingInterceptor 14 | import retrofit2.Retrofit 15 | import retrofit2.adapter.rxjava2.RxJava2CallAdapterFactory 16 | import retrofit2.converter.gson.GsonConverterFactory 17 | import javax.inject.Singleton 18 | 19 | @InstallIn(SingletonComponent::class) 20 | @Module 21 | object ApiModule { 22 | 23 | @Provides 24 | @Singleton 25 | fun provideGson(): Gson = GsonBuilder().setLenient().create() 26 | 27 | @Provides 28 | @Singleton 29 | fun provideInterceptor(): HttpLoggingInterceptor = 30 | HttpLoggingInterceptor() 31 | .setLevel(if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.BODY else HttpLoggingInterceptor.Level.NONE) 32 | 33 | @Provides 34 | @Singleton 35 | fun getOkHttpClient(interceptor: HttpLoggingInterceptor): OkHttpClient.Builder = 36 | OkHttpClient.Builder().addInterceptor(interceptor) 37 | 38 | @Provides 39 | @Singleton 40 | fun provideRetrofit(gson: Gson, okHttpClient: OkHttpClient.Builder): Retrofit = 41 | Retrofit.Builder() 42 | .baseUrl(AppConstants.BASE_URL) 43 | .addCallAdapterFactory(RxJava2CallAdapterFactory.create()) 44 | .addConverterFactory(GsonConverterFactory.create(gson)) 45 | .client(okHttpClient.build()) 46 | .build() 47 | 48 | @Provides 49 | @Singleton 50 | fun getApiService(retrofit: Retrofit): ApiService = retrofit.create(ApiService::class.java) 51 | } 52 | -------------------------------------------------------------------------------- /app/src/main/java/com/josycom/mayorjay/flowoverstack/di/DataStoreModule.kt: -------------------------------------------------------------------------------- 1 | package com.josycom.mayorjay.flowoverstack.di 2 | 3 | import android.content.Context 4 | import androidx.datastore.core.DataStore 5 | import androidx.datastore.preferences.core.PreferenceDataStoreFactory 6 | import androidx.datastore.preferences.core.Preferences 7 | import androidx.datastore.preferences.preferencesDataStoreFile 8 | import com.josycom.mayorjay.flowoverstack.util.AppConstants 9 | import dagger.Module 10 | import dagger.Provides 11 | import dagger.hilt.InstallIn 12 | import dagger.hilt.android.qualifiers.ApplicationContext 13 | import dagger.hilt.components.SingletonComponent 14 | import javax.inject.Singleton 15 | 16 | @InstallIn(SingletonComponent::class) 17 | @Module 18 | object DataStoreModule { 19 | 20 | @Provides 21 | @Singleton 22 | fun provideDataStore(@ApplicationContext context: Context): DataStore = 23 | PreferenceDataStoreFactory.create( 24 | produceFile = { 25 | context.preferencesDataStoreFile(AppConstants.PREFERENCES_FILE_NAME) 26 | } 27 | ) 28 | } -------------------------------------------------------------------------------- /app/src/main/java/com/josycom/mayorjay/flowoverstack/di/RepositoryModule.kt: -------------------------------------------------------------------------------- 1 | package com.josycom.mayorjay.flowoverstack.di 2 | 3 | import com.josycom.mayorjay.flowoverstack.data.repository.PreferenceRepository 4 | import com.josycom.mayorjay.flowoverstack.data.repository.PreferenceRepositoryImpl 5 | import dagger.Binds 6 | import dagger.Module 7 | import dagger.hilt.InstallIn 8 | import dagger.hilt.components.SingletonComponent 9 | 10 | @InstallIn(SingletonComponent::class) 11 | @Module 12 | abstract class RepositoryModule { 13 | 14 | @Binds 15 | abstract fun bindRepository(repositoryImpl: PreferenceRepositoryImpl): PreferenceRepository 16 | } -------------------------------------------------------------------------------- /app/src/main/java/com/josycom/mayorjay/flowoverstack/util/AppConstants.kt: -------------------------------------------------------------------------------- 1 | package com.josycom.mayorjay.flowoverstack.util 2 | 3 | object AppConstants { 4 | const val BASE_URL = "https://api.stackexchange.com" 5 | const val QUESTIONS_END_POINT = "/2.3/questions" 6 | const val ANSWERS_END_POINT = "/2.3/questions/{question_id}/answers" 7 | const val SEARCH_END_POINT = 8 | "/2.3/search?order=desc&sort=activity&site=stackoverflow&filter=!9Z(-wwYGT" 9 | const val TAGS_END_POINT = "/2.3/tags?order=desc&sort=popular&site=stackoverflow" 10 | const val PLAY_STORE_URL = 11 | "https://play.google.com/store/apps/details?id=com.josycom.mayorjay.flowoverstack" 12 | const val TWITTER_URL = "https://twitter.com/mayorjay1" 13 | const val EMAIL_ADDRESS = "joseolu4gsm@yahoo.com" 14 | const val SITE = "stackoverflow" 15 | const val FIRST_PAGE = 1 16 | const val PAGE_SIZE = 50 17 | const val SEARCH_PAGE_SIZE = 100 18 | const val SPLASH_TIME = 1000L 19 | const val ORDER_DESCENDING = "desc" 20 | const val QUESTION_FILTER = "!9Z(-wwYGT" 21 | const val ANSWER_FILTER = "!9Z(-wzu0T" 22 | const val API_KEY = "YOUR_API_KEY" 23 | const val SORT_BY_ACTIVITY = "activity" 24 | const val SORT_BY_VOTES = "votes" 25 | const val SORT_BY_CREATION = "creation" 26 | const val SORT_BY_HOT = "hot" 27 | const val EXTRA_QUESTION_KEY = "key.QUESTION_EXTRA" 28 | const val WEBVIEW_EXTRA_OBJECT = "key.EXTRA_OBJC" 29 | const val LOADING = "loading" 30 | const val LOADED = "loaded" 31 | const val FAILED = "failed" 32 | const val NO_MATCHING_RESULT = "No matching result" 33 | const val FRAGMENT_STATE = "fragment_state" 34 | const val PREFERENCES_FILE_NAME = "fos_pref" 35 | const val APP_OPEN_COUNT_PREF_KEY = "key.APP_OPEN_COUNT" 36 | } -------------------------------------------------------------------------------- /app/src/main/java/com/josycom/mayorjay/flowoverstack/util/AppLogger.kt: -------------------------------------------------------------------------------- 1 | package com.josycom.mayorjay.flowoverstack.util 2 | 3 | import android.util.Log 4 | import com.google.firebase.crashlytics.FirebaseCrashlytics 5 | import com.josycom.mayorjay.flowoverstack.BuildConfig 6 | import timber.log.Timber 7 | 8 | object AppLogger { 9 | 10 | fun init() { 11 | Timber.plant(if (BuildConfig.DEBUG) Timber.DebugTree() else ReleaseTree()) 12 | } 13 | 14 | class ReleaseTree : Timber.Tree() { 15 | 16 | companion object { 17 | const val PRIORITY = "Priority" 18 | const val TAG = "Tag" 19 | } 20 | 21 | override fun log(priority: Int, tag: String?, message: String, t: Throwable?) { 22 | if (priority == Log.VERBOSE || priority == Log.DEBUG || priority == Log.INFO) { 23 | return 24 | } 25 | 26 | FirebaseCrashlytics.getInstance().apply { 27 | this.setCustomKey(PRIORITY, priority) 28 | tag?.let { this.setCustomKey(TAG, it) } 29 | this.log(message) 30 | t?.let { this.recordException(it) } 31 | }.sendUnsentReports() 32 | } 33 | } 34 | } -------------------------------------------------------------------------------- /app/src/main/java/com/josycom/mayorjay/flowoverstack/util/AppUtils.kt: -------------------------------------------------------------------------------- 1 | package com.josycom.mayorjay.flowoverstack.util 2 | 3 | import android.content.Context 4 | import android.content.Intent 5 | import android.net.Uri 6 | import android.widget.Toast 7 | import androidx.appcompat.app.AppCompatActivity 8 | import com.josycom.mayorjay.flowoverstack.R 9 | import com.josycom.mayorjay.flowoverstack.view.answer.WebViewActivity 10 | import timber.log.Timber 11 | import java.text.SimpleDateFormat 12 | import java.util.Date 13 | import java.util.Locale 14 | 15 | /** 16 | * FlowOverStack 17 | * Created by Mbuodile Obiosio on Jun 05, 2020 18 | * https://twitter.com/cazewonder 19 | * Modified by MayorJay 20 | */ 21 | object AppUtils { 22 | 23 | private const val OPEN_IN_APP_BROWSER = true 24 | 25 | fun directLinkToBrowser(activity: AppCompatActivity, url: String?) { 26 | try { 27 | if (OPEN_IN_APP_BROWSER) { 28 | WebViewActivity.navigate(activity, url) 29 | } else { 30 | launchViewIntent(activity, url) 31 | } 32 | } catch (e: Exception) { 33 | Timber.e(e) 34 | showToast(activity, activity.getString(R.string.cannot_open_url)) 35 | } 36 | } 37 | 38 | fun showToast(context: Context, text: String) { 39 | Toast.makeText(context, text, Toast.LENGTH_SHORT).show() 40 | } 41 | 42 | fun toNormalDate(seconds: Long): String? { 43 | val date = Date(seconds * 1000L) 44 | val simpleDateFormat = SimpleDateFormat("MMM dd yyyy", Locale.getDefault()) 45 | return simpleDateFormat.format(date) 46 | } 47 | 48 | fun getFormattedTags(tagList: List): String = tagList.joinToString(", ") 49 | 50 | fun shareContent(content: String, context: Context) { 51 | val intent = Intent(Intent.ACTION_SEND) 52 | intent.type = "text/plain" 53 | intent.putExtra(Intent.EXTRA_TEXT, content) 54 | context.startActivity(Intent.createChooser(intent, "Share Via")) 55 | } 56 | 57 | fun launchViewIntent(context: Context, url: String?) { 58 | context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) 59 | } 60 | 61 | fun launchEmailIntent(context: Context, address: String) { 62 | val intent = Intent(Intent.ACTION_SENDTO, Uri.parse("mailto:$address")) 63 | context.startActivity(Intent.createChooser(intent, "Send With")) 64 | } 65 | } -------------------------------------------------------------------------------- /app/src/main/java/com/josycom/mayorjay/flowoverstack/util/ThreadExecutor.kt: -------------------------------------------------------------------------------- 1 | package com.josycom.mayorjay.flowoverstack.util 2 | 3 | import java.util.concurrent.ExecutorService 4 | import java.util.concurrent.Executors 5 | 6 | object ThreadExecutor { 7 | var mExecutor: ExecutorService = Executors.newFixedThreadPool(5) 8 | } -------------------------------------------------------------------------------- /app/src/main/java/com/josycom/mayorjay/flowoverstack/view/answer/AnswerActivity.kt: -------------------------------------------------------------------------------- 1 | package com.josycom.mayorjay.flowoverstack.view.answer 2 | 3 | import android.content.Intent 4 | import android.os.Bundle 5 | import android.view.MenuItem 6 | import android.view.View 7 | import androidx.activity.viewModels 8 | import androidx.appcompat.app.AppCompatActivity 9 | import androidx.core.view.isInvisible 10 | import androidx.core.view.isVisible 11 | import androidx.lifecycle.Lifecycle 12 | import androidx.lifecycle.lifecycleScope 13 | import androidx.lifecycle.repeatOnLifecycle 14 | import androidx.paging.LoadState 15 | import androidx.recyclerview.widget.DefaultItemAnimator 16 | import androidx.recyclerview.widget.LinearLayoutManager 17 | import com.bumptech.glide.Glide 18 | import com.josycom.mayorjay.flowoverstack.R 19 | import com.josycom.mayorjay.flowoverstack.data.model.Answer 20 | import com.josycom.mayorjay.flowoverstack.data.model.Question 21 | import com.josycom.mayorjay.flowoverstack.databinding.ActivityAnswerBinding 22 | import com.josycom.mayorjay.flowoverstack.util.AppConstants 23 | import com.josycom.mayorjay.flowoverstack.util.AppUtils 24 | import com.josycom.mayorjay.flowoverstack.view.home.PagingLoadStateAdapter 25 | import com.josycom.mayorjay.flowoverstack.viewmodel.AnswerViewModel 26 | import dagger.hilt.android.AndroidEntryPoint 27 | import kotlinx.coroutines.flow.collectLatest 28 | import kotlinx.coroutines.launch 29 | import org.jsoup.Jsoup 30 | 31 | @AndroidEntryPoint 32 | class AnswerActivity : AppCompatActivity() { 33 | 34 | private lateinit var binding: ActivityAnswerBinding 35 | private val answerViewModel: AnswerViewModel by viewModels() 36 | private var ownerQuestionLink: String? = null 37 | private var questionId = 0 38 | private var questionLink: String? = null 39 | 40 | override fun onCreate(savedInstanceState: Bundle?) { 41 | super.onCreate(savedInstanceState) 42 | binding = ActivityAnswerBinding.inflate(layoutInflater) 43 | setContentView(binding.root) 44 | 45 | setupViewContents(intent) 46 | answerViewModel.init(questionId, 47 | AppConstants.ORDER_DESCENDING, 48 | AppConstants.SORT_BY_ACTIVITY, 49 | AppConstants.SITE, 50 | AppConstants.ANSWER_FILTER, 51 | AppConstants.API_KEY) 52 | 53 | fetchAndDisplayAnswers() 54 | setupListeners() 55 | } 56 | 57 | private fun setupViewContents(intent: Intent?) { 58 | var avatarAddress: String? = "" 59 | intent?.let { 60 | val question = it.getSerializableExtra(AppConstants.EXTRA_QUESTION_KEY) as Question 61 | binding.apply { 62 | tvQuestionDetail.text = Jsoup.parse(question.title).text() 63 | markDownView.setMarkDownText(question.body) 64 | tvDateQuestionDetail.text = AppUtils.toNormalDate(question.creationDate.toLong()) 65 | tvNameQuestionDetail.text = question.owner.displayName 66 | } 67 | val voteCount = question.score 68 | if (voteCount <= 0) { 69 | binding.tvVotesCountItem.text = voteCount.toString() 70 | } else { 71 | binding.tvVotesCountItem.text = getString(R.string.plus_score, voteCount) 72 | } 73 | questionId = question.questionId 74 | avatarAddress = question.owner.profileImage 75 | ownerQuestionLink = question.owner.link 76 | questionLink = question.link 77 | } 78 | Glide.with(this) 79 | .load(avatarAddress) 80 | .placeholder(R.drawable.loading) 81 | .into(binding.ivAvatarQuestionDetail) 82 | } 83 | 84 | private fun setupListeners() { 85 | binding.apply { 86 | avatarCard.setOnClickListener { 87 | AppUtils.directLinkToBrowser(this@AnswerActivity, ownerQuestionLink) 88 | } 89 | tvNameQuestionDetail.setOnClickListener { 90 | AppUtils.directLinkToBrowser(this@AnswerActivity, ownerQuestionLink) 91 | } 92 | ivShare.setOnClickListener { 93 | val content = getString( 94 | R.string.share_content, 95 | getString(R.string.question), 96 | questionLink ?: "", 97 | AppConstants.PLAY_STORE_URL 98 | ) 99 | AppUtils.shareContent(content, this@AnswerActivity) 100 | } 101 | } 102 | } 103 | 104 | private fun fetchAndDisplayAnswers() { 105 | val answerAdapter = AnswerAdapter() 106 | binding.rvAnswers.apply { 107 | layoutManager = LinearLayoutManager(this@AnswerActivity) 108 | itemAnimator = DefaultItemAnimator() 109 | adapter = answerAdapter.withLoadStateFooter(PagingLoadStateAdapter { answerAdapter.retry() }) 110 | } 111 | 112 | lifecycleScope.launch { 113 | answerAdapter.loadStateFlow.collect { 114 | binding.pbFetchData.isVisible = it.source.refresh is LoadState.Loading 115 | binding.tvError.isVisible = it.source.refresh is LoadState.Error 116 | binding.btRetry.isVisible = it.source.refresh is LoadState.Error 117 | if (it.source.refresh is LoadState.NotLoading && answerAdapter.itemCount <= 0) { 118 | binding.tvNoAnswerQuestionDetail.isVisible = true 119 | } else { 120 | binding.tvNoAnswerQuestionDetail.isInvisible = true 121 | } 122 | } 123 | } 124 | 125 | lifecycleScope.launch { 126 | repeatOnLifecycle(Lifecycle.State.STARTED) { 127 | answerViewModel.answerDataFlow?.collectLatest { 128 | answerAdapter.submitData(it) 129 | } 130 | } 131 | } 132 | binding.btRetry.setOnClickListener { answerAdapter.retry() } 133 | answerAdapter.setOnClickListener(shareClickListener) 134 | } 135 | 136 | private val shareClickListener = View.OnClickListener { v -> 137 | val currentAnswer = v.tag as? Answer 138 | currentAnswer?.let { 139 | val content = getString( 140 | R.string.share_content, 141 | getString(R.string.answer), 142 | "https://stackoverflow.com/a/${it.answerId}", 143 | AppConstants.PLAY_STORE_URL 144 | ) 145 | AppUtils.shareContent(content, this) 146 | } 147 | } 148 | 149 | override fun onOptionsItemSelected(item: MenuItem): Boolean { 150 | if (item.itemId == android.R.id.home) { 151 | onBackPressed() 152 | return true 153 | } 154 | return super.onOptionsItemSelected(item) 155 | } 156 | 157 | override fun onBackPressed() { 158 | if (binding.markDownView.canGoBack()) { 159 | binding.markDownView.goBack() 160 | } else { 161 | super.onBackPressed() 162 | finish() 163 | } 164 | } 165 | } -------------------------------------------------------------------------------- /app/src/main/java/com/josycom/mayorjay/flowoverstack/view/answer/AnswerAdapter.kt: -------------------------------------------------------------------------------- 1 | package com.josycom.mayorjay.flowoverstack.view.answer 2 | 3 | import android.annotation.SuppressLint 4 | import android.view.LayoutInflater 5 | import android.view.View 6 | import android.view.ViewGroup 7 | import androidx.paging.PagingDataAdapter 8 | import androidx.recyclerview.widget.DiffUtil 9 | import androidx.recyclerview.widget.RecyclerView 10 | import com.josycom.mayorjay.flowoverstack.data.model.Answer 11 | import com.josycom.mayorjay.flowoverstack.databinding.AnswerItemBinding 12 | import com.josycom.mayorjay.flowoverstack.util.AppUtils 13 | import com.josycom.mayorjay.flowoverstack.view.answer.AnswerAdapter.AnswerViewHolder 14 | import org.jsoup.Jsoup 15 | 16 | class AnswerAdapter : PagingDataAdapter(DIFF_CALLBACK) { 17 | 18 | override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AnswerViewHolder { 19 | val answerItemBinding = AnswerItemBinding.inflate(LayoutInflater.from(parent.context), parent, false) 20 | return AnswerViewHolder(answerItemBinding) 21 | } 22 | 23 | override fun onBindViewHolder(holder: AnswerViewHolder, position: Int) { 24 | getItem(position)?.let { 25 | holder.bind(it) 26 | } 27 | } 28 | 29 | fun setOnClickListener(shareOnClickListener: View.OnClickListener?) { 30 | shareClickListener = shareOnClickListener 31 | } 32 | 33 | companion object { 34 | private var shareClickListener: View.OnClickListener? = null 35 | private val DIFF_CALLBACK = object : DiffUtil.ItemCallback() { 36 | override fun areItemsTheSame(oldItem: Answer, newItem: Answer): Boolean = oldItem.answerId == newItem.answerId 37 | 38 | @SuppressLint("DiffUtilEquals") 39 | override fun areContentsTheSame(oldItem: Answer, newItem: Answer): Boolean = oldItem == newItem 40 | } 41 | } 42 | 43 | class AnswerViewHolder(private val binding: AnswerItemBinding) : RecyclerView.ViewHolder(binding.root) { 44 | 45 | init { 46 | binding.ivShare.setOnClickListener(shareClickListener) 47 | } 48 | 49 | fun bind(answer: Answer) { 50 | binding.ivShare.tag = answer 51 | binding.tvVotesItem.text = answer.score.toString() 52 | binding.tvAnswerNameItem.text = answer.owner.displayName 53 | binding.tvAnswerDateItem.text = AppUtils.toNormalDate(answer.creationDate.toLong()) 54 | binding.tvAnswerBodyItem.text = Jsoup.parse(answer.body).text() 55 | } 56 | } 57 | } -------------------------------------------------------------------------------- /app/src/main/java/com/josycom/mayorjay/flowoverstack/view/home/PagingLoadStateAdapter.kt: -------------------------------------------------------------------------------- 1 | package com.josycom.mayorjay.flowoverstack.view.home 2 | 3 | import android.view.LayoutInflater 4 | import android.view.ViewGroup 5 | import androidx.core.view.isVisible 6 | import androidx.paging.LoadState 7 | import androidx.paging.LoadStateAdapter 8 | import androidx.recyclerview.widget.RecyclerView 9 | import com.josycom.mayorjay.flowoverstack.databinding.PagingLoadStateFooterViewItemBinding 10 | 11 | class PagingLoadStateAdapter(private val retry: () -> Unit) : LoadStateAdapter() { 12 | 13 | override fun onCreateViewHolder(parent: ViewGroup, loadState: LoadState): PagingLoadStateViewHolder { 14 | val view = PagingLoadStateFooterViewItemBinding.inflate(LayoutInflater.from(parent.context), parent, false) 15 | return PagingLoadStateViewHolder(view, retry) 16 | } 17 | 18 | override fun onBindViewHolder(holder: PagingLoadStateViewHolder, loadState: LoadState) { 19 | holder.bind(loadState) 20 | } 21 | 22 | class PagingLoadStateViewHolder(private val binding: PagingLoadStateFooterViewItemBinding, private val retry: () -> Unit) : RecyclerView.ViewHolder(binding.root) { 23 | 24 | init { 25 | binding.btRetry.setOnClickListener { retry.invoke() } 26 | } 27 | 28 | fun bind(loadState: LoadState) { 29 | binding.pbFooter.isVisible = loadState is LoadState.Loading 30 | binding.btRetry.isVisible = loadState is LoadState.Error 31 | binding.tvErrorMsg.isVisible = loadState is LoadState.Error 32 | } 33 | } 34 | } -------------------------------------------------------------------------------- /app/src/main/java/com/josycom/mayorjay/flowoverstack/view/home/QuestionAdapter.kt: -------------------------------------------------------------------------------- 1 | package com.josycom.mayorjay.flowoverstack.view.home 2 | 3 | import android.annotation.SuppressLint 4 | import android.view.LayoutInflater 5 | import android.view.View 6 | import android.view.ViewGroup 7 | import androidx.core.view.isVisible 8 | import androidx.paging.PagingDataAdapter 9 | import androidx.recyclerview.widget.DiffUtil 10 | import androidx.recyclerview.widget.RecyclerView 11 | import com.bumptech.glide.Glide 12 | import com.josycom.mayorjay.flowoverstack.R 13 | import com.josycom.mayorjay.flowoverstack.data.model.Question 14 | import com.josycom.mayorjay.flowoverstack.databinding.QuestionItemBinding 15 | import com.josycom.mayorjay.flowoverstack.util.AppUtils 16 | import com.josycom.mayorjay.flowoverstack.view.home.QuestionAdapter.QuestionViewHolder 17 | import org.jsoup.Jsoup 18 | 19 | class QuestionAdapter : PagingDataAdapter(DIFF_CALLBACK) { 20 | 21 | override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): QuestionViewHolder { 22 | val questionItemBinding = QuestionItemBinding.inflate(LayoutInflater.from(parent.context), parent, false) 23 | return QuestionViewHolder(questionItemBinding) 24 | } 25 | 26 | override fun onBindViewHolder(holder: QuestionViewHolder, position: Int) { 27 | getItem(position)?.let { 28 | holder.bind(it) 29 | } 30 | } 31 | 32 | fun setOnClickListeners(onClickListener: View.OnClickListener?, shareOnClickListener: View.OnClickListener?) { 33 | viewHolderClickListener = onClickListener 34 | shareClickListener = shareOnClickListener 35 | } 36 | 37 | companion object { 38 | private var viewHolderClickListener: View.OnClickListener? = null 39 | private var shareClickListener: View.OnClickListener? = null 40 | private val DIFF_CALLBACK: DiffUtil.ItemCallback = object : DiffUtil.ItemCallback() { 41 | override fun areItemsTheSame(oldItem: Question, newItem: Question): Boolean = oldItem.questionId == newItem.questionId 42 | 43 | @SuppressLint("DiffUtilEquals") 44 | override fun areContentsTheSame(oldItem: Question, newItem: Question): Boolean = oldItem == newItem 45 | } 46 | } 47 | 48 | class QuestionViewHolder(private val binding: QuestionItemBinding) : RecyclerView.ViewHolder(binding.root) { 49 | 50 | init { 51 | binding.root.setOnClickListener(viewHolderClickListener) 52 | binding.ivShare.setOnClickListener(shareClickListener) 53 | } 54 | 55 | fun bind(question: Question) { 56 | binding.root.tag = question 57 | binding.ivShare.tag = question 58 | Glide.with(binding.root.context) 59 | .load(question.owner.profileImage) 60 | .placeholder(R.drawable.loading) 61 | .into(binding.ivAvatarItem) 62 | binding.tvQuestionItem.text = Jsoup.parse(question.title).text() 63 | binding.tvViewsCountItem.text = question.viewCount.toString() 64 | binding.tvDateItem.text = AppUtils.toNormalDate(question.creationDate.toLong()) 65 | binding.answered.isVisible = question.isAnswered 66 | val answers = question.answerCount 67 | val answerCount = binding.root.context.resources.getQuantityString( 68 | R.plurals.answers, 69 | answers, 70 | answers 71 | ) 72 | binding.tvAnswersCountItem.text = answerCount 73 | if (question.score <= 0) { 74 | binding.tvVotesCountItem.text = question.score.toString() 75 | } else { 76 | binding.tvVotesCountItem.text = 77 | binding.root.context.getString(R.string.plus_score, question.score) 78 | } 79 | binding.tvTagsListItem.text = AppUtils.getFormattedTags(question.tags) 80 | } 81 | } 82 | } -------------------------------------------------------------------------------- /app/src/main/java/com/josycom/mayorjay/flowoverstack/view/home/QuestionsFragment.kt: -------------------------------------------------------------------------------- 1 | package com.josycom.mayorjay.flowoverstack.view.home 2 | 3 | import android.content.Intent 4 | import android.os.Bundle 5 | import android.view.LayoutInflater 6 | import android.view.View 7 | import android.view.ViewGroup 8 | import androidx.appcompat.app.AppCompatActivity 9 | import androidx.core.view.isInvisible 10 | import androidx.core.view.isVisible 11 | import androidx.fragment.app.Fragment 12 | import androidx.fragment.app.viewModels 13 | import androidx.lifecycle.Lifecycle 14 | import androidx.lifecycle.lifecycleScope 15 | import androidx.lifecycle.repeatOnLifecycle 16 | import androidx.paging.LoadState 17 | import androidx.recyclerview.widget.DefaultItemAnimator 18 | import androidx.recyclerview.widget.LinearLayoutManager 19 | import androidx.recyclerview.widget.RecyclerView 20 | import com.josycom.mayorjay.flowoverstack.R 21 | import com.josycom.mayorjay.flowoverstack.data.model.Question 22 | import com.josycom.mayorjay.flowoverstack.databinding.FragmentQuestionsBinding 23 | import com.josycom.mayorjay.flowoverstack.util.AppConstants 24 | import com.josycom.mayorjay.flowoverstack.util.AppUtils 25 | import com.josycom.mayorjay.flowoverstack.view.answer.AnswerActivity 26 | import com.josycom.mayorjay.flowoverstack.viewmodel.QuestionViewModel 27 | import dagger.hilt.android.AndroidEntryPoint 28 | import kotlinx.coroutines.flow.collectLatest 29 | import kotlinx.coroutines.launch 30 | 31 | /** 32 | * This [Fragment] houses all Questions 33 | */ 34 | @AndroidEntryPoint 35 | class QuestionsFragment : Fragment() { 36 | 37 | private lateinit var binding: FragmentQuestionsBinding 38 | private val viewModel: QuestionViewModel by viewModels() 39 | 40 | override fun onCreateView( 41 | inflater: LayoutInflater, 42 | container: ViewGroup?, 43 | savedInstanceState: Bundle? 44 | ): View { 45 | binding = FragmentQuestionsBinding.inflate(layoutInflater) 46 | return binding.root 47 | } 48 | 49 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 50 | super.onViewCreated(view, savedInstanceState) 51 | 52 | viewModel.init( 53 | AppConstants.FIRST_PAGE, 54 | AppConstants.PAGE_SIZE, 55 | AppConstants.ORDER_DESCENDING, 56 | sortCondition, 57 | AppConstants.SITE, 58 | tagName, 59 | AppConstants.QUESTION_FILTER, 60 | AppConstants.API_KEY 61 | ) 62 | 63 | initViews() 64 | fetchAndDisplayQuestions() 65 | } 66 | 67 | private fun initViews() { 68 | (activity as AppCompatActivity).supportActionBar?.title = title 69 | binding.activityScrollUpFab.isInvisible = true 70 | binding.activityRecyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { 71 | override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { 72 | super.onScrolled(recyclerView, dx, dy) 73 | if (dy > 0) { 74 | binding.activityScrollUpFab.isVisible = true 75 | } else { 76 | binding.activityScrollUpFab.isInvisible = true 77 | } 78 | } 79 | }) 80 | binding.activityScrollUpFab.setOnClickListener { 81 | binding.activityRecyclerView.scrollToPosition(0) 82 | } 83 | } 84 | 85 | private fun fetchAndDisplayQuestions() { 86 | val questionAdapter = QuestionAdapter() 87 | binding.activityRecyclerView.apply { 88 | layoutManager = LinearLayoutManager(context) 89 | itemAnimator = DefaultItemAnimator() 90 | adapter = questionAdapter.withLoadStateFooter(PagingLoadStateAdapter { questionAdapter.retry() }) 91 | } 92 | 93 | viewLifecycleOwner.lifecycleScope.launch { 94 | questionAdapter.loadStateFlow.collect { 95 | if (it.source.refresh is LoadState.Loading) onLoading() 96 | if (it.source.refresh is LoadState.Error) onError() 97 | if (it.source.refresh !is LoadState.Loading && it.source.refresh !is LoadState.Error) onLoaded() 98 | } 99 | } 100 | 101 | viewLifecycleOwner.lifecycleScope.launch { 102 | repeatOnLifecycle(Lifecycle.State.STARTED) { 103 | viewModel.questionDataFlow?.collectLatest { 104 | questionAdapter.submitData(it) 105 | } 106 | } 107 | } 108 | 109 | questionAdapter.setOnClickListeners(viewHolderClickListener, shareClickListener) 110 | binding.btRetry.setOnClickListener { questionAdapter.retry() } 111 | } 112 | 113 | private val viewHolderClickListener = View.OnClickListener { v -> 114 | Intent(requireContext(), AnswerActivity::class.java).apply { 115 | val currentQuestion = v?.tag as? Question 116 | currentQuestion?.let { putExtra(AppConstants.EXTRA_QUESTION_KEY, it) } 117 | startActivity(this) 118 | } 119 | } 120 | 121 | private val shareClickListener = View.OnClickListener { v -> 122 | val currentQuestion = v.tag as? Question 123 | currentQuestion?.let { 124 | val content = getString( 125 | R.string.share_content, 126 | getString(R.string.question), 127 | it.link, 128 | AppConstants.PLAY_STORE_URL 129 | ) 130 | AppUtils.shareContent(content, requireContext()) 131 | } 132 | } 133 | 134 | private fun onLoaded() = binding.apply { 135 | activityPbFetchData.isInvisible = true 136 | activityRecyclerView.isVisible = true 137 | activityTvError.isInvisible = true 138 | btRetry.isInvisible = true 139 | } 140 | 141 | private fun onError() = binding.apply { 142 | activityPbFetchData.isInvisible = true 143 | activityRecyclerView.isInvisible = true 144 | activityTvError.isVisible = true 145 | btRetry.isVisible = true 146 | } 147 | 148 | private fun onLoading() = binding.apply { 149 | activityPbFetchData.isVisible = true 150 | activityRecyclerView.isVisible = true 151 | activityTvError.isInvisible = true 152 | btRetry.isInvisible = true 153 | } 154 | 155 | companion object { 156 | var title = "" 157 | var sortCondition = "" 158 | var tagName = "" 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /app/src/main/java/com/josycom/mayorjay/flowoverstack/view/init/SplashActivity.kt: -------------------------------------------------------------------------------- 1 | package com.josycom.mayorjay.flowoverstack.view.init 2 | 3 | import android.annotation.SuppressLint 4 | import android.content.Intent 5 | import android.os.Bundle 6 | import androidx.appcompat.app.AppCompatActivity 7 | import androidx.lifecycle.lifecycleScope 8 | import com.josycom.mayorjay.flowoverstack.util.AppConstants 9 | import com.josycom.mayorjay.flowoverstack.view.home.QuestionActivity 10 | import dagger.hilt.android.AndroidEntryPoint 11 | import kotlinx.coroutines.Job 12 | import kotlinx.coroutines.delay 13 | import kotlinx.coroutines.launch 14 | 15 | @SuppressLint("CustomSplashScreen") 16 | @AndroidEntryPoint 17 | class SplashActivity : AppCompatActivity() { 18 | 19 | private var job: Job? = null 20 | 21 | override fun onCreate(savedInstanceState: Bundle?) { 22 | super.onCreate(savedInstanceState) 23 | startJob() 24 | } 25 | 26 | private fun startJob() { 27 | job = lifecycleScope.launch { 28 | delay(AppConstants.SPLASH_TIME) 29 | goToMainActivity() 30 | } 31 | } 32 | 33 | private fun goToMainActivity() { 34 | startActivity(Intent(this, QuestionActivity::class.java)) 35 | finish() 36 | } 37 | 38 | override fun onDestroy() { 39 | super.onDestroy() 40 | job?.cancel() 41 | } 42 | } -------------------------------------------------------------------------------- /app/src/main/java/com/josycom/mayorjay/flowoverstack/view/search/SearchActivity.kt: -------------------------------------------------------------------------------- 1 | package com.josycom.mayorjay.flowoverstack.view.search 2 | 3 | import android.content.Intent 4 | import android.os.Bundle 5 | import android.text.TextUtils 6 | import android.view.MenuItem 7 | import android.view.View 8 | import android.view.inputmethod.InputMethodManager 9 | import androidx.activity.viewModels 10 | import androidx.appcompat.app.AppCompatActivity 11 | import androidx.core.view.isInvisible 12 | import androidx.core.view.isVisible 13 | import androidx.core.widget.NestedScrollView 14 | import androidx.recyclerview.widget.DefaultItemAnimator 15 | import androidx.recyclerview.widget.LinearLayoutManager 16 | import androidx.recyclerview.widget.RecyclerView 17 | import com.josycom.mayorjay.flowoverstack.R 18 | import com.josycom.mayorjay.flowoverstack.databinding.ActivitySearchBinding 19 | import com.josycom.mayorjay.flowoverstack.data.model.Question 20 | import com.josycom.mayorjay.flowoverstack.data.remote.model.SearchResponse 21 | import com.josycom.mayorjay.flowoverstack.viewmodel.SearchViewModel 22 | import com.josycom.mayorjay.flowoverstack.util.AppConstants 23 | import com.josycom.mayorjay.flowoverstack.util.AppUtils 24 | import com.josycom.mayorjay.flowoverstack.view.answer.AnswerActivity 25 | import dagger.hilt.android.AndroidEntryPoint 26 | 27 | @AndroidEntryPoint 28 | class SearchActivity : AppCompatActivity() { 29 | 30 | private lateinit var binding: ActivitySearchBinding 31 | private var searchInput: String = "" 32 | private var questions: List? = listOf() 33 | private val searchViewModel: SearchViewModel by viewModels() 34 | override fun onCreate(savedInstanceState: Bundle?) { 35 | super.onCreate(savedInstanceState) 36 | binding = ActivitySearchBinding.inflate(layoutInflater) 37 | setContentView(binding.root) 38 | 39 | binding.apply { 40 | rvSearchResults.layoutManager = LinearLayoutManager(this@SearchActivity) 41 | rvSearchResults.setHasFixedSize(true) 42 | rvSearchResults.itemAnimator = DefaultItemAnimator() 43 | searchScrollUpFab.isInvisible = true 44 | searchNestedScrollview.setOnScrollChangeListener { _: NestedScrollView?, _: Int, scrollY: Int, _: Int, _: Int -> 45 | if (scrollY > 0) { 46 | searchScrollUpFab.isVisible = true 47 | } else { 48 | searchScrollUpFab.isInvisible = true 49 | } 50 | } 51 | searchScrollUpFab.setOnClickListener { searchNestedScrollview.scrollTo(0, 0) } 52 | } 53 | 54 | binding.searchButton.setOnClickListener { 55 | if (TextUtils.isEmpty(binding.searchTextInputEditText.text.toString())) { 56 | binding.searchTextInputEditText.error = getString(R.string.type_a_search_query) 57 | } else { 58 | searchInput = binding.searchTextInputEditText.text.toString().trim() 59 | val inputMethodManager = getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager 60 | inputMethodManager.hideSoftInputFromWindow(currentFocus?.windowToken, 0) 61 | setQuery() 62 | binding.ivLookup.isInvisible = true 63 | } 64 | } 65 | val searchAdapter = SearchAdapter() 66 | searchViewModel.responseLiveData.observe(this) { searchResponse: SearchResponse -> 67 | when (searchResponse.networkState) { 68 | AppConstants.LOADING -> onLoading() 69 | AppConstants.LOADED -> { 70 | onLoaded() 71 | questions = searchResponse.questions 72 | searchAdapter.setQuestions(searchResponse.questions) 73 | } 74 | AppConstants.NO_MATCHING_RESULT -> onNoMatchingResult() 75 | AppConstants.FAILED -> onError() 76 | else -> searchAdapter.setQuestions(null) 77 | } 78 | } 79 | binding.rvSearchResults.adapter = searchAdapter 80 | searchAdapter.setOnClickListener(viewHolderClickListener, shareClickListener) 81 | } 82 | 83 | private val viewHolderClickListener = View.OnClickListener { v -> 84 | val viewHolder = v.tag as RecyclerView.ViewHolder 85 | val position = viewHolder.bindingAdapterPosition 86 | Intent(this, AnswerActivity::class.java).apply { 87 | val currentQuestion = questions?.get(position) 88 | currentQuestion?.let { putExtra(AppConstants.EXTRA_QUESTION_KEY, it) } 89 | startActivity(this) 90 | } 91 | } 92 | 93 | private val shareClickListener = View.OnClickListener { v -> 94 | val currentQuestion = v.tag as? Question 95 | currentQuestion?.let { 96 | val content = getString( 97 | R.string.share_content, 98 | getString(R.string.question), 99 | it.link, 100 | AppConstants.PLAY_STORE_URL 101 | ) 102 | AppUtils.shareContent(content, this) 103 | } 104 | } 105 | 106 | private fun setQuery() { 107 | searchViewModel.setQuery(searchInput) 108 | } 109 | 110 | private fun onLoading() = binding.apply { 111 | searchPbFetchData.isVisible = true 112 | rvSearchResults.isInvisible = true 113 | searchTvError.isInvisible = true 114 | } 115 | 116 | private fun onLoaded() = binding.apply { 117 | searchPbFetchData.isInvisible = true 118 | rvSearchResults.isVisible = true 119 | searchTvError.isInvisible = true 120 | } 121 | 122 | private fun onNoMatchingResult() = binding.apply { 123 | searchPbFetchData.isInvisible = true 124 | rvSearchResults.isInvisible = true 125 | searchTvError.isVisible = true 126 | searchTvError.setText(R.string.no_matching_result) 127 | } 128 | 129 | private fun onError() = binding.apply { 130 | searchPbFetchData.isInvisible = true 131 | rvSearchResults.isInvisible = true 132 | searchTvError.isVisible = true 133 | searchTvError.setText(R.string.network_error_message) 134 | } 135 | 136 | override fun onOptionsItemSelected(item: MenuItem): Boolean { 137 | if (item.itemId == android.R.id.home) { 138 | onBackPressed() 139 | return true 140 | } 141 | return super.onOptionsItemSelected(item) 142 | } 143 | 144 | override fun onBackPressed() { 145 | finish() 146 | } 147 | } -------------------------------------------------------------------------------- /app/src/main/java/com/josycom/mayorjay/flowoverstack/view/search/SearchAdapter.kt: -------------------------------------------------------------------------------- 1 | package com.josycom.mayorjay.flowoverstack.view.search 2 | 3 | import android.view.LayoutInflater 4 | import android.view.View 5 | import android.view.ViewGroup 6 | import androidx.core.view.isInvisible 7 | import androidx.core.view.isVisible 8 | import androidx.recyclerview.widget.RecyclerView 9 | import com.bumptech.glide.Glide 10 | import com.josycom.mayorjay.flowoverstack.R 11 | import com.josycom.mayorjay.flowoverstack.databinding.QuestionItemBinding 12 | import com.josycom.mayorjay.flowoverstack.data.model.Question 13 | import com.josycom.mayorjay.flowoverstack.view.search.SearchAdapter.SearchViewHolder 14 | import com.josycom.mayorjay.flowoverstack.util.AppUtils 15 | import org.jsoup.Jsoup 16 | 17 | class SearchAdapter : RecyclerView.Adapter() { 18 | 19 | private var questions: List? = null 20 | 21 | companion object { 22 | private var clickListener: View.OnClickListener? = null 23 | private var shareClickListener: View.OnClickListener? = null 24 | } 25 | 26 | override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SearchViewHolder { 27 | val questionItemBinding = QuestionItemBinding.inflate(LayoutInflater.from(parent.context), parent, false) 28 | return SearchViewHolder(questionItemBinding) 29 | } 30 | 31 | override fun onBindViewHolder(holder: SearchViewHolder, position: Int) { 32 | if (questions != null) { 33 | val currentQuestion = questions?.get(position) 34 | holder.bind(currentQuestion) 35 | } 36 | } 37 | 38 | fun setOnClickListener(onClickListener: View.OnClickListener?, shareOnClickListener: View.OnClickListener?) { 39 | clickListener = onClickListener 40 | shareClickListener = shareOnClickListener 41 | } 42 | 43 | override fun getItemCount(): Int { 44 | return if (questions != null) questions?.size ?: 0 else 0 45 | } 46 | 47 | fun setQuestions(questions: List?) { 48 | this.questions = questions 49 | notifyDataSetChanged() 50 | } 51 | 52 | inner class SearchViewHolder(private val binding: QuestionItemBinding) : RecyclerView.ViewHolder(binding.root) { 53 | 54 | init { 55 | binding.root.tag = this 56 | binding.root.setOnClickListener(clickListener) 57 | binding.ivShare.setOnClickListener(shareClickListener) 58 | } 59 | 60 | fun bind(question: Question?) { 61 | if (question != null) { 62 | binding.ivShare.tag = question 63 | val owner = question.owner 64 | val profileImage = owner.profileImage 65 | val tagList = question.tags 66 | Glide.with(binding.root.context) 67 | .load(profileImage) 68 | .placeholder(R.drawable.loading) 69 | .into(binding.ivAvatarItem) 70 | binding.tvQuestionItem.text = Jsoup.parse(question.title).text() 71 | binding.tvViewsCountItem.text = question.viewCount.toString() 72 | binding.tvDateItem.text = AppUtils.toNormalDate(question.creationDate.toLong()) 73 | if (question.isAnswered) { 74 | binding.answered.isVisible = true 75 | } else { 76 | binding.answered.isInvisible = true 77 | } 78 | val answers = question.answerCount 79 | val resources = binding.root.context.resources 80 | val answerCount = resources.getQuantityString(R.plurals.answers, answers, answers) 81 | binding.tvAnswersCountItem.text = answerCount 82 | if (question.score <= 0) { 83 | binding.tvVotesCountItem.text = question.score.toString() 84 | } else { 85 | binding.tvVotesCountItem.text = 86 | binding.root.context.getString(R.string.plus_score, question.score) 87 | } 88 | binding.tvTagsListItem.text = AppUtils.getFormattedTags(tagList) 89 | } 90 | } 91 | } 92 | } -------------------------------------------------------------------------------- /app/src/main/java/com/josycom/mayorjay/flowoverstack/view/tag/TagsAdapter.kt: -------------------------------------------------------------------------------- 1 | package com.josycom.mayorjay.flowoverstack.view.tag 2 | 3 | import android.annotation.SuppressLint 4 | import android.view.LayoutInflater 5 | import android.view.View 6 | import android.view.ViewGroup 7 | import androidx.paging.PagingDataAdapter 8 | import androidx.recyclerview.widget.DiffUtil 9 | import androidx.recyclerview.widget.RecyclerView 10 | import com.josycom.mayorjay.flowoverstack.R 11 | import com.josycom.mayorjay.flowoverstack.databinding.TagItemBinding 12 | import com.josycom.mayorjay.flowoverstack.data.model.Tag 13 | 14 | class TagsAdapter: PagingDataAdapter(DIFF_CALLBACK) { 15 | 16 | override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TagViewHolder { 17 | val view = TagItemBinding.inflate(LayoutInflater.from(parent.context), parent, false) 18 | return TagViewHolder(view) 19 | } 20 | 21 | override fun onBindViewHolder(holder: TagViewHolder, position: Int) { 22 | getItem(position)?.let { holder.bind(it) } 23 | } 24 | 25 | fun setOnClickListener(listener: View.OnClickListener?) { 26 | onClickListener = listener 27 | } 28 | 29 | companion object { 30 | private var onClickListener: View.OnClickListener? = null 31 | private val DIFF_CALLBACK = object: DiffUtil.ItemCallback() { 32 | override fun areItemsTheSame(oldItem: Tag, newItem: Tag): Boolean = oldItem.name == newItem.name 33 | 34 | @SuppressLint("DiffUtilEquals") 35 | override fun areContentsTheSame(oldItem: Tag, newItem: Tag): Boolean = oldItem == newItem 36 | } 37 | } 38 | 39 | 40 | class TagViewHolder(private val binding: TagItemBinding): RecyclerView.ViewHolder(binding.root) { 41 | 42 | init { 43 | binding.root.tag = this 44 | binding.root.setOnClickListener(onClickListener) 45 | } 46 | 47 | fun bind(tag: Tag) { 48 | with(binding) { 49 | tvTagName.text = tag.name 50 | tvTagCount.text = root.context.resources.getQuantityString( 51 | R.plurals.questions, 52 | tag.count.toInt(), 53 | tag.count.toInt() 54 | ) 55 | } 56 | } 57 | } 58 | } -------------------------------------------------------------------------------- /app/src/main/java/com/josycom/mayorjay/flowoverstack/view/tag/TagsDialogFragment.kt: -------------------------------------------------------------------------------- 1 | package com.josycom.mayorjay.flowoverstack.view.tag 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.TextView 8 | import androidx.appcompat.app.AppCompatActivity 9 | import androidx.core.view.isInvisible 10 | import androidx.core.view.isVisible 11 | import androidx.fragment.app.DialogFragment 12 | import androidx.fragment.app.viewModels 13 | import androidx.lifecycle.Lifecycle 14 | import androidx.lifecycle.lifecycleScope 15 | import androidx.lifecycle.repeatOnLifecycle 16 | import androidx.paging.LoadState 17 | import androidx.recyclerview.widget.DefaultItemAnimator 18 | import androidx.recyclerview.widget.LinearLayoutManager 19 | import androidx.recyclerview.widget.RecyclerView 20 | import com.josycom.mayorjay.flowoverstack.R 21 | import com.josycom.mayorjay.flowoverstack.databinding.TagsDialogFragmentBinding 22 | import com.josycom.mayorjay.flowoverstack.view.home.PagingLoadStateAdapter 23 | import com.josycom.mayorjay.flowoverstack.viewmodel.TagsDialogViewModel 24 | import com.josycom.mayorjay.flowoverstack.util.AppConstants 25 | import dagger.hilt.android.AndroidEntryPoint 26 | import kotlinx.coroutines.flow.collectLatest 27 | import kotlinx.coroutines.launch 28 | import org.jsoup.internal.StringUtil 29 | import java.util.Locale 30 | 31 | @AndroidEntryPoint 32 | class TagsDialogFragment : DialogFragment() { 33 | 34 | private lateinit var activity: AppCompatActivity 35 | private val viewModel: TagsDialogViewModel by viewModels() 36 | private lateinit var binding: TagsDialogFragmentBinding 37 | private var tagSelectionCallback: TagSelectionCallback? = null 38 | private var title: String = "" 39 | private var isPopularTagOption: Boolean = false 40 | 41 | override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { 42 | activity = getActivity() as AppCompatActivity 43 | binding = TagsDialogFragmentBinding.inflate(layoutInflater) 44 | return binding.root 45 | } 46 | 47 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 48 | super.onViewCreated(view, savedInstanceState) 49 | 50 | initViews() 51 | if (isPopularTagOption) { 52 | fetchAndDisplayTags("") 53 | } 54 | setupListeners() 55 | } 56 | 57 | private fun initViews() { 58 | binding.tvTitle.text = title 59 | binding.layoutSearch.isInvisible = isPopularTagOption 60 | binding.tvInfo.isInvisible = !isPopularTagOption 61 | } 62 | 63 | private fun setupListeners() { 64 | binding.btSearch.setOnClickListener { 65 | val query = binding.searchTextInputEditText.text.toString().trim() 66 | .lowercase(Locale.getDefault()) 67 | if (StringUtil.isBlank(query)) { 68 | binding.searchTextInputLayout.isErrorEnabled = true 69 | binding.searchTextInputLayout.error = getString(R.string.type_a_search_query) 70 | return@setOnClickListener 71 | } 72 | binding.searchTextInputLayout.isErrorEnabled = false 73 | fetchAndDisplayTags(query) 74 | } 75 | } 76 | 77 | private fun fetchAndDisplayTags(inName: String) { 78 | viewModel.fetchTags(inName, AppConstants.FIRST_PAGE, AppConstants.PAGE_SIZE, AppConstants.API_KEY) 79 | binding.ivLookup.isInvisible = true 80 | val popularTagAdapter = TagsAdapter() 81 | binding.rvPopularTags.apply { 82 | layoutManager = LinearLayoutManager(activity) 83 | itemAnimator = DefaultItemAnimator() 84 | adapter = popularTagAdapter.withLoadStateFooter(PagingLoadStateAdapter { popularTagAdapter.retry() }) 85 | } 86 | 87 | viewLifecycleOwner.lifecycleScope.launch { 88 | popularTagAdapter.loadStateFlow.collect { 89 | binding.pbPopularTags.isVisible = it.source.refresh is LoadState.Loading 90 | binding.tvError.isVisible = it.source.refresh is LoadState.Error 91 | binding.btRetry.isVisible = it.source.refresh is LoadState.Error 92 | binding.tvInfo.isVisible = it.source.refresh !is LoadState.Loading && it.source.refresh !is LoadState.Error && isPopularTagOption 93 | if (it.source.refresh is LoadState.NotLoading && popularTagAdapter.itemCount <= 0) { 94 | binding.tvError.isVisible = true 95 | binding.tvError.setText(R.string.no_matching_result_rephrase) 96 | } else { 97 | binding.tvError.isInvisible = true 98 | } 99 | } 100 | } 101 | 102 | viewLifecycleOwner.lifecycleScope.launch { 103 | repeatOnLifecycle(Lifecycle.State.STARTED) { 104 | viewModel.tagDataFlow?.collectLatest { 105 | popularTagAdapter.submitData(it) 106 | } 107 | } 108 | } 109 | 110 | popularTagAdapter.setOnClickListener(onClickListener) 111 | binding.btRetry.setOnClickListener { popularTagAdapter.retry() } 112 | } 113 | 114 | private val onClickListener = View.OnClickListener { v -> 115 | val holder = v?.tag as RecyclerView.ViewHolder 116 | val tagName = holder.itemView.findViewById(R.id.tv_tag_name).text 117 | tagSelectionCallback?.onTagSelected(tagName.toString()) 118 | dismiss() 119 | } 120 | 121 | override fun onStart() { 122 | super.onStart() 123 | val window = dialog?.window 124 | if (window != null) { 125 | val width = ViewGroup.LayoutParams.MATCH_PARENT 126 | val height = ViewGroup.LayoutParams.MATCH_PARENT 127 | window.setLayout(width, height) 128 | } 129 | } 130 | 131 | fun setTagSelectionListener(tagSelectionCallback: TagSelectionCallback) { 132 | this.tagSelectionCallback = tagSelectionCallback 133 | } 134 | 135 | fun setInitItems(title: String, isPopularTagOption: Boolean) { 136 | this.title = title 137 | this.isPopularTagOption = isPopularTagOption 138 | } 139 | 140 | interface TagSelectionCallback { 141 | fun onTagSelected(tagName: String) 142 | } 143 | 144 | } -------------------------------------------------------------------------------- /app/src/main/java/com/josycom/mayorjay/flowoverstack/viewmodel/AnswerViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.josycom.mayorjay.flowoverstack.viewmodel 2 | 3 | import com.josycom.mayorjay.flowoverstack.data.repository.AnswerRepository 4 | import androidx.lifecycle.ViewModel 5 | import androidx.lifecycle.viewModelScope 6 | import androidx.paging.PagingData 7 | import androidx.paging.cachedIn 8 | import com.josycom.mayorjay.flowoverstack.data.model.Answer 9 | import dagger.hilt.android.lifecycle.HiltViewModel 10 | import kotlinx.coroutines.flow.Flow 11 | import javax.inject.Inject 12 | 13 | @HiltViewModel 14 | class AnswerViewModel @Inject constructor(private val answerRepository: AnswerRepository) : ViewModel() { 15 | 16 | var answerDataFlow: Flow>? = null 17 | 18 | fun init(questionId: Int, order: String, sortCondition: String, site: String, filter: String, siteKey: String) { 19 | answerRepository.init(questionId, order, sortCondition, site, filter, siteKey) 20 | answerDataFlow = answerRepository.answerDataFlow?.cachedIn(viewModelScope) 21 | } 22 | } -------------------------------------------------------------------------------- /app/src/main/java/com/josycom/mayorjay/flowoverstack/viewmodel/QuestionActivityViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.josycom.mayorjay.flowoverstack.viewmodel 2 | 3 | import androidx.lifecycle.LiveData 4 | import androidx.lifecycle.ViewModel 5 | import androidx.lifecycle.asLiveData 6 | import androidx.lifecycle.viewModelScope 7 | import com.josycom.mayorjay.flowoverstack.data.repository.PreferenceRepository 8 | import dagger.hilt.android.lifecycle.HiltViewModel 9 | import kotlinx.coroutines.flow.Flow 10 | import kotlinx.coroutines.launch 11 | import javax.inject.Inject 12 | 13 | @HiltViewModel 14 | class QuestionActivityViewModel @Inject constructor(private val preferenceRepository: PreferenceRepository) : 15 | ViewModel() { 16 | 17 | var appOpenCountLiveData: LiveData? = null 18 | var appOpenCount = 0 19 | 20 | fun getAppOpenCountPref(key: String): Flow { 21 | return preferenceRepository.getIntPreferenceFlow(key).apply { 22 | appOpenCountLiveData = this.asLiveData() 23 | } 24 | } 25 | 26 | fun saveAppOpenCounts(key: String, value: Int) { 27 | viewModelScope.launch { 28 | preferenceRepository.setIntPreference(key, value) 29 | } 30 | } 31 | 32 | fun deletePreferences() { 33 | viewModelScope.launch { 34 | preferenceRepository.deleteAllPreferences() 35 | } 36 | } 37 | } -------------------------------------------------------------------------------- /app/src/main/java/com/josycom/mayorjay/flowoverstack/viewmodel/QuestionViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.josycom.mayorjay.flowoverstack.viewmodel 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.viewModelScope 5 | import androidx.paging.PagingData 6 | import androidx.paging.cachedIn 7 | import com.josycom.mayorjay.flowoverstack.data.model.Question 8 | import com.josycom.mayorjay.flowoverstack.data.repository.QuestionRepository 9 | import dagger.hilt.android.lifecycle.HiltViewModel 10 | import kotlinx.coroutines.flow.Flow 11 | import javax.inject.Inject 12 | 13 | @HiltViewModel 14 | class QuestionViewModel @Inject constructor(private val questionRepository: QuestionRepository) : ViewModel() { 15 | 16 | var questionDataFlow: Flow>? = null 17 | 18 | fun init(page: Int, pageSize: Int, order: String, sortCondition: String, site: String, tagged: String, filter: String, siteKey: String) { 19 | questionRepository.init(page, pageSize, order, sortCondition, site, tagged, filter, siteKey) 20 | questionDataFlow = questionRepository.questionDataFlow?.cachedIn(viewModelScope) 21 | } 22 | } -------------------------------------------------------------------------------- /app/src/main/java/com/josycom/mayorjay/flowoverstack/viewmodel/SearchViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.josycom.mayorjay.flowoverstack.viewmodel 2 | 3 | import androidx.lifecycle.ViewModel 4 | import com.josycom.mayorjay.flowoverstack.data.remote.model.SearchResponse 5 | import com.josycom.mayorjay.flowoverstack.data.repository.SearchRepository 6 | import com.josycom.mayorjay.flowoverstack.util.AppConstants 7 | import dagger.hilt.android.lifecycle.HiltViewModel 8 | import javax.inject.Inject 9 | 10 | @HiltViewModel 11 | class SearchViewModel @Inject constructor(private val searchRepository: SearchRepository) : ViewModel() { 12 | 13 | val responseLiveData = searchRepository.searchResponse 14 | 15 | init { 16 | responseLiveData.value = SearchResponse() 17 | } 18 | 19 | fun setQuery(query: String) { 20 | searchRepository.performSearch(query, AppConstants.FIRST_PAGE, AppConstants.SEARCH_PAGE_SIZE) 21 | } 22 | } -------------------------------------------------------------------------------- /app/src/main/java/com/josycom/mayorjay/flowoverstack/viewmodel/TagsDialogViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.josycom.mayorjay.flowoverstack.viewmodel 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.viewModelScope 5 | import androidx.paging.PagingData 6 | import androidx.paging.cachedIn 7 | import com.josycom.mayorjay.flowoverstack.data.repository.TagRepository 8 | import com.josycom.mayorjay.flowoverstack.data.model.Tag 9 | import dagger.hilt.android.lifecycle.HiltViewModel 10 | import kotlinx.coroutines.flow.Flow 11 | import javax.inject.Inject 12 | 13 | @HiltViewModel 14 | class TagsDialogViewModel @Inject constructor(private val tagRepository: TagRepository) : ViewModel() { 15 | 16 | var tagDataFlow: Flow>? = null 17 | 18 | fun fetchTags(inName: String, page: Int, pageSize: Int, siteKey: String) { 19 | tagRepository.init(page, pageSize, inName, siteKey) 20 | tagDataFlow = tagRepository.tagDataFlow?.cachedIn(viewModelScope) 21 | } 22 | } -------------------------------------------------------------------------------- /app/src/main/res/anim/fab_close.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 13 | 17 | -------------------------------------------------------------------------------- /app/src/main/res/anim/fab_open.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 13 | 17 | -------------------------------------------------------------------------------- /app/src/main/res/anim/fade_in_anim.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 8 | -------------------------------------------------------------------------------- /app/src/main/res/anim/fade_out_anim.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 8 | -------------------------------------------------------------------------------- /app/src/main/res/anim/slide_in_left.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | -------------------------------------------------------------------------------- /app/src/main/res/anim/slide_in_right.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/anim/slide_out_left.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/anim/slide_out_right.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/animator/search_button_state_list_anim.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 9 | 10 | 15 | 20 | 21 | 22 | 23 | 24 | 27 | 28 | 33 | 38 | 39 | 40 | 41 | 42 | 45 | 46 | 51 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 69 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 85 | 90 | 91 | 92 | 93 | 94 | -------------------------------------------------------------------------------- /app/src/main/res/drawable-anydpi/ic_action_scroll_up.xml: -------------------------------------------------------------------------------- 1 | 8 | 11 | 12 | -------------------------------------------------------------------------------- /app/src/main/res/drawable-anydpi/ic_info.xml: -------------------------------------------------------------------------------- 1 | 8 | 11 | 12 | -------------------------------------------------------------------------------- /app/src/main/res/drawable-hdpi/ic_action_scroll_up.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mayorjay/StackOverflow-Community-Version/eb154d661077579ccf00bbf329d2bf6ae358d7e7/app/src/main/res/drawable-hdpi/ic_action_scroll_up.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-hdpi/ic_info.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mayorjay/StackOverflow-Community-Version/eb154d661077579ccf00bbf329d2bf6ae358d7e7/app/src/main/res/drawable-hdpi/ic_info.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-mdpi/ic_action_scroll_up.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mayorjay/StackOverflow-Community-Version/eb154d661077579ccf00bbf329d2bf6ae358d7e7/app/src/main/res/drawable-mdpi/ic_action_scroll_up.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-mdpi/ic_info.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mayorjay/StackOverflow-Community-Version/eb154d661077579ccf00bbf329d2bf6ae358d7e7/app/src/main/res/drawable-mdpi/ic_info.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xhdpi/ic_action_scroll_up.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mayorjay/StackOverflow-Community-Version/eb154d661077579ccf00bbf329d2bf6ae358d7e7/app/src/main/res/drawable-xhdpi/ic_action_scroll_up.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xhdpi/ic_info.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mayorjay/StackOverflow-Community-Version/eb154d661077579ccf00bbf329d2bf6ae358d7e7/app/src/main/res/drawable-xhdpi/ic_info.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xhdpi/loading.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mayorjay/StackOverflow-Community-Version/eb154d661077579ccf00bbf329d2bf6ae358d7e7/app/src/main/res/drawable-xhdpi/loading.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xxhdpi/ic_action_scroll_up.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mayorjay/StackOverflow-Community-Version/eb154d661077579ccf00bbf329d2bf6ae358d7e7/app/src/main/res/drawable-xxhdpi/ic_action_scroll_up.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xxhdpi/ic_info.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mayorjay/StackOverflow-Community-Version/eb154d661077579ccf00bbf329d2bf6ae358d7e7/app/src/main/res/drawable-xxhdpi/ic_info.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/app_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mayorjay/StackOverflow-Community-Version/eb154d661077579ccf00bbf329d2bf6ae358d7e7/app/src/main/res/drawable/app_icon.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/app_iconn.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mayorjay/StackOverflow-Community-Version/eb154d661077579ccf00bbf329d2bf6ae358d7e7/app/src/main/res/drawable/app_iconn.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/button_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_baseline_verified_answer.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_camera.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_clear.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_close.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_date.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_email.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_keyboard.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_refresh.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_search.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_share.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_share_green.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_tag.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_twitter.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_view.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_vote.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/splash_screen.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/tag_bg.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/font/magra.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | -------------------------------------------------------------------------------- /app/src/main/res/font/montserrat.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | -------------------------------------------------------------------------------- /app/src/main/res/font/montserrat_bold.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | -------------------------------------------------------------------------------- /app/src/main/res/font/montserrat_light.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mayorjay/StackOverflow-Community-Version/eb154d661077579ccf00bbf329d2bf6ae358d7e7/app/src/main/res/font/montserrat_light.otf -------------------------------------------------------------------------------- /app/src/main/res/font/poppins_bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mayorjay/StackOverflow-Community-Version/eb154d661077579ccf00bbf329d2bf6ae358d7e7/app/src/main/res/font/poppins_bold.ttf -------------------------------------------------------------------------------- /app/src/main/res/font/poppins_light.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mayorjay/StackOverflow-Community-Version/eb154d661077579ccf00bbf329d2bf6ae358d7e7/app/src/main/res/font/poppins_light.ttf -------------------------------------------------------------------------------- /app/src/main/res/font/poppins_semibold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mayorjay/StackOverflow-Community-Version/eb154d661077579ccf00bbf329d2bf6ae358d7e7/app/src/main/res/font/poppins_semibold.ttf -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_ocr.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 14 | 15 | 18 | 19 | 25 | 26 | 38 | 39 | 55 | 56 | 66 | 67 | 80 | 81 | 88 | 89 | 90 | 106 | 107 | 112 | 113 | 126 | 127 | 128 | 129 | 130 | 147 | 148 | 163 | -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_question.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 14 | 15 | 21 | 22 | 23 | 24 | 25 | 26 | 49 | 50 | 73 | 74 | 90 | 91 | -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_search.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 14 | 15 | 18 | 19 | 30 | 31 | 37 | 38 | 39 | 50 | 51 | 64 | 65 | 78 | 79 | 85 | 86 | 90 | 91 | 92 | 93 | 94 | 95 | 110 | 111 | 118 | 119 | -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_web_view.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 17 | 18 | 21 | 22 | 35 | 36 | 43 | 44 | 45 | 46 | 47 | 48 | 53 | 54 | 58 | 59 | 60 | 61 | -------------------------------------------------------------------------------- /app/src/main/res/layout/answer_item.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 20 | 21 | 31 | 32 | 41 | 42 | 51 | 52 | 61 | 62 | 71 | 72 | 82 | 83 | 94 | 95 | 102 | 103 | -------------------------------------------------------------------------------- /app/src/main/res/layout/content_main.xml: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /app/src/main/res/layout/fragment_questions.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 19 | 20 | 31 | 32 | 47 | 48 |