initial commit
This commit is contained in:
commit
99038aa419
|
@ -0,0 +1 @@
|
|||
* @connyduck
|
|
@ -0,0 +1 @@
|
|||
open_collective: pixelcat
|
|
@ -0,0 +1,11 @@
|
|||
*.iml
|
||||
.gradle
|
||||
/local.properties
|
||||
/.idea
|
||||
/.DS_Store
|
||||
/build
|
||||
/captures
|
||||
.externalNativeBuild
|
||||
*.apk
|
||||
*.aab
|
||||
output.json
|
|
@ -0,0 +1 @@
|
|||
/build
|
|
@ -0,0 +1,114 @@
|
|||
plugins {
|
||||
id("com.android.application")
|
||||
id("kotlin-android")
|
||||
id("kotlin-android-extensions")
|
||||
id("kotlin-kapt")
|
||||
}
|
||||
|
||||
android {
|
||||
compileSdkVersion(29)
|
||||
defaultConfig {
|
||||
applicationId = "at.connyduck.pixelcat"
|
||||
minSdkVersion(23)
|
||||
targetSdkVersion(29)
|
||||
versionCode = 1
|
||||
versionName = "0.0"
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
getByName("release") {
|
||||
isMinifyEnabled = true
|
||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_1_8
|
||||
targetCompatibility = JavaVersion.VERSION_1_8
|
||||
}
|
||||
|
||||
androidExtensions {
|
||||
features = setOf("parcelize")
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
viewBinding = true
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
android.sourceSets["main"].java.srcDir("src/main/kotlin")
|
||||
|
||||
tasks {
|
||||
withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile> {
|
||||
kotlinOptions.jvmTarget = "1.8"
|
||||
kotlinOptions.freeCompilerArgs += "-Xopt-in=kotlin.RequiresOptIn"
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
|
||||
val lifecycleVersion = "2.3.0-alpha04"
|
||||
val roomVersion = "2.3.0-alpha01"
|
||||
val okHttpVersion = "4.7.2"
|
||||
val retrofitVersion = "2.9.0"
|
||||
val moshiVersion = "1.9.2"
|
||||
val daggerVersion = "2.27"
|
||||
|
||||
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.3.72")
|
||||
|
||||
implementation("androidx.core:core:1.3.0")
|
||||
implementation("androidx.appcompat:appcompat:1.3.0-alpha01")
|
||||
implementation("androidx.activity:activity-ktx:1.2.0-alpha06")
|
||||
implementation("androidx.fragment:fragment-ktx:1.3.0-alpha06")
|
||||
implementation("com.google.android.material:material:1.2.0-beta01")
|
||||
implementation("androidx.constraintlayout:constraintlayout:1.1.3")
|
||||
implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0-rc01")
|
||||
implementation("androidx.recyclerview:recyclerview:1.2.0-alpha03")
|
||||
implementation("androidx.annotation:annotation:1.1.0")
|
||||
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycleVersion")
|
||||
implementation("androidx.lifecycle:lifecycle-livedata-ktx:$lifecycleVersion")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:$lifecycleVersion")
|
||||
implementation("androidx.lifecycle:lifecycle-common-java8:$lifecycleVersion")
|
||||
implementation("androidx.preference:preference:1.1.1")
|
||||
implementation("androidx.emoji:emoji-bundled:1.1.0-rc01")
|
||||
implementation("androidx.paging:paging-runtime-ktx:3.0.0-alpha01")
|
||||
implementation("androidx.viewpager2:viewpager2:1.0.0")
|
||||
|
||||
implementation("androidx.room:room-runtime:$roomVersion")
|
||||
implementation("androidx.room:room-ktx:$roomVersion")
|
||||
kapt("androidx.room:room-compiler:$roomVersion")
|
||||
|
||||
implementation("com.squareup.okhttp3:okhttp:$okHttpVersion")
|
||||
implementation("com.squareup.okhttp3:logging-interceptor:$okHttpVersion")
|
||||
|
||||
implementation("com.squareup.retrofit2:retrofit:$retrofitVersion")
|
||||
implementation("com.squareup.retrofit2:converter-moshi:$retrofitVersion")
|
||||
implementation("com.squareup.moshi:moshi-kotlin:$moshiVersion")
|
||||
implementation("com.squareup.moshi:moshi-adapters:$moshiVersion")
|
||||
kapt("com.squareup.moshi:moshi-kotlin-codegen:$moshiVersion")
|
||||
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.7")
|
||||
|
||||
implementation("com.google.dagger:dagger:2.28")
|
||||
|
||||
implementation("com.fxn769:pix:1.4.4")
|
||||
implementation( "com.github.yalantis:ucrop:2.2.5")
|
||||
|
||||
implementation("me.relex:circleindicator:2.1.4")
|
||||
|
||||
implementation("io.coil-kt:coil:0.11.0")
|
||||
|
||||
implementation("com.github.connyduck:sparkbutton:4.0.0")
|
||||
|
||||
implementation("com.google.dagger:dagger:$daggerVersion")
|
||||
kapt("com.google.dagger:dagger-compiler:$daggerVersion")
|
||||
implementation("com.google.dagger:dagger-android:$daggerVersion")
|
||||
implementation("com.google.dagger:dagger-android-support:$daggerVersion")
|
||||
kapt("com.google.dagger:dagger-android-processor:$daggerVersion")
|
||||
|
||||
testImplementation("junit:junit:4.13")
|
||||
|
||||
implementation("com.facebook.stetho:stetho:1.5.1")
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
# Add project specific ProGuard rules here.
|
||||
# You can control the set of applied configuration files using the
|
||||
# proguardFiles setting in build.gradle.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# If your project uses WebView with JS, uncomment the following
|
||||
# and specify the fully qualified class name to the JavaScript interface
|
||||
# class:
|
||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||
# public *;
|
||||
#}
|
||||
|
||||
# Uncomment this to preserve the line number information for
|
||||
# debugging stack traces.
|
||||
#-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
|
@ -0,0 +1,57 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
package="at.connyduck.pixelcat">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
|
||||
<application
|
||||
android:name=".PixelcatApplication"
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/AppTheme"
|
||||
android:usesCleartextTraffic="false"
|
||||
tools:ignore="AllowBackup,GoogleAppIndexingWarning">
|
||||
<!-- TODO order activities -->
|
||||
<activity android:name=".components.splash.SplashActivity">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
|
||||
|
||||
<activity android:name=".components.login.LoginWebViewActivity">
|
||||
</activity>
|
||||
<activity
|
||||
android:name=".components.settings.SettingsActivity"
|
||||
android:label="@string/title_activity_settings">
|
||||
</activity>
|
||||
<activity
|
||||
android:name=".components.login.LoginActivity"
|
||||
android:theme="@style/AppTheme.Fullscreen">
|
||||
|
||||
</activity>
|
||||
<activity
|
||||
android:name=".components.main.MainActivity"
|
||||
android:label="@string/app_name">
|
||||
</activity>
|
||||
<activity
|
||||
android:name=".components.about.AboutActivity"
|
||||
android:theme="@style/AppTheme.Fullscreen"/>
|
||||
<activity android:name=".components.about.licenses.LicenseActivity"/>
|
||||
<activity android:name="at.connyduck.pixelcat.components.compose.ComposeActivity" />
|
||||
<activity android:name=".components.profile.ProfileActivity" />
|
||||
|
||||
<service android:name=".components.compose.SendStatusService" />
|
||||
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
|
@ -0,0 +1,38 @@
|
|||
package at.connyduck.pixelcat
|
||||
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.emoji.bundled.BundledEmojiCompatConfig
|
||||
import androidx.emoji.text.EmojiCompat
|
||||
import at.connyduck.pixelcat.components.settings.AppSettings
|
||||
import at.connyduck.pixelcat.dagger.DaggerAppComponent
|
||||
import com.facebook.stetho.Stetho
|
||||
import dagger.android.AndroidInjector
|
||||
import dagger.android.DaggerApplication
|
||||
import okhttp3.OkHttpClient
|
||||
import javax.inject.Inject
|
||||
|
||||
class PixelcatApplication : DaggerApplication() {
|
||||
|
||||
@Inject
|
||||
lateinit var appSettings: AppSettings
|
||||
|
||||
@Inject
|
||||
lateinit var okhttpClient: OkHttpClient
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
Stetho.initializeWithDefaults(this)
|
||||
|
||||
AppCompatDelegate.setDefaultNightMode(appSettings.getNightMode())
|
||||
|
||||
EmojiCompat.init(BundledEmojiCompatConfig(this))
|
||||
}
|
||||
|
||||
override fun applicationInjector(): AndroidInjector<PixelcatApplication> {
|
||||
return DaggerAppComponent.builder()
|
||||
.application(this)
|
||||
.build()
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,49 @@
|
|||
package at.connyduck.pixelcat.components.about
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.ViewGroup
|
||||
import at.connyduck.pixelcat.BuildConfig
|
||||
import at.connyduck.pixelcat.R
|
||||
import at.connyduck.pixelcat.components.about.licenses.LicenseActivity
|
||||
import at.connyduck.pixelcat.components.general.BaseActivity
|
||||
import at.connyduck.pixelcat.databinding.ActivityAboutBinding
|
||||
|
||||
class AboutActivity : BaseActivity() {
|
||||
|
||||
private lateinit var binding: ActivityAboutBinding
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
binding = ActivityAboutBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
|
||||
binding.root.setOnApplyWindowInsetsListener { _, insets ->
|
||||
val top = insets.systemWindowInsetTop
|
||||
|
||||
val toolbarParams = binding.aboutToolbar.layoutParams as ViewGroup.MarginLayoutParams
|
||||
toolbarParams.topMargin = top
|
||||
|
||||
insets.consumeSystemWindowInsets()
|
||||
}
|
||||
|
||||
setSupportActionBar(binding.aboutToolbar)
|
||||
|
||||
binding.aboutToolbar.setNavigationOnClickListener {
|
||||
onBackPressed()
|
||||
}
|
||||
|
||||
binding.aboutAppVersion.text = getString(R.string.about_version, BuildConfig.VERSION_NAME)
|
||||
|
||||
binding.aboutLicensesButton.setOnClickListener {
|
||||
startActivity(LicenseActivity.newIntent(this))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun newIntent(context: Context) = Intent(context, AboutActivity::class.java)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,70 @@
|
|||
package at.connyduck.pixelcat.components.about.licenses
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.view.ViewGroup
|
||||
import android.widget.TextView
|
||||
import androidx.annotation.RawRes
|
||||
import at.connyduck.pixelcat.R
|
||||
import at.connyduck.pixelcat.components.general.BaseActivity
|
||||
import at.connyduck.pixelcat.databinding.ActivityLicenseBinding
|
||||
import java.io.BufferedReader
|
||||
import java.io.IOException
|
||||
import java.io.InputStreamReader
|
||||
|
||||
class LicenseActivity : BaseActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
val binding = ActivityLicenseBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
|
||||
binding.licenseContainer.setOnApplyWindowInsetsListener { _, insets ->
|
||||
val top = insets.systemWindowInsetTop
|
||||
|
||||
val toolbarParams = binding.licenseToolbar.layoutParams as ViewGroup.MarginLayoutParams
|
||||
toolbarParams.topMargin = top
|
||||
|
||||
insets.consumeSystemWindowInsets()
|
||||
}
|
||||
|
||||
setSupportActionBar(binding.licenseToolbar)
|
||||
|
||||
binding.licenseToolbar.setNavigationOnClickListener {
|
||||
onBackPressed()
|
||||
}
|
||||
|
||||
loadFileIntoTextView(R.raw.apache, binding.licenseApacheTextView)
|
||||
|
||||
}
|
||||
|
||||
private fun loadFileIntoTextView(@RawRes fileId: Int, textView: TextView) {
|
||||
|
||||
val sb = StringBuilder()
|
||||
|
||||
val br = BufferedReader(InputStreamReader(resources.openRawResource(fileId)))
|
||||
|
||||
try {
|
||||
var line: String? = br.readLine()
|
||||
while (line != null) {
|
||||
sb.append(line)
|
||||
sb.append('\n')
|
||||
line = br.readLine()
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Log.w("LicenseActivity", e)
|
||||
}
|
||||
|
||||
br.close()
|
||||
|
||||
textView.text = sb.toString()
|
||||
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun newIntent(context: Context) = Intent(context, LicenseActivity::class.java)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,44 @@
|
|||
package at.connyduck.pixelcat.components.about.licenses
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.view.LayoutInflater
|
||||
import at.connyduck.pixelcat.R
|
||||
import at.connyduck.pixelcat.components.util.extension.hide
|
||||
import at.connyduck.pixelcat.databinding.CardLicenseBinding
|
||||
import com.google.android.material.card.MaterialCardView
|
||||
|
||||
class LicenseCard
|
||||
@JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = 0
|
||||
) : MaterialCardView(context, attrs, defStyleAttr) {
|
||||
|
||||
private val binding =
|
||||
CardLicenseBinding.inflate(LayoutInflater.from(context), this)
|
||||
|
||||
init {
|
||||
|
||||
val a = context.theme.obtainStyledAttributes(attrs, R.styleable.LicenseCard, 0, 0)
|
||||
|
||||
val name: String? = a.getString(R.styleable.LicenseCard_name)
|
||||
val license: String? = a.getString(R.styleable.LicenseCard_license)
|
||||
val link: String? = a.getString(R.styleable.LicenseCard_link)
|
||||
a.recycle()
|
||||
|
||||
radius = resources.getDimension(R.dimen.license_card_radius)
|
||||
|
||||
binding.licenseCardName.text = name
|
||||
binding.licenseCardLicense.text = license
|
||||
if(link.isNullOrBlank()) {
|
||||
binding.licenseCardLink.hide()
|
||||
} else {
|
||||
binding.licenseCardLink.text = link
|
||||
// setOnClickListener { LinkHelper.openLink(link, context) }
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
package at.connyduck.pixelcat.components.bottomsheet.accountselection
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import at.connyduck.pixelcat.R
|
||||
import at.connyduck.pixelcat.components.util.extension.hide
|
||||
import at.connyduck.pixelcat.components.util.extension.show
|
||||
import at.connyduck.pixelcat.databinding.ItemAccountSelectionBinding
|
||||
import at.connyduck.pixelcat.db.entitity.AccountEntity
|
||||
import coil.api.load
|
||||
import coil.transform.RoundedCornersTransformation
|
||||
|
||||
class AccountSelectionAdapter(
|
||||
private val accounts: List<AccountEntity>,
|
||||
private val onAccountSelected: (Long) -> Unit,
|
||||
private val onAddAccount: () -> Unit
|
||||
) : RecyclerView.Adapter<AccountSelectionViewHolder>() {
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AccountSelectionViewHolder {
|
||||
val binding =
|
||||
ItemAccountSelectionBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||
return AccountSelectionViewHolder(binding)
|
||||
}
|
||||
|
||||
override fun getItemCount() = accounts.size + 1
|
||||
|
||||
override fun onBindViewHolder(holder: AccountSelectionViewHolder, position: Int) {
|
||||
val binding = holder.binding
|
||||
if (position == accounts.size) {
|
||||
binding.accountAvatar.load(R.drawable.ic_plus_background)
|
||||
binding.accountName.hide()
|
||||
binding.accountDisplayName.setText(R.string.action_add_new_account)
|
||||
binding.root.isSelected = false
|
||||
binding.root.setOnClickListener {
|
||||
onAddAccount()
|
||||
}
|
||||
} else {
|
||||
val account = accounts[position]
|
||||
binding.accountAvatar.load(account.profilePictureUrl) {
|
||||
transformations(RoundedCornersTransformation(25f))
|
||||
}
|
||||
binding.accountDisplayName.text = account.displayName
|
||||
binding.accountName.show()
|
||||
binding.accountName.text = account.fullName
|
||||
|
||||
binding.root.isSelected = account.isActive
|
||||
binding.root.setOnClickListener {
|
||||
if (!account.isActive) {
|
||||
onAccountSelected(account.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
class AccountSelectionViewHolder(val binding: ItemAccountSelectionBinding) :
|
||||
RecyclerView.ViewHolder(binding.root)
|
|
@ -0,0 +1,62 @@
|
|||
package at.connyduck.pixelcat.components.bottomsheet.accountselection
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import at.connyduck.pixelcat.components.login.LoginActivity
|
||||
import at.connyduck.pixelcat.components.main.MainActivity
|
||||
import at.connyduck.pixelcat.databinding.BottomsheetAccountsBinding
|
||||
import at.connyduck.pixelcat.db.AccountManager
|
||||
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
|
||||
class AccountSelectionBottomSheet(
|
||||
private val accountManager: AccountManager
|
||||
) : BottomSheetDialogFragment() {
|
||||
|
||||
private var _binding: BottomsheetAccountsBinding? = null
|
||||
private val binding
|
||||
get() = _binding!!
|
||||
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
_binding = BottomsheetAccountsBinding.inflate(inflater, container, false)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
lifecycleScope.launch {
|
||||
binding.accountsRecyclerView.adapter = AccountSelectionAdapter(accountManager.getAllAccounts(), ::onAccountSelected, ::onNewAccount)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onAccountSelected(accountId: Long) {
|
||||
lifecycleScope.launch {
|
||||
accountManager.setActiveAccount(accountId)
|
||||
val intent = Intent(requireContext(), MainActivity::class.java)
|
||||
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
||||
startActivity(intent)
|
||||
|
||||
activity?.finish()
|
||||
}
|
||||
}
|
||||
|
||||
private fun onNewAccount() {
|
||||
//TODO don't create intent here
|
||||
startActivity(Intent(requireContext(), LoginActivity::class.java))
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
_binding = null
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,46 @@
|
|||
package at.connyduck.pixelcat.components.bottomsheet.menu
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import at.connyduck.pixelcat.components.about.AboutActivity
|
||||
import at.connyduck.pixelcat.components.settings.SettingsActivity
|
||||
import at.connyduck.pixelcat.databinding.BottomsheetMenuBinding
|
||||
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
|
||||
|
||||
class MenuBottomSheet : BottomSheetDialogFragment() {
|
||||
|
||||
private var _binding: BottomsheetMenuBinding? = null
|
||||
private val binding
|
||||
get() = _binding!!
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
_binding = BottomsheetMenuBinding.inflate(inflater, container, false)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
|
||||
binding.menuSettings.setOnClickListener {
|
||||
startActivity(SettingsActivity.newIntent(requireActivity()))
|
||||
dismiss()
|
||||
}
|
||||
binding.menuAbout.setOnClickListener {
|
||||
startActivity(AboutActivity.newIntent(it.context))
|
||||
dismiss()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
_binding = null
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,122 @@
|
|||
package at.connyduck.pixelcat.components.compose
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import androidx.activity.viewModels
|
||||
import androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||
import androidx.lifecycle.Observer
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import at.connyduck.pixelcat.R
|
||||
import at.connyduck.pixelcat.components.general.BaseActivity
|
||||
import at.connyduck.pixelcat.dagger.ViewModelFactory
|
||||
import at.connyduck.pixelcat.databinding.ActivityComposeBinding
|
||||
import at.connyduck.pixelcat.util.viewBinding
|
||||
import com.fxn.pix.Pix
|
||||
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
||||
import javax.inject.Inject
|
||||
|
||||
class ComposeActivity: BaseActivity() {
|
||||
|
||||
@Inject
|
||||
lateinit var viewModelFactory: ViewModelFactory
|
||||
|
||||
private val viewModel: ComposeViewModel by viewModels { viewModelFactory }
|
||||
|
||||
private val binding by viewBinding(ActivityComposeBinding::inflate)
|
||||
|
||||
private val adapter = ComposeImageAdapter()
|
||||
|
||||
private lateinit var visibilityBottomSheet: BottomSheetBehavior<*>
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(binding.root)
|
||||
|
||||
|
||||
binding.root.setOnApplyWindowInsetsListener { _, insets ->
|
||||
val top = insets.systemWindowInsetTop
|
||||
|
||||
val toolbarParams = binding.composeAppBar.layoutParams as CoordinatorLayout.LayoutParams
|
||||
toolbarParams.topMargin = top
|
||||
|
||||
insets.consumeSystemWindowInsets()
|
||||
}
|
||||
|
||||
if(viewModel.images.value.isNullOrEmpty()) {
|
||||
viewModel.addImage(intent.getStringExtra(EXTRA_MEDIA_URI)!!)
|
||||
}
|
||||
|
||||
|
||||
setSupportActionBar(binding.composeToolBar)
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
|
||||
binding.composeImages.layoutManager = LinearLayoutManager(this, LinearLayoutManager.HORIZONTAL, false)
|
||||
binding.composeImages.adapter = adapter
|
||||
|
||||
visibilityBottomSheet = BottomSheetBehavior.from(binding.composeVisibilityBottomSheet)
|
||||
|
||||
binding.composeShareButton.setOnClickListener {
|
||||
viewModel.sendStatus()
|
||||
}
|
||||
|
||||
binding.composeVisibilityButton.setOnClickListener {
|
||||
visibilityBottomSheet.state = BottomSheetBehavior.STATE_EXPANDED
|
||||
}
|
||||
binding.composeVisibilityPublic.setOnClickListener {
|
||||
changeVisibility(VISIBILITY.PUBLIC)
|
||||
}
|
||||
binding.composeVisibilityUnlisted.setOnClickListener {
|
||||
changeVisibility(VISIBILITY.UNLISTED)
|
||||
}
|
||||
binding.composeVisibilityFollowers.setOnClickListener {
|
||||
changeVisibility(VISIBILITY.FOLLOWERS_ONLY)
|
||||
}
|
||||
|
||||
viewModel.images.observe(this, Observer {
|
||||
adapter.submitList(it)
|
||||
})
|
||||
|
||||
viewModel.visibility.observe(this, Observer {
|
||||
val visibilityString = when(it) {
|
||||
VISIBILITY.PUBLIC -> R.string.compose_visibility_public
|
||||
VISIBILITY.UNLISTED -> R.string.compose_visibility_unlisted
|
||||
VISIBILITY.FOLLOWERS_ONLY -> R.string.compose_visibility_followers_only
|
||||
}
|
||||
|
||||
binding.composeVisibilityButton.text = getString(R.string.compose_visibility, getString(visibilityString))
|
||||
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
super.onActivityResult(requestCode, resultCode, data)
|
||||
if (resultCode == Activity.RESULT_OK && requestCode == REQUEST_CODE_PICK_MEDIA) {
|
||||
val returnValue =
|
||||
data?.getStringArrayListExtra(Pix.IMAGE_RESULTS)
|
||||
Log.e("Result", returnValue.toString())
|
||||
viewModel.addImage(returnValue?.first()!!)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
private fun changeVisibility(visibility: VISIBILITY) {
|
||||
viewModel.setVisibility(visibility)
|
||||
visibilityBottomSheet.state = BottomSheetBehavior.STATE_COLLAPSED
|
||||
}
|
||||
|
||||
|
||||
companion object {
|
||||
private const val REQUEST_CODE_PICK_MEDIA = 123
|
||||
private const val EXTRA_MEDIA_URI = "MEDIA_URI"
|
||||
|
||||
fun newIntent(context: Context, mediaUri: String): Intent {
|
||||
return Intent(context, ComposeActivity::class.java).apply {
|
||||
putExtra(EXTRA_MEDIA_URI, mediaUri)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,44 @@
|
|||
package at.connyduck.pixelcat.components.compose
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.ListAdapter
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import at.connyduck.pixelcat.R
|
||||
import at.connyduck.pixelcat.databinding.ItemComposeImageBinding
|
||||
import coil.api.load
|
||||
import java.io.File
|
||||
|
||||
class ComposeImageAdapter : ListAdapter<String, ComposeImageViewHolder>(
|
||||
object : DiffUtil.ItemCallback<String>() {
|
||||
override fun areItemsTheSame(old: String, new: String): Boolean {
|
||||
return old == new
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(old: String, new: String): Boolean {
|
||||
return old == new
|
||||
}
|
||||
}
|
||||
) {
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ComposeImageViewHolder {
|
||||
val binding =
|
||||
ItemComposeImageBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||
return ComposeImageViewHolder(binding)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: ComposeImageViewHolder, position: Int) {
|
||||
|
||||
getItem(position)?.let { uri ->
|
||||
|
||||
holder.binding.root.load(File(uri)) {
|
||||
placeholder(R.drawable.ic_cat)
|
||||
error(R.drawable.ic_message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ComposeImageViewHolder(val binding: ItemComposeImageBinding) :
|
||||
RecyclerView.ViewHolder(binding.root)
|
|
@ -0,0 +1,60 @@
|
|||
package at.connyduck.pixelcat.components.compose
|
||||
|
||||
import android.content.Context
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import at.connyduck.pixelcat.db.AccountManager
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
class ComposeViewModel @Inject constructor(
|
||||
val context: Context,
|
||||
val accountManager: AccountManager
|
||||
): ViewModel() {
|
||||
|
||||
|
||||
val images = MutableLiveData<List<String>>()
|
||||
|
||||
val visibility = MutableLiveData(VISIBILITY.PUBLIC)
|
||||
|
||||
|
||||
|
||||
fun addImage(imageUri: String) {
|
||||
|
||||
images.value = images.value.orEmpty() + imageUri
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
fun setVisibility(visibility: VISIBILITY) {
|
||||
this.visibility.value = visibility
|
||||
}
|
||||
|
||||
fun sendStatus() {
|
||||
|
||||
viewModelScope.launch {
|
||||
val statusToSend = StatusToSend(
|
||||
accountId = accountManager.activeAccount()!!.id,
|
||||
text = "test",
|
||||
visibility = visibility.value!!.serverName,
|
||||
sensitive = false,
|
||||
mediaUris = images.value!!
|
||||
)
|
||||
|
||||
val intent = SendStatusService.sendStatusIntent(context, statusToSend)
|
||||
ContextCompat.startForegroundService(context, intent)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
enum class VISIBILITY(val serverName: String) {
|
||||
PUBLIC("public"),
|
||||
UNLISTED("unlisted"),
|
||||
FOLLOWERS_ONLY("private")
|
||||
}
|
|
@ -0,0 +1,272 @@
|
|||
package at.connyduck.pixelcat.components.compose
|
||||
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.os.Parcelable
|
||||
import android.webkit.MimeTypeMap
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.ServiceCompat
|
||||
import at.connyduck.pixelcat.R
|
||||
import at.connyduck.pixelcat.components.util.getColorForAttr
|
||||
import at.connyduck.pixelcat.db.AccountManager
|
||||
import at.connyduck.pixelcat.model.NewStatus
|
||||
import at.connyduck.pixelcat.network.FediverseApi
|
||||
import at.connyduck.pixelcat.network.calladapter.NetworkResponseError
|
||||
import dagger.android.DaggerService
|
||||
import kotlinx.android.parcel.Parcelize
|
||||
import kotlinx.coroutines.*
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.RequestBody.Companion.asRequestBody
|
||||
import java.io.File
|
||||
import java.util.*
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.inject.Inject
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
|
||||
|
||||
class SendStatusService : DaggerService(), CoroutineScope {
|
||||
|
||||
@Inject
|
||||
lateinit var api: FediverseApi
|
||||
@Inject
|
||||
lateinit var accountManager: AccountManager
|
||||
|
||||
private val statusesToSend = ConcurrentHashMap<Int, StatusToSend>()
|
||||
private val sendJobs = ConcurrentHashMap<Int, Job>()
|
||||
|
||||
|
||||
private val timer = Timer()
|
||||
|
||||
private val notificationManager by lazy { getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager }
|
||||
|
||||
|
||||
override fun onBind(intent: Intent): IBinder? {
|
||||
return null
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
|
||||
|
||||
if (intent.hasExtra(KEY_STATUS)) {
|
||||
val tootToSend = intent.getParcelableExtra<StatusToSend>(KEY_STATUS)
|
||||
?: throw IllegalStateException("SendTootService started without $KEY_STATUS extra")
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val channel = NotificationChannel(CHANNEL_ID, getString(R.string.send_status_notification_channel_name), NotificationManager.IMPORTANCE_LOW)
|
||||
notificationManager.createNotificationChannel(channel)
|
||||
|
||||
}
|
||||
|
||||
val builder = NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_cat)
|
||||
.setContentTitle(getString(R.string.send_status_notification_title))
|
||||
.setContentText(tootToSend.text)
|
||||
.setProgress(1, 0, true)
|
||||
.setOngoing(true)
|
||||
.setColor(getColorForAttr(android.R.attr.colorPrimary))
|
||||
.addAction(0, getString(android.R.string.cancel), cancelSendingIntent(sendingNotificationId))
|
||||
|
||||
if (statusesToSend.size == 0 || Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_DETACH)
|
||||
startForeground(sendingNotificationId, builder.build())
|
||||
} else {
|
||||
notificationManager.notify(sendingNotificationId, builder.build())
|
||||
}
|
||||
|
||||
statusesToSend[sendingNotificationId] = tootToSend
|
||||
sendStatus(sendingNotificationId--)
|
||||
|
||||
} else {
|
||||
|
||||
if (intent.hasExtra(KEY_CANCEL)) {
|
||||
cancelSending(intent.getIntExtra(KEY_CANCEL, 0))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return START_NOT_STICKY
|
||||
|
||||
}
|
||||
|
||||
private fun sendStatus(id: Int) {
|
||||
|
||||
// when tootToSend == null, sending has been canceled
|
||||
val statusToSend = statusesToSend[id] ?: return
|
||||
|
||||
// when account == null, user has logged out, cancel sending
|
||||
val account = accountManager.getAccountById(statusToSend.accountId)
|
||||
|
||||
if (account == null) {
|
||||
statusesToSend.remove(id)
|
||||
notificationManager.cancel(id)
|
||||
stopSelfWhenDone()
|
||||
return
|
||||
}
|
||||
|
||||
statusToSend.retries++
|
||||
|
||||
launch {
|
||||
|
||||
val mediaIds = statusToSend.mediaUris.map {
|
||||
|
||||
|
||||
var type: String? = null
|
||||
val extension = MimeTypeMap.getFileExtensionFromUrl(it)
|
||||
if (extension != null) {
|
||||
type = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension)
|
||||
}
|
||||
|
||||
val file = File(it)
|
||||
val filePart = file.asRequestBody(type!!.toMediaType())
|
||||
|
||||
val body = MultipartBody.Part.create(filePart)
|
||||
|
||||
api.uploadMedia(body).fold({ attachment ->
|
||||
attachment.id
|
||||
}, {
|
||||
""
|
||||
})
|
||||
}
|
||||
|
||||
val newStatus = NewStatus(
|
||||
status = statusToSend.text,
|
||||
inReplyToId = null,
|
||||
visibility = statusToSend.visibility,
|
||||
sensitive = statusToSend.sensitive,
|
||||
mediaIds = mediaIds
|
||||
)
|
||||
|
||||
api.createStatus(
|
||||
"Bearer " + account.auth.accessToken,
|
||||
account.domain,
|
||||
statusToSend.idempotencyKey,
|
||||
newStatus
|
||||
).fold<Any?>({
|
||||
statusesToSend.remove(id)
|
||||
}, {
|
||||
when(it) {
|
||||
is NetworkResponseError.ApiError -> {
|
||||
// the server refused to accept the status, save toot & show error message
|
||||
// TODO saveToDrafts
|
||||
|
||||
val builder = NotificationCompat.Builder(this@SendStatusService, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_cat)
|
||||
.setContentTitle(getString(R.string.send_status_notification_error_title))
|
||||
//.setContentText(getString(R.string.send_toot_notification_saved_content))
|
||||
.setColor(getColorForAttr(android.R.attr.colorPrimary))
|
||||
|
||||
notificationManager.cancel(id)
|
||||
notificationManager.notify(errorNotificationId--, builder.build())
|
||||
}
|
||||
else -> {
|
||||
var backoff = TimeUnit.SECONDS.toMillis(statusToSend.retries.toLong())
|
||||
if (backoff > MAX_RETRY_INTERVAL) {
|
||||
backoff = MAX_RETRY_INTERVAL
|
||||
}
|
||||
|
||||
timer.schedule(object : TimerTask() {
|
||||
override fun run() {
|
||||
sendStatus(id)
|
||||
}
|
||||
}, backoff)
|
||||
}
|
||||
}
|
||||
})
|
||||
}.apply {
|
||||
sendJobs[id] = this
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
private fun stopSelfWhenDone() {
|
||||
|
||||
if (statusesToSend.isEmpty()) {
|
||||
coroutineContext.cancel()
|
||||
ServiceCompat.stopForeground(this@SendStatusService, ServiceCompat.STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
}
|
||||
}
|
||||
|
||||
private fun cancelSending(id: Int) {
|
||||
val statusToCancel = statusesToSend.remove(id)
|
||||
if (statusToCancel != null) {
|
||||
val sendCall = sendJobs.remove(id)
|
||||
sendCall?.cancel()
|
||||
|
||||
// saveTootToDrafts(tootToCancel)
|
||||
|
||||
val builder = NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_cat)
|
||||
.setContentTitle(getString(R.string.send_status_notification_cancel_title))
|
||||
// .setContentText(getString(R.string.send_toot_notification_saved_content))
|
||||
.setColor(getColorForAttr(android.R.attr.colorPrimary))
|
||||
|
||||
notificationManager.notify(id, builder.build())
|
||||
|
||||
timer.schedule(object : TimerTask() {
|
||||
override fun run() {
|
||||
notificationManager.cancel(id)
|
||||
stopSelfWhenDone()
|
||||
}
|
||||
}, 5000)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
private fun cancelSendingIntent(tootId: Int): PendingIntent {
|
||||
|
||||
val intent = Intent(this, SendStatusService::class.java)
|
||||
|
||||
intent.putExtra(KEY_CANCEL, tootId)
|
||||
|
||||
return PendingIntent.getService(this, tootId, intent, PendingIntent.FLAG_UPDATE_CURRENT)
|
||||
}
|
||||
|
||||
|
||||
companion object {
|
||||
|
||||
private const val KEY_STATUS = "status"
|
||||
private const val KEY_CANCEL = "cancel_id"
|
||||
private const val CHANNEL_ID = "send_status"
|
||||
|
||||
private val MAX_RETRY_INTERVAL = TimeUnit.MINUTES.toMillis(1)
|
||||
|
||||
private var sendingNotificationId = -1 // use negative ids to not clash with other notis
|
||||
private var errorNotificationId = Int.MIN_VALUE // use even more negative ids to not clash with other notis
|
||||
|
||||
@JvmStatic
|
||||
fun sendStatusIntent(context: Context,
|
||||
statusToSend: StatusToSend
|
||||
): Intent {
|
||||
val intent = Intent(context, SendStatusService::class.java)
|
||||
intent.putExtra(KEY_STATUS, statusToSend)
|
||||
|
||||
return intent
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
override val coroutineContext: CoroutineContext
|
||||
get() = SupervisorJob() + Dispatchers.IO
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
data class StatusToSend(
|
||||
val accountId: Long,
|
||||
val idempotencyKey: String = UUID.randomUUID().toString(),
|
||||
val text: String,
|
||||
val visibility: String,
|
||||
val sensitive: Boolean,
|
||||
val mediaUris: List<String>,
|
||||
val mediaDescriptions: List<String> = emptyList(),
|
||||
val inReplyToId: String? = null,
|
||||
val savedTootUid: Int = 0,
|
||||
var retries: Int = 0
|
||||
) : Parcelable
|
|
@ -0,0 +1,24 @@
|
|||
package at.connyduck.pixelcat.components.general
|
||||
|
||||
import android.os.Bundle
|
||||
import at.connyduck.pixelcat.R
|
||||
import at.connyduck.pixelcat.components.settings.AppSettings
|
||||
import dagger.android.support.DaggerAppCompatActivity
|
||||
import javax.inject.Inject
|
||||
|
||||
abstract class BaseActivity: DaggerAppCompatActivity() {
|
||||
|
||||
@Inject
|
||||
lateinit var appSettings: AppSettings
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
theme.applyStyle(appSettings.getAppColorStyle(), true)
|
||||
if(!appSettings.useSystemFont()) {
|
||||
theme.applyStyle(R.style.NunitoFont, true)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,118 @@
|
|||
package at.connyduck.pixelcat.components.login
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import android.view.ViewGroup
|
||||
import androidx.activity.viewModels
|
||||
import androidx.lifecycle.Observer
|
||||
import at.connyduck.pixelcat.components.main.MainActivity
|
||||
import at.connyduck.pixelcat.R
|
||||
import at.connyduck.pixelcat.components.about.AboutActivity
|
||||
import at.connyduck.pixelcat.components.general.BaseActivity
|
||||
import at.connyduck.pixelcat.components.settings.SettingsActivity
|
||||
import at.connyduck.pixelcat.dagger.ViewModelFactory
|
||||
import at.connyduck.pixelcat.databinding.ActivityLoginBinding
|
||||
import at.connyduck.pixelcat.util.viewBinding
|
||||
import javax.inject.Inject
|
||||
|
||||
|
||||
class LoginActivity : BaseActivity(), Observer<LoginModel> {
|
||||
|
||||
@Inject
|
||||
lateinit var viewModelFactory: ViewModelFactory
|
||||
|
||||
private val loginViewModel: LoginViewModel by viewModels { viewModelFactory }
|
||||
|
||||
private val binding by viewBinding(ActivityLoginBinding::inflate)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
setContentView(binding.root)
|
||||
|
||||
binding.loginContainer.setOnApplyWindowInsetsListener { _, insets ->
|
||||
val top = insets.systemWindowInsetTop
|
||||
|
||||
val toolbarParams = binding.loginToolbar.layoutParams as ViewGroup.MarginLayoutParams
|
||||
toolbarParams.topMargin = top
|
||||
|
||||
insets.consumeSystemWindowInsets()
|
||||
}
|
||||
|
||||
setSupportActionBar(binding.loginToolbar)
|
||||
|
||||
supportActionBar?.run {
|
||||
setDisplayShowTitleEnabled(false)
|
||||
}
|
||||
|
||||
loginViewModel.loginState.observe(this, this)
|
||||
|
||||
binding.loginButton.setOnClickListener {
|
||||
loginViewModel.startLogin(binding.loginInput.text.toString())
|
||||
}
|
||||
}
|
||||
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
|
||||
val authCode = data?.getStringExtra(LoginWebViewActivity.RESULT_AUTHORIZATION_CODE)
|
||||
if(requestCode == REQUEST_CODE && resultCode == Activity.RESULT_OK && !authCode.isNullOrEmpty()) {
|
||||
loginViewModel.authCode(authCode)
|
||||
return
|
||||
}
|
||||
|
||||
super.onActivityResult(requestCode, resultCode, data)
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||
menuInflater.inflate(R.menu.login, menu)
|
||||
return super.onCreateOptionsMenu(menu)
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
when(item.itemId) {
|
||||
R.id.navigation_settings -> {
|
||||
startActivity(SettingsActivity.newIntent(this))
|
||||
return true
|
||||
}
|
||||
R.id.navigation_info -> {
|
||||
startActivity(AboutActivity.newIntent(this))
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return super.onOptionsItemSelected(item)
|
||||
}
|
||||
|
||||
override fun onChanged(loginModel: LoginModel?) {
|
||||
binding.loginInput.setText(loginModel?.input)
|
||||
|
||||
if(loginModel == null) {
|
||||
return
|
||||
}
|
||||
|
||||
when(loginModel.state) {
|
||||
LoginState.NO_ERROR -> binding.loginInputLayout.error = null
|
||||
LoginState.AUTH_ERROR -> binding.loginInputLayout.error = "auth error"
|
||||
LoginState.INVALID_DOMAIN -> binding.loginInputLayout.error = "invalid domain"
|
||||
LoginState.NETWORK_ERROR -> binding.loginInputLayout.error = "network error"
|
||||
LoginState.LOADING -> {
|
||||
|
||||
}
|
||||
LoginState.SUCCESS -> {
|
||||
startActivityForResult(LoginWebViewActivity.newIntent(loginModel.domain!!, loginModel.clientId!!, loginModel.clientSecret!!, this), REQUEST_CODE)
|
||||
}
|
||||
LoginState.SUCCESS_FINAL -> {
|
||||
startActivity(Intent(this, MainActivity::class.java)) // TODO dont create intent here
|
||||
finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val REQUEST_CODE = 14
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
package at.connyduck.pixelcat.components.login
|
||||
|
||||
import android.os.Parcelable
|
||||
import kotlinx.android.parcel.Parcelize
|
||||
|
||||
@Parcelize
|
||||
data class LoginModel(
|
||||
val input: CharSequence = "",
|
||||
val state: LoginState = LoginState.NO_ERROR,
|
||||
val domain: String? = null,
|
||||
val clientId: String? = null,
|
||||
val clientSecret: String? = null
|
||||
): Parcelable
|
||||
|
||||
enum class LoginState { //TODO rename this stuff so it makes sense
|
||||
LOADING, NO_ERROR, NETWORK_ERROR, INVALID_DOMAIN, AUTH_ERROR, SUCCESS, SUCCESS_FINAL
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
package at.connyduck.pixelcat.components.login
|
||||
|
||||
import androidx.annotation.MainThread
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import at.connyduck.pixelcat.config.Config
|
||||
import at.connyduck.pixelcat.db.AccountManager
|
||||
import at.connyduck.pixelcat.db.entitity.AccountAuthData
|
||||
import at.connyduck.pixelcat.network.FediverseApi
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.HttpUrl
|
||||
import java.util.*
|
||||
import javax.inject.Inject
|
||||
|
||||
class LoginViewModel @Inject constructor(
|
||||
private val fediverseApi: FediverseApi,
|
||||
private val accountManager: AccountManager
|
||||
): ViewModel() {
|
||||
|
||||
|
||||
val loginState = MutableLiveData<LoginModel>().apply {
|
||||
value = LoginModel(state = LoginState.NO_ERROR)
|
||||
}
|
||||
|
||||
@MainThread
|
||||
fun startLogin(input: String) {
|
||||
|
||||
val domainInput = canonicalizeDomain(input)
|
||||
|
||||
try {
|
||||
HttpUrl.Builder().host(domainInput).scheme("https").build()
|
||||
} catch (e: IllegalArgumentException) {
|
||||
loginState.value = LoginModel(input, LoginState.INVALID_DOMAIN)
|
||||
return
|
||||
}
|
||||
|
||||
val exceptionMatch = Config.domainExceptions.any {exception ->
|
||||
domainInput.equals(exception, true) || domainInput.endsWith(".$exception", true)
|
||||
}
|
||||
|
||||
if(exceptionMatch) {
|
||||
loginState.value = LoginModel(input, LoginState.AUTH_ERROR)
|
||||
return
|
||||
}
|
||||
|
||||
loginState.value = LoginModel(input, LoginState.LOADING)
|
||||
|
||||
|
||||
viewModelScope.launch {
|
||||
fediverseApi.authenticateAppAsync(
|
||||
domain = domainInput,
|
||||
clientName = "Pixelcat",
|
||||
clientWebsite = Config.website,
|
||||
redirectUris = Config.oAuthRedirect,
|
||||
scopes = Config.oAuthScopes
|
||||
).fold({ appData ->
|
||||
loginState.postValue(LoginModel(input, LoginState.SUCCESS, domainInput, appData.clientId, appData.clientSecret))
|
||||
}, {
|
||||
loginState.postValue(LoginModel(input, LoginState.AUTH_ERROR))
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@MainThread
|
||||
fun authCode(authCode: String) {
|
||||
viewModelScope.launch {
|
||||
val loginModel = loginState.value!!
|
||||
|
||||
fediverseApi.fetchOAuthToken(
|
||||
domain = loginModel.domain!!,
|
||||
clientId = loginModel.clientId!!,
|
||||
clientSecret = loginModel.clientSecret!!,
|
||||
redirectUri = Config.oAuthRedirect,
|
||||
code = authCode
|
||||
).fold({ tokenResponse ->
|
||||
val authData = AccountAuthData(
|
||||
accessToken = tokenResponse.accessToken,
|
||||
refreshToken = tokenResponse.refreshToken,
|
||||
tokenExpiresAt = tokenResponse.createdAt ?: 0 + (tokenResponse.expiresIn
|
||||
?: 0),
|
||||
clientId = loginModel.clientId,
|
||||
clientSecret = loginModel.clientSecret
|
||||
)
|
||||
accountManager.addAccount(loginModel.domain, authData)
|
||||
loginState.postValue(loginState.value?.copy(state = LoginState.SUCCESS_FINAL))
|
||||
}, {
|
||||
|
||||
})
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun canonicalizeDomain(domain: String): String {
|
||||
// Strip any schemes out.
|
||||
var s = domain.replaceFirst("http://", "")
|
||||
.replaceFirst("https://", "")
|
||||
// If a username was included (e.g. username@example.com), just take what's after the '@'.
|
||||
val at = s.lastIndexOf('@')
|
||||
if (at != -1) {
|
||||
s = s.substring(at + 1)
|
||||
}
|
||||
return s.trim().toLowerCase(Locale.ROOT)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,83 @@
|
|||
package at.connyduck.pixelcat.components.login
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import android.os.Bundle
|
||||
import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebView
|
||||
import at.connyduck.pixelcat.config.Config
|
||||
import android.webkit.WebViewClient
|
||||
import at.connyduck.pixelcat.databinding.ActivityLoginWebViewBinding
|
||||
|
||||
|
||||
class LoginWebViewActivity : AppCompatActivity() {
|
||||
|
||||
private lateinit var binding: ActivityLoginWebViewBinding
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
binding = ActivityLoginWebViewBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
|
||||
val domain = intent.getStringExtra(EXTRA_DOMAIN)
|
||||
val clientId = intent.getStringExtra(EXTRA_CLIENT_ID)
|
||||
val clientSecret = intent.getStringExtra(EXTRA_CLIENT_SECRET)
|
||||
|
||||
val endpoint = "/oauth/authorize"
|
||||
val parameters = mapOf(
|
||||
"client_id" to clientId,
|
||||
"redirect_uri" to Config.oAuthRedirect,
|
||||
"response_type" to "code",
|
||||
"scope" to Config.oAuthScopes
|
||||
)
|
||||
|
||||
val url = "https://" + domain + endpoint + "?" + toQueryString(parameters)
|
||||
|
||||
binding.loginWebView.webViewClient = object: WebViewClient() {
|
||||
|
||||
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
|
||||
if(request.url.scheme == Config.oAuthScheme && request.url.host == Config.oAuthHost) {
|
||||
loginSuccess(request.url.getQueryParameter("code").orEmpty())
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
binding.loginWebView.loadUrl(url)
|
||||
|
||||
}
|
||||
|
||||
private fun loginSuccess(authCode: String) {
|
||||
val successIntent = Intent().apply {
|
||||
putExtra(RESULT_AUTHORIZATION_CODE, authCode)
|
||||
}
|
||||
setResult(Activity.RESULT_OK, successIntent)
|
||||
finish()
|
||||
}
|
||||
|
||||
|
||||
private fun toQueryString(parameters: Map<String, String>): String {
|
||||
return parameters.map { "${it.key}=${Uri.encode(it.value)}" }
|
||||
.joinToString("&")
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val RESULT_AUTHORIZATION_CODE = "authCode"
|
||||
|
||||
private const val EXTRA_DOMAIN = "domain"
|
||||
private const val EXTRA_CLIENT_ID = "clientId"
|
||||
private const val EXTRA_CLIENT_SECRET = "clientSecret"
|
||||
|
||||
fun newIntent(domain: String, clientId: String, clientSecret: String, context: Context): Intent {
|
||||
return Intent(context, LoginWebViewActivity::class.java).apply {
|
||||
putExtra(EXTRA_DOMAIN, domain)
|
||||
putExtra(EXTRA_CLIENT_ID, clientId)
|
||||
putExtra(EXTRA_CLIENT_SECRET, clientSecret)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,105 @@
|
|||
package at.connyduck.pixelcat.components.main
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.widget.LinearLayout
|
||||
import androidx.activity.viewModels
|
||||
import at.connyduck.pixelcat.R
|
||||
import at.connyduck.pixelcat.components.compose.ComposeActivity
|
||||
import at.connyduck.pixelcat.components.general.BaseActivity
|
||||
import at.connyduck.pixelcat.dagger.ViewModelFactory
|
||||
import at.connyduck.pixelcat.databinding.ActivityMainBinding
|
||||
import at.connyduck.pixelcat.db.AccountManager
|
||||
import com.fxn.pix.Options
|
||||
import com.fxn.pix.Pix
|
||||
import com.fxn.utility.ImageQuality
|
||||
import com.google.android.material.bottomnavigation.BottomNavigationView
|
||||
import javax.inject.Inject
|
||||
|
||||
class MainActivity : BaseActivity() {
|
||||
|
||||
@Inject
|
||||
lateinit var viewModelFactory: ViewModelFactory
|
||||
|
||||
@Inject
|
||||
lateinit var accountManager: AccountManager
|
||||
|
||||
private val mainViewModel: MainViewModel by viewModels { viewModelFactory }
|
||||
|
||||
private lateinit var binding: ActivityMainBinding
|
||||
|
||||
private lateinit var mainFragmentAdapter: MainFragmentAdapter
|
||||
|
||||
private val onNavigationItemSelectedListener = BottomNavigationView.OnNavigationItemSelectedListener { item ->
|
||||
return@OnNavigationItemSelectedListener when (item.itemId) {
|
||||
R.id.navigation_home -> {
|
||||
binding.mainViewPager.setCurrentItem(0, false)
|
||||
true
|
||||
}
|
||||
R.id.navigation_search -> {
|
||||
binding.mainViewPager.setCurrentItem(1, false)
|
||||
true
|
||||
}
|
||||
R.id.navigation_compose -> {
|
||||
val options = Options.init()
|
||||
.setRequestCode(100)
|
||||
.setImageQuality(ImageQuality.HIGH)
|
||||
.setScreenOrientation(Options.SCREEN_ORIENTATION_PORTRAIT)
|
||||
|
||||
Pix.start(this, options)
|
||||
false
|
||||
}
|
||||
R.id.navigation_notifications -> {
|
||||
binding.mainViewPager.setCurrentItem(2, false)
|
||||
true
|
||||
}
|
||||
R.id.navigation_profile -> {
|
||||
binding.mainViewPager.setCurrentItem(3, false)
|
||||
true
|
||||
}
|
||||
else -> false
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
binding = ActivityMainBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
|
||||
binding.container.setOnApplyWindowInsetsListener { _, insets ->
|
||||
val top = insets.systemWindowInsetTop
|
||||
|
||||
val toolbarParams = binding.mainViewPager.layoutParams as LinearLayout.LayoutParams
|
||||
toolbarParams.topMargin = top
|
||||
|
||||
insets.consumeSystemWindowInsets()
|
||||
}
|
||||
|
||||
mainFragmentAdapter = MainFragmentAdapter(this)
|
||||
binding.mainViewPager.adapter = mainFragmentAdapter
|
||||
binding.mainViewPager.isUserInputEnabled = false
|
||||
|
||||
binding.navigation.setOnNavigationItemSelectedListener(onNavigationItemSelectedListener)
|
||||
|
||||
mainViewModel.whatever()
|
||||
|
||||
}
|
||||
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
super.onActivityResult(requestCode, resultCode, data)
|
||||
if (resultCode == Activity.RESULT_OK && requestCode == 100) {
|
||||
val returnValue =
|
||||
data?.getStringArrayListExtra(Pix.IMAGE_RESULTS)
|
||||
Log.e("Result", returnValue.toString())
|
||||
|
||||
startActivity(ComposeActivity.newIntent(this, returnValue?.firstOrNull()!!))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
package at.connyduck.pixelcat.components.main
|
||||
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.viewpager2.adapter.FragmentStateAdapter
|
||||
import at.connyduck.pixelcat.components.notifications.NotificationsFragment
|
||||
import at.connyduck.pixelcat.components.profile.ProfileFragment
|
||||
import at.connyduck.pixelcat.components.search.SearchFragment
|
||||
import at.connyduck.pixelcat.components.timeline.TimelineFragment
|
||||
|
||||
class MainFragmentAdapter(fragmentActivity: FragmentActivity): FragmentStateAdapter(fragmentActivity) {
|
||||
|
||||
override fun createFragment(position: Int): Fragment {
|
||||
return when(position) {
|
||||
0 -> TimelineFragment.newInstance()
|
||||
1 -> SearchFragment.newInstance()
|
||||
2 -> NotificationsFragment.newInstance()
|
||||
3 -> ProfileFragment.newInstance()
|
||||
else -> {
|
||||
throw IllegalStateException()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemCount() = 4
|
||||
|
||||
}
|
|
@ -0,0 +1,30 @@
|
|||
package at.connyduck.pixelcat.components.main
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import at.connyduck.pixelcat.db.AccountManager
|
||||
import at.connyduck.pixelcat.network.FediverseApi
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
class MainViewModel @Inject constructor(
|
||||
private val fediverseApi: FediverseApi,
|
||||
private val accountManager: AccountManager
|
||||
): ViewModel() {
|
||||
|
||||
fun whatever() {
|
||||
|
||||
}
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
|
||||
fediverseApi.accountVerifyCredentials().fold({ account ->
|
||||
accountManager.updateActiveAccount(account)
|
||||
}, {
|
||||
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,20 @@
|
|||
package at.connyduck.pixelcat.components.notifications
|
||||
|
||||
import androidx.fragment.app.viewModels
|
||||
import at.connyduck.pixelcat.R
|
||||
import at.connyduck.pixelcat.dagger.ViewModelFactory
|
||||
import dagger.android.support.DaggerFragment
|
||||
import javax.inject.Inject
|
||||
|
||||
class NotificationsFragment: DaggerFragment(R.layout.fragment_notifications) {
|
||||
|
||||
@Inject
|
||||
lateinit var viewModelFactory: ViewModelFactory
|
||||
|
||||
private val notificationsViewModel: NotificationsViewModel by viewModels { viewModelFactory }
|
||||
|
||||
companion object {
|
||||
fun newInstance() = NotificationsFragment()
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
package at.connyduck.pixelcat.components.notifications
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import javax.inject.Inject
|
||||
|
||||
class NotificationsViewModel @Inject constructor(
|
||||
|
||||
): ViewModel() {
|
||||
|
||||
|
||||
|
||||
}
|
|
@ -0,0 +1,34 @@
|
|||
package at.connyduck.pixelcat.components.profile
|
||||
|
||||
import android.graphics.Rect
|
||||
import android.view.View
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.recyclerview.widget.RecyclerView.ItemDecoration
|
||||
|
||||
class GridSpacingItemDecoration(
|
||||
private val spanCount: Int,
|
||||
private val spacing: Int,
|
||||
private val topOffset: Int
|
||||
) : ItemDecoration() {
|
||||
|
||||
override fun getItemOffsets(
|
||||
outRect: Rect,
|
||||
view: View,
|
||||
parent: RecyclerView,
|
||||
state: RecyclerView.State
|
||||
) {
|
||||
val position = parent.getChildAdapterPosition(view) // item position
|
||||
if(position < topOffset) return
|
||||
|
||||
val column = (position - topOffset) % spanCount // item column
|
||||
|
||||
outRect.left = column * spacing / spanCount // column * ((1f / spanCount) * spacing)
|
||||
outRect.right =
|
||||
spacing - (column + 1) * spacing / spanCount // spacing - (column + 1) * ((1f / spanCount) * spacing)
|
||||
if (position - topOffset >= spanCount) {
|
||||
outRect.top = spacing // item top
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,45 @@
|
|||
package at.connyduck.pixelcat.components.profile
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.fragment.app.commit
|
||||
import at.connyduck.pixelcat.R
|
||||
import at.connyduck.pixelcat.components.general.BaseActivity
|
||||
import at.connyduck.pixelcat.databinding.ActivityProfileBinding
|
||||
|
||||
class ProfileActivity: BaseActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
val binding = ActivityProfileBinding.inflate(layoutInflater)
|
||||
|
||||
setContentView(binding.root)
|
||||
|
||||
binding.root.setOnApplyWindowInsetsListener { _, insets ->
|
||||
val top = insets.systemWindowInsetTop
|
||||
|
||||
binding.root.setPadding(0, top, 0, 0)
|
||||
|
||||
insets.consumeSystemWindowInsets()
|
||||
}
|
||||
|
||||
if (supportFragmentManager.findFragmentById(R.id.layoutContainer) == null) {
|
||||
supportFragmentManager.commit {
|
||||
add(R.id.layoutContainer, ProfileFragment.newInstance(intent.getStringExtra(EXTRA_ACCOUNT_ID)))
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val EXTRA_ACCOUNT_ID = "ACCOUNT_ID"
|
||||
|
||||
fun newIntent(context: Context, accountId: String): Intent {
|
||||
return Intent(context, ProfileActivity::class.java).apply {
|
||||
putExtra(EXTRA_ACCOUNT_ID, accountId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,73 @@
|
|||
package at.connyduck.pixelcat.components.profile
|
||||
|
||||
import androidx.paging.DataSource
|
||||
import androidx.paging.ItemKeyedDataSource
|
||||
import at.connyduck.pixelcat.db.AccountManager
|
||||
import at.connyduck.pixelcat.model.Status
|
||||
import at.connyduck.pixelcat.network.FediverseApi
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class ProfileDataSourceFactory(
|
||||
private val api: FediverseApi,
|
||||
private val accountId: String?,
|
||||
private val accountManager: AccountManager,
|
||||
private val scope: CoroutineScope
|
||||
) : DataSource.Factory<String, Status>() {
|
||||
|
||||
override fun create(): DataSource<String, Status> {
|
||||
val source = ProfileImageDataSource(api, accountId, accountManager, scope)
|
||||
return source
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class ProfileImageDataSource(
|
||||
private val api: FediverseApi,
|
||||
private val accountId: String?,
|
||||
private val accountManager: AccountManager,
|
||||
private val scope: CoroutineScope
|
||||
): ItemKeyedDataSource<String, Status>() {
|
||||
override fun loadInitial(
|
||||
params: LoadInitialParams<String>,
|
||||
callback: LoadInitialCallback<Status>
|
||||
) {
|
||||
scope.launch(context = Dispatchers.IO) {
|
||||
val id = accountId ?: accountManager.activeAccount()?.accountId!!
|
||||
api.accountStatuses(
|
||||
id,
|
||||
limit = params.requestedLoadSize,
|
||||
onlyMedia = true,
|
||||
excludeReblogs = true
|
||||
).fold({
|
||||
callback.onResult(it)
|
||||
}, {
|
||||
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
override fun loadAfter(params: LoadParams<String>, callback: LoadCallback<Status>) {
|
||||
scope.launch(context = Dispatchers.IO) {
|
||||
val id = accountId ?: accountManager.activeAccount()?.accountId!!
|
||||
api.accountStatuses(
|
||||
id,
|
||||
maxId = params.key,
|
||||
limit = params.requestedLoadSize,
|
||||
onlyMedia = true,
|
||||
excludeReblogs = true
|
||||
).fold({
|
||||
callback.onResult(it)
|
||||
}, {
|
||||
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
override fun loadBefore(params: LoadParams<String>, callback: LoadCallback<Status>) {
|
||||
// we always load from top
|
||||
}
|
||||
|
||||
override fun getKey(item: Status) = item.id
|
||||
}
|
|
@ -0,0 +1,142 @@
|
|||
package at.connyduck.pixelcat.components.profile
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.*
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.Observer
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.MergeAdapter
|
||||
import at.connyduck.pixelcat.R
|
||||
import at.connyduck.pixelcat.components.bottomsheet.accountselection.AccountSelectionBottomSheet
|
||||
import at.connyduck.pixelcat.components.bottomsheet.menu.MenuBottomSheet
|
||||
import at.connyduck.pixelcat.components.main.MainActivity
|
||||
import at.connyduck.pixelcat.components.util.Success
|
||||
import at.connyduck.pixelcat.components.util.extension.getDisplayWidthInPx
|
||||
import at.connyduck.pixelcat.dagger.ViewModelFactory
|
||||
import at.connyduck.pixelcat.databinding.FragmentProfileBinding
|
||||
import at.connyduck.pixelcat.db.AccountManager
|
||||
import at.connyduck.pixelcat.model.Account
|
||||
import at.connyduck.pixelcat.model.Relationship
|
||||
import at.connyduck.pixelcat.util.arg
|
||||
import at.connyduck.pixelcat.util.viewBinding
|
||||
import at.connyduck.pixelcat.util.withArgs
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import dagger.android.support.DaggerFragment
|
||||
import javax.inject.Inject
|
||||
|
||||
class ProfileFragment : DaggerFragment(R.layout.fragment_profile) {
|
||||
|
||||
@Inject
|
||||
lateinit var accountManager: AccountManager
|
||||
|
||||
@Inject
|
||||
lateinit var viewModelFactory: ViewModelFactory
|
||||
|
||||
private val viewModel: ProfileViewModel by viewModels { viewModelFactory }
|
||||
|
||||
private val binding by viewBinding(FragmentProfileBinding::bind)
|
||||
|
||||
private var loadedAccount: Account? = null
|
||||
|
||||
private val headerAdapter = ProfileHeaderAdapter()
|
||||
private lateinit var imageAdapter: ProfileImageAdapter
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
|
||||
if(activity is MainActivity) {
|
||||
binding.toolbar.inflateMenu(R.menu.secondary_navigation)
|
||||
binding.toolbar.setOnMenuItemClickListener {
|
||||
when (it.itemId) {
|
||||
|
||||
R.id.navigation_accounts -> {
|
||||
val bottomSheetDialog =
|
||||
AccountSelectionBottomSheet(accountManager)
|
||||
bottomSheetDialog.show(childFragmentManager, "accountsBottomSheet")
|
||||
}
|
||||
R.id.navigation_menu -> {
|
||||
val bottomSheetDialog =
|
||||
MenuBottomSheet()
|
||||
bottomSheetDialog.show(childFragmentManager, "menuBottomSheet")
|
||||
}
|
||||
|
||||
}
|
||||
true
|
||||
}
|
||||
} else {
|
||||
binding.toolbar.setNavigationIcon(R.drawable.ic_arrow_back)
|
||||
binding.toolbar.setNavigationOnClickListener {
|
||||
activity?.onBackPressed()
|
||||
}
|
||||
}
|
||||
|
||||
val displayWidth = view.context.getDisplayWidthInPx()
|
||||
val imageSpacing = resources.getDimensionPixelOffset(R.dimen.profile_images_spacing)
|
||||
val imageSize = (displayWidth - (IMAGE_COLUMN_COUNT - 1) * imageSpacing) / IMAGE_COLUMN_COUNT
|
||||
imageAdapter = ProfileImageAdapter(imageSize)
|
||||
val layoutManager = GridLayoutManager(view.context, IMAGE_COLUMN_COUNT)
|
||||
layoutManager.spanSizeLookup = object: GridLayoutManager.SpanSizeLookup() {
|
||||
override fun getSpanSize(position: Int): Int {
|
||||
if(position == 0) return IMAGE_COLUMN_COUNT
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
binding.profileRecyclerView.layoutManager = layoutManager
|
||||
|
||||
binding.profileRecyclerView.adapter = MergeAdapter(headerAdapter, imageAdapter)
|
||||
binding.profileRecyclerView.addItemDecoration(GridSpacingItemDecoration(IMAGE_COLUMN_COUNT, imageSpacing, 1))
|
||||
|
||||
viewModel.setAccountInfo(arg(ACCOUNT_ID))
|
||||
|
||||
viewModel.profile.observe(viewLifecycleOwner, Observer {
|
||||
when (it) {
|
||||
is Success -> onAccountChanged(it.data)
|
||||
is Error -> showError()
|
||||
}
|
||||
})
|
||||
viewModel.relationship.observe(viewLifecycleOwner, Observer {
|
||||
when (it) {
|
||||
is Success -> onRelationshipChanged(it.data)
|
||||
is Error -> showError()
|
||||
}
|
||||
})
|
||||
viewModel.profileImages.observe(viewLifecycleOwner, Observer {
|
||||
imageAdapter.submitList(it)
|
||||
})
|
||||
}
|
||||
|
||||
private fun onAccountChanged(account: Account?) {
|
||||
|
||||
loadedAccount = account ?: return
|
||||
|
||||
binding.toolbar.title = account.displayName
|
||||
|
||||
headerAdapter.setAccount(account, viewModel.isSelf)
|
||||
|
||||
}
|
||||
|
||||
private fun onRelationshipChanged(relation: Relationship?) {
|
||||
relation?.let {
|
||||
headerAdapter.setRelationship(it)
|
||||
}
|
||||
}
|
||||
|
||||
private fun showError() {
|
||||
Snackbar.make(binding.root, R.string.error_generic, Snackbar.LENGTH_LONG)
|
||||
.setAction(R.string.action_retry) { viewModel.load(reload = true) }
|
||||
.show()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val IMAGE_COLUMN_COUNT = 3
|
||||
private const val ACCOUNT_ID = "ACCOUNT_ID"
|
||||
|
||||
/**
|
||||
* create a new ProfileFragment instance
|
||||
* @param accountId the id of the profile to load, null to show the profile of the currently active user
|
||||
*/
|
||||
fun newInstance(accountId: String? = null) =
|
||||
ProfileFragment().withArgs { putString(ACCOUNT_ID, accountId) }
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,83 @@
|
|||
package at.connyduck.pixelcat.components.profile
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import at.connyduck.pixelcat.R
|
||||
import at.connyduck.pixelcat.components.util.extension.visible
|
||||
import at.connyduck.pixelcat.databinding.ItemProfileHeaderBinding
|
||||
import at.connyduck.pixelcat.model.Account
|
||||
import at.connyduck.pixelcat.model.Relationship
|
||||
import coil.api.load
|
||||
import coil.transform.RoundedCornersTransformation
|
||||
import java.text.NumberFormat
|
||||
|
||||
class ProfileHeaderAdapter: RecyclerView.Adapter<ProfileHeaderViewHolder>() {
|
||||
|
||||
private var account: Account? = null
|
||||
private var isSelf: Boolean = false
|
||||
private var relationship: Relationship? = null
|
||||
|
||||
fun setAccount(account: Account, isSelf: Boolean) {
|
||||
this.account = account
|
||||
this.isSelf = isSelf
|
||||
notifyItemChanged(0, ACCOUNT_CHANGED)
|
||||
}
|
||||
|
||||
fun setRelationship(relationship: Relationship) {
|
||||
this.relationship = relationship
|
||||
notifyItemChanged(1, RELATIONSHIP_CHANGED)
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ProfileHeaderViewHolder {
|
||||
val binding = ItemProfileHeaderBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||
return ProfileHeaderViewHolder(binding)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: ProfileHeaderViewHolder, position: Int) {
|
||||
// nothing to do
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: ProfileHeaderViewHolder, position: Int, payloads: List<Any>) {
|
||||
if (payloads.isEmpty() || payloads.contains(ACCOUNT_CHANGED)) {
|
||||
account?.let {
|
||||
holder.binding.profileName.text = it.username
|
||||
|
||||
holder.binding.profileFollowButton.visible = !isSelf
|
||||
holder.binding.profileMessageButton.visible = !isSelf
|
||||
|
||||
val numberFormat = NumberFormat.getNumberInstance()
|
||||
|
||||
holder.binding.profileImage.load(it.avatar) {
|
||||
transformations(RoundedCornersTransformation(20f))
|
||||
}
|
||||
|
||||
holder.binding.profileFollowersTextView.text = numberFormat.format(it.followersCount)
|
||||
holder.binding.profileFollowingTextView.text = numberFormat.format(it.followingCount)
|
||||
holder.binding.profileStatusesTextView.text = numberFormat.format(it.statusesCount)
|
||||
|
||||
holder.binding.profileNote.text = it.note
|
||||
}
|
||||
}
|
||||
if (payloads.isEmpty() || payloads.contains(RELATIONSHIP_CHANGED)) {
|
||||
relationship?.let {
|
||||
if(it.following) {
|
||||
holder.binding.profileFollowButton.setText(R.string.profile_follows_you)
|
||||
} else {
|
||||
holder.binding.profileFollowButton.setText(R.string.profile_action_follow)
|
||||
}
|
||||
holder.binding.profileFollowsYouText.visible = it.followedBy
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemCount() = 1
|
||||
|
||||
companion object {
|
||||
const val ACCOUNT_CHANGED = "ACCOUNT"
|
||||
const val RELATIONSHIP_CHANGED = "RELATIONSHIP"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class ProfileHeaderViewHolder(val binding: ItemProfileHeaderBinding): RecyclerView.ViewHolder(binding.root)
|
|
@ -0,0 +1,61 @@
|
|||
package at.connyduck.pixelcat.components.profile
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.paging.PagedListAdapter
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import at.connyduck.pixelcat.R
|
||||
import at.connyduck.pixelcat.components.util.extension.hide
|
||||
import at.connyduck.pixelcat.components.util.extension.show
|
||||
import at.connyduck.pixelcat.databinding.ItemProfileImageBinding
|
||||
import at.connyduck.pixelcat.model.Attachment
|
||||
import at.connyduck.pixelcat.model.Status
|
||||
import coil.api.load
|
||||
|
||||
class ProfileImageAdapter(
|
||||
private val imageSizePx: Int
|
||||
): PagedListAdapter<Status, ProfileImageViewHolder>(
|
||||
object: DiffUtil.ItemCallback<Status>() {
|
||||
override fun areItemsTheSame(old: Status, new: Status): Boolean {
|
||||
return false
|
||||
}
|
||||
override fun areContentsTheSame(old: Status, new: Status): Boolean {
|
||||
return true
|
||||
}
|
||||
}
|
||||
) {
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ProfileImageViewHolder {
|
||||
val binding = ItemProfileImageBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||
|
||||
binding.root.layoutParams = ViewGroup.LayoutParams(imageSizePx, imageSizePx)
|
||||
|
||||
return ProfileImageViewHolder(binding)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: ProfileImageViewHolder, position: Int) {
|
||||
getItem(position)?.let { status ->
|
||||
|
||||
holder.binding.profileImageView.load(status.attachments.firstOrNull()?.previewUrl)
|
||||
|
||||
when {
|
||||
status.attachments.size > 1 -> {
|
||||
holder.binding.profileImageIcon.show()
|
||||
holder.binding.profileImageIcon.setImageResource(R.drawable.ic_multiple)
|
||||
}
|
||||
status.attachments.first().type == Attachment.Type.VIDEO -> {
|
||||
holder.binding.profileImageIcon.show()
|
||||
holder.binding.profileImageIcon.setImageResource(R.drawable.ic_play)
|
||||
}
|
||||
else -> {
|
||||
holder.binding.profileImageIcon.hide()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
class ProfileImageViewHolder(val binding: ItemProfileImageBinding): RecyclerView.ViewHolder(binding.root)
|
|
@ -0,0 +1,89 @@
|
|||
package at.connyduck.pixelcat.components.profile
|
||||
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.paging.PagedList
|
||||
import at.connyduck.pixelcat.components.util.Error
|
||||
import at.connyduck.pixelcat.components.util.Success
|
||||
import at.connyduck.pixelcat.components.util.UiState
|
||||
import at.connyduck.pixelcat.db.AccountManager
|
||||
import at.connyduck.pixelcat.model.Account
|
||||
import at.connyduck.pixelcat.model.Relationship
|
||||
import at.connyduck.pixelcat.model.Status
|
||||
import at.connyduck.pixelcat.network.FediverseApi
|
||||
import com.bumptech.glide.util.Executors
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
class ProfileViewModel @Inject constructor(
|
||||
private val fediverseApi: FediverseApi,
|
||||
private val accountManager: AccountManager
|
||||
): ViewModel() {
|
||||
|
||||
val profile = MutableLiveData<UiState<Account>>()
|
||||
val relationship = MutableLiveData<UiState<Relationship>>()
|
||||
val profileImages = MutableLiveData<PagedList<Status>>()
|
||||
|
||||
val isSelf: Boolean
|
||||
get() = accountId == null
|
||||
|
||||
private var accountId: String? = null
|
||||
|
||||
fun load(reload: Boolean = false) {
|
||||
loadAccount(reload)
|
||||
if (!isSelf) {
|
||||
loadRelationship(reload)
|
||||
}
|
||||
loadImages(reload)
|
||||
}
|
||||
|
||||
fun setAccountInfo(accountId: String?) {
|
||||
this@ProfileViewModel.accountId = accountId
|
||||
load(false)
|
||||
}
|
||||
|
||||
private fun loadAccount(reload: Boolean = false) {
|
||||
if (profile.value == null || reload) {
|
||||
viewModelScope.launch {
|
||||
fediverseApi.account(getAccountId()).fold({
|
||||
profile.value = Success(it)
|
||||
}, {
|
||||
profile.value = Error(cause = it)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadRelationship(reload: Boolean = false) {
|
||||
if (relationship.value == null || reload) {
|
||||
viewModelScope.launch {
|
||||
fediverseApi.relationships(listOf(getAccountId())).fold({
|
||||
relationship.value = Success(it.first())
|
||||
}, {
|
||||
relationship.value = Error(cause = it)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadImages(reload: Boolean = false) {
|
||||
if(profileImages.value == null || reload) {
|
||||
profileImages.value = PagedList.Builder(
|
||||
ProfileImageDataSource(
|
||||
fediverseApi,
|
||||
accountId,
|
||||
accountManager,
|
||||
viewModelScope
|
||||
), 20
|
||||
).setNotifyExecutor(Executors.mainThreadExecutor())
|
||||
.setFetchExecutor(java.util.concurrent.Executors.newSingleThreadExecutor())
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getAccountId(): String {
|
||||
return accountId ?: accountManager.activeAccount()?.accountId!!
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,20 @@
|
|||
package at.connyduck.pixelcat.components.search
|
||||
|
||||
import androidx.fragment.app.viewModels
|
||||
import at.connyduck.pixelcat.R
|
||||
import at.connyduck.pixelcat.dagger.ViewModelFactory
|
||||
import dagger.android.support.DaggerFragment
|
||||
import javax.inject.Inject
|
||||
|
||||
class SearchFragment: DaggerFragment(R.layout.fragment_search) {
|
||||
|
||||
@Inject
|
||||
lateinit var viewModelFactory: ViewModelFactory
|
||||
|
||||
private val searchViewModel: SearchViewModel by viewModels { viewModelFactory }
|
||||
|
||||
companion object {
|
||||
fun newInstance() = SearchFragment()
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
package at.connyduck.pixelcat.components.search
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import javax.inject.Inject
|
||||
|
||||
class SearchViewModel @Inject constructor(
|
||||
|
||||
): ViewModel() {
|
||||
|
||||
|
||||
|
||||
}
|
|
@ -0,0 +1,68 @@
|
|||
package at.connyduck.pixelcat.components.settings
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.annotation.StyleRes
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.appcompat.app.AppCompatDelegate.*
|
||||
import at.connyduck.pixelcat.R
|
||||
import javax.inject.Inject
|
||||
|
||||
|
||||
class AppSettings @Inject constructor (
|
||||
private val sharedPrefs: SharedPreferences,
|
||||
private val context: Context
|
||||
) {
|
||||
|
||||
@StyleRes
|
||||
fun getAppColorStyle(): Int {
|
||||
val appColorPref = sharedPrefs.getString(
|
||||
context.getString(R.string.key_pref_app_color),
|
||||
context.getString(R.string.key_pref_app_color_default)
|
||||
)
|
||||
|
||||
return when (appColorPref) {
|
||||
context.getString(R.string.key_pref_app_color_warm) -> R.style.Warm
|
||||
context.getString(R.string.key_pref_app_color_cold) -> R.style.Cold
|
||||
else -> throw IllegalStateException()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@AppCompatDelegate.NightMode
|
||||
fun getNightMode(): Int {
|
||||
val nightModePref = sharedPrefs.getString(
|
||||
context.getString(R.string.key_pref_night_mode),
|
||||
context.getString(R.string.key_pref_night_mode_default)
|
||||
)
|
||||
|
||||
return when (nightModePref) {
|
||||
context.getString(R.string.key_pref_night_mode_off) -> MODE_NIGHT_NO
|
||||
context.getString(R.string.key_pref_night_mode_on) -> MODE_NIGHT_YES
|
||||
context.getString(R.string.key_pref_night_mode_auto) -> MODE_NIGHT_AUTO_TIME
|
||||
context.getString(R.string.key_pref_night_mode_follow_system) -> MODE_NIGHT_FOLLOW_SYSTEM
|
||||
else -> throw IllegalStateException()
|
||||
}
|
||||
}
|
||||
|
||||
fun isBlackNightMode(): Boolean {
|
||||
return sharedPrefs.getBoolean(
|
||||
context.getString(R.string.key_pref_black_night_mode),
|
||||
context.resources.getBoolean(R.bool.pref_title_black_night_mode_default)
|
||||
)
|
||||
}
|
||||
|
||||
fun useSystemFont(): Boolean {
|
||||
return sharedPrefs.getBoolean(
|
||||
context.getString(R.string.key_pref_system_font),
|
||||
context.resources.getBoolean(R.bool.pref_title_system_font_default)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
private fun SharedPreferences.getNonNullString(key: String, default: String): String {
|
||||
return getString(key, default) ?: default
|
||||
}
|
|
@ -0,0 +1,105 @@
|
|||
package at.connyduck.pixelcat.components.settings
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.ComponentName
|
||||
import android.content.Intent
|
||||
import android.content.SharedPreferences
|
||||
import android.os.Bundle
|
||||
import android.view.ViewGroup
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.preference.PreferenceFragmentCompat
|
||||
import at.connyduck.pixelcat.R
|
||||
import at.connyduck.pixelcat.components.general.BaseActivity
|
||||
import at.connyduck.pixelcat.databinding.ActivitySettingsBinding
|
||||
import at.connyduck.pixelcat.util.viewBinding
|
||||
import javax.inject.Inject
|
||||
|
||||
class SettingsActivity : BaseActivity(), SharedPreferences.OnSharedPreferenceChangeListener {
|
||||
|
||||
@Inject
|
||||
lateinit var preferences: SharedPreferences
|
||||
|
||||
private val binding by viewBinding(ActivitySettingsBinding::inflate)
|
||||
|
||||
private var restartActivitiesOnExit = false
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(binding.root)
|
||||
|
||||
binding.settingsContainer.setOnApplyWindowInsetsListener { _, insets ->
|
||||
val top = insets.systemWindowInsetTop
|
||||
|
||||
val toolbarParams = binding.settingsToolbar.layoutParams as ViewGroup.MarginLayoutParams
|
||||
toolbarParams.topMargin = top
|
||||
|
||||
insets.consumeSystemWindowInsets()
|
||||
}
|
||||
|
||||
supportFragmentManager
|
||||
.beginTransaction()
|
||||
.replace(R.id.settings, SettingsFragment())
|
||||
.commit()
|
||||
|
||||
binding.settingsToolbar.setNavigationOnClickListener {
|
||||
onBackPressed()
|
||||
}
|
||||
|
||||
preferences.registerOnSharedPreferenceChangeListener(this)
|
||||
|
||||
restartActivitiesOnExit = intent.getBooleanExtra(EXTRA_RESTART_ACTIVITIES, false)
|
||||
|
||||
}
|
||||
|
||||
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) {
|
||||
when(key) {
|
||||
getString(R.string.key_pref_app_color) -> restartCurrentActivity()
|
||||
getString(R.string.key_pref_night_mode) -> AppCompatDelegate.setDefaultNightMode(appSettings.getNightMode())
|
||||
}
|
||||
}
|
||||
|
||||
private fun restartCurrentActivity() {
|
||||
intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
intent.putExtra(EXTRA_RESTART_ACTIVITIES, restartActivitiesOnExit)
|
||||
startActivity(intent)
|
||||
finish()
|
||||
overridePendingTransition(R.anim.fade_in, R.anim.fade_out)
|
||||
}
|
||||
|
||||
override fun onBackPressed() {
|
||||
val parentActivityName = intent.getStringExtra(EXTRA_PARENT_ACTIVITY)
|
||||
if(restartActivitiesOnExit && parentActivityName != null) {
|
||||
val restartIntent = Intent()
|
||||
restartIntent.component = ComponentName(this, intent.getStringExtra(EXTRA_PARENT_ACTIVITY)!!)
|
||||
restartIntent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
||||
startActivity(restartIntent)
|
||||
} else {
|
||||
super.onBackPressed()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
preferences.unregisterOnSharedPreferenceChangeListener(this)
|
||||
}
|
||||
|
||||
class SettingsFragment : PreferenceFragmentCompat() {
|
||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||
setPreferencesFromResource(R.xml.settings, rootKey)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private const val EXTRA_PARENT_ACTIVITY = "parent"
|
||||
private const val EXTRA_RESTART_ACTIVITIES = "restart"
|
||||
|
||||
fun newIntent(activity: Activity): Intent {
|
||||
return Intent(activity, SettingsActivity::class.java).apply {
|
||||
putExtra(EXTRA_PARENT_ACTIVITY, activity.componentName.className)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
|
@ -0,0 +1,35 @@
|
|||
package at.connyduck.pixelcat.components.splash
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import at.connyduck.pixelcat.components.login.LoginActivity
|
||||
import at.connyduck.pixelcat.components.main.MainActivity
|
||||
import at.connyduck.pixelcat.db.AccountManager
|
||||
import dagger.android.support.DaggerAppCompatActivity
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
class SplashActivity: DaggerAppCompatActivity() {
|
||||
|
||||
@Inject
|
||||
lateinit var accountManager: AccountManager
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
lifecycleScope.launch {
|
||||
|
||||
val intent = if (accountManager.activeAccount() != null) {
|
||||
Intent(
|
||||
this@SplashActivity,
|
||||
MainActivity::class.java
|
||||
) //TODO don't create intents here
|
||||
} else {
|
||||
Intent(this@SplashActivity, LoginActivity::class.java)
|
||||
}
|
||||
startActivity(intent)
|
||||
finish()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,92 @@
|
|||
package at.connyduck.pixelcat.components.timeline
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.paging.ExperimentalPagingApi
|
||||
import androidx.recyclerview.widget.DividerItemDecoration
|
||||
import androidx.recyclerview.widget.SimpleItemAnimator
|
||||
import at.connyduck.pixelcat.R
|
||||
import at.connyduck.pixelcat.components.util.getColorForAttr
|
||||
import at.connyduck.pixelcat.dagger.ViewModelFactory
|
||||
import at.connyduck.pixelcat.databinding.FragmentTimelineBinding
|
||||
import at.connyduck.pixelcat.db.entitity.StatusEntity
|
||||
import at.connyduck.pixelcat.util.viewBinding
|
||||
import dagger.android.support.DaggerFragment
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
class TimelineFragment: DaggerFragment(R.layout.fragment_timeline), TimeLineActionListener {
|
||||
|
||||
@Inject
|
||||
lateinit var viewModelFactory: ViewModelFactory
|
||||
|
||||
private val viewModel: TimelineViewModel by viewModels { viewModelFactory }
|
||||
|
||||
private val binding by viewBinding(FragmentTimelineBinding::bind)
|
||||
|
||||
@ExperimentalPagingApi
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
|
||||
binding.timelineSwipeRefresh.setColorSchemeColors(
|
||||
view.context.getColorForAttr(R.attr.pixelcat_gradient_color_start),
|
||||
view.context.getColorForAttr(R.attr.pixelcat_gradient_color_end)
|
||||
)
|
||||
|
||||
binding.toolbar.setNavigationOnClickListener {
|
||||
binding.timelineRecyclerView.scrollToPosition(0)
|
||||
}
|
||||
/* binding.timelineContainer.setOnApplyWindowInsetsListener { _, insets ->
|
||||
val top = insets.systemWindowInsetTop
|
||||
|
||||
val toolbarParams = binding.toolbar.layoutParams as AppBarLayout.LayoutParams
|
||||
toolbarParams.topMargin = top
|
||||
|
||||
insets.consumeSystemWindowInsets()
|
||||
|
||||
}*/
|
||||
|
||||
val adapter = TimelineListAdapter(this)
|
||||
|
||||
binding.timelineRecyclerView.adapter = adapter
|
||||
(binding.timelineRecyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false
|
||||
binding.timelineRecyclerView.addItemDecoration(DividerItemDecoration(requireContext(), DividerItemDecoration.VERTICAL))
|
||||
|
||||
lifecycleScope.launch {
|
||||
viewModel.flow.collectLatest { pagingData ->
|
||||
adapter.submitData(pagingData)
|
||||
}
|
||||
}
|
||||
|
||||
binding.timelineSwipeRefresh.setOnRefreshListener {
|
||||
adapter.refresh()
|
||||
}
|
||||
|
||||
adapter.addDataRefreshListener {
|
||||
binding.timelineSwipeRefresh.isRefreshing = false
|
||||
}
|
||||
|
||||
|
||||
//viewModel.posts.observe(viewLifecycleOwner, Observer { t -> adapter.submitList(t) })
|
||||
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun newInstance() = TimelineFragment()
|
||||
}
|
||||
|
||||
override fun onFavorite(post: StatusEntity) {
|
||||
viewModel.onFavorite(post)
|
||||
}
|
||||
|
||||
override fun onBoost(post: StatusEntity) {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override fun onReply(status: StatusEntity) {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,34 @@
|
|||
package at.connyduck.pixelcat.components.timeline
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import at.connyduck.pixelcat.databinding.ItemTimelineImageBinding
|
||||
import at.connyduck.pixelcat.model.Attachment
|
||||
import coil.api.load
|
||||
|
||||
class TimelineImageAdapter: RecyclerView.Adapter<TimelineImageViewHolder>() {
|
||||
|
||||
var images: List<Attachment> = emptyList()
|
||||
set(value) {
|
||||
field = value
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TimelineImageViewHolder {
|
||||
val binding = ItemTimelineImageBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||
return TimelineImageViewHolder(binding)
|
||||
}
|
||||
|
||||
override fun getItemCount() = images.size
|
||||
|
||||
override fun onBindViewHolder(holder: TimelineImageViewHolder, position: Int) {
|
||||
|
||||
holder.binding.timelineImageView.load(images[position].previewUrl)
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
class TimelineImageViewHolder(val binding: ItemTimelineImageBinding): RecyclerView.ViewHolder(binding.root)
|
|
@ -0,0 +1,104 @@
|
|||
package at.connyduck.pixelcat.components.timeline
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.text.parseAsHtml
|
||||
import androidx.paging.PagingDataAdapter
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import at.connyduck.pixelcat.components.profile.ProfileActivity
|
||||
import at.connyduck.pixelcat.components.util.extension.visible
|
||||
import at.connyduck.pixelcat.databinding.ItemStatusBinding
|
||||
import at.connyduck.pixelcat.db.entitity.StatusEntity
|
||||
import coil.api.load
|
||||
import coil.transform.RoundedCornersTransformation
|
||||
import java.text.SimpleDateFormat
|
||||
|
||||
interface TimeLineActionListener {
|
||||
fun onFavorite(post: StatusEntity)
|
||||
fun onBoost(post: StatusEntity)
|
||||
fun onReply(status: StatusEntity)
|
||||
}
|
||||
|
||||
object TimelineDiffUtil: DiffUtil.ItemCallback<StatusEntity>() {
|
||||
override fun areItemsTheSame(oldItem: StatusEntity, newItem: StatusEntity): Boolean {
|
||||
return oldItem.id == newItem.id
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(oldItem: StatusEntity, newItem: StatusEntity): Boolean {
|
||||
return oldItem == newItem
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class TimelineListAdapter(
|
||||
private val listener: TimeLineActionListener
|
||||
) : PagingDataAdapter<StatusEntity, TimelineViewHolder>(TimelineDiffUtil) {
|
||||
|
||||
private val dateTimeFormatter = SimpleDateFormat.getDateTimeInstance(SimpleDateFormat.SHORT, SimpleDateFormat.SHORT)
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TimelineViewHolder {
|
||||
val binding = ItemStatusBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||
return TimelineViewHolder(binding)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: TimelineViewHolder, position: Int) {
|
||||
|
||||
getItem(position)?.let { status ->
|
||||
|
||||
// TODO order the stuff here
|
||||
|
||||
(holder.binding.postImages.adapter as TimelineImageAdapter).images = status.attachments
|
||||
holder.binding.postAvatar.load(status.account.avatar) {
|
||||
transformations(RoundedCornersTransformation(25f))
|
||||
}
|
||||
|
||||
holder.binding.postAvatar.setOnClickListener {
|
||||
holder.binding.root.context.startActivity(ProfileActivity.newIntent(holder.binding.root.context, status.account.id))
|
||||
}
|
||||
|
||||
holder.binding.postDisplayName.text = status.account.displayName
|
||||
holder.binding.postName.text = "@${status.account.username}"
|
||||
|
||||
holder.binding.postLikeButton.isChecked = status.favourited
|
||||
|
||||
holder.binding.postLikeButton.setEventListener { button, buttonState ->
|
||||
listener.onFavorite(status)
|
||||
true
|
||||
}
|
||||
|
||||
holder.binding.postBoostButton.setEventListener { button, buttonState ->
|
||||
listener.onBoost(status)
|
||||
true
|
||||
}
|
||||
|
||||
holder.binding.postReplyButton.setOnClickListener {
|
||||
listener.onReply(status)
|
||||
}
|
||||
|
||||
holder.binding.postIndicator.visible = status.attachments.size > 1
|
||||
|
||||
holder.binding.postImages.visible = status.attachments.isNotEmpty()
|
||||
|
||||
holder.binding.postDescription.text = status.content.parseAsHtml().trim()
|
||||
|
||||
holder.binding.postDate.text = dateTimeFormatter.format(status.createdAt)
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
class TimelineViewHolder(val binding: ItemStatusBinding): RecyclerView.ViewHolder(binding.root) {
|
||||
init {
|
||||
binding.postImages.adapter = TimelineImageAdapter()
|
||||
|
||||
|
||||
binding.postIndicator.setViewPager(binding.postImages)
|
||||
(binding.postImages.adapter as TimelineImageAdapter).registerAdapterDataObserver(binding.postIndicator.adapterDataObserver)
|
||||
// val snapHelper = PagerSnapHelper()
|
||||
// snapHelper.attachToRecyclerView(binding.postImages)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,55 @@
|
|||
package at.connyduck.pixelcat.components.timeline
|
||||
|
||||
import android.util.Log
|
||||
import androidx.paging.ExperimentalPagingApi
|
||||
import androidx.paging.LoadType
|
||||
import androidx.paging.PagingState
|
||||
import androidx.paging.RemoteMediator
|
||||
import androidx.room.withTransaction
|
||||
import at.connyduck.pixelcat.db.AppDatabase
|
||||
import at.connyduck.pixelcat.db.entitity.StatusEntity
|
||||
import at.connyduck.pixelcat.db.entitity.toEntity
|
||||
import at.connyduck.pixelcat.network.FediverseApi
|
||||
|
||||
@ExperimentalPagingApi
|
||||
class TimelineRemoteMediator(
|
||||
private val accountId: Long,
|
||||
private val api: FediverseApi,
|
||||
private val db: AppDatabase
|
||||
): RemoteMediator<Int, StatusEntity>() {
|
||||
|
||||
override suspend fun load(
|
||||
loadType: LoadType,
|
||||
state: PagingState<Int, StatusEntity>
|
||||
): MediatorResult {
|
||||
val apiCall = when (loadType) {
|
||||
LoadType.REFRESH -> {
|
||||
api.homeTimeline(limit = state.config.initialLoadSize)
|
||||
}
|
||||
LoadType.PREPEND -> {
|
||||
return MediatorResult.Success(true)
|
||||
}
|
||||
LoadType.APPEND -> {
|
||||
val maxId = state.pages.findLast { it.data.isNotEmpty() }?.data?.lastOrNull()?.id
|
||||
Log.w("TimelineRemoteMediator", "anchorPosition: ${state.anchorPosition} maxId: $maxId")
|
||||
api.homeTimeline(maxId = maxId, limit = state.config.pageSize)
|
||||
}
|
||||
}
|
||||
|
||||
return apiCall.fold({ statusResult ->
|
||||
db.withTransaction {
|
||||
if (loadType == LoadType.REFRESH) {
|
||||
db.statusDao().clearAll(accountId)
|
||||
}
|
||||
db.statusDao().insertOrReplace(statusResult.map { it.toEntity(accountId) })
|
||||
}
|
||||
MediatorResult.Success(endOfPaginationReached = statusResult.isEmpty())
|
||||
}, {
|
||||
MediatorResult.Error(it)
|
||||
})
|
||||
|
||||
|
||||
}
|
||||
|
||||
override suspend fun initialize() = InitializeAction.SKIP_INITIAL_REFRESH
|
||||
}
|
|
@ -0,0 +1,45 @@
|
|||
package at.connyduck.pixelcat.components.timeline
|
||||
|
||||
import androidx.lifecycle.*
|
||||
import androidx.paging.*
|
||||
import at.connyduck.pixelcat.db.AccountManager
|
||||
import at.connyduck.pixelcat.db.AppDatabase
|
||||
import at.connyduck.pixelcat.db.entitity.AccountEntity
|
||||
import at.connyduck.pixelcat.db.entitity.StatusEntity
|
||||
import at.connyduck.pixelcat.network.FediverseApi
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
class TimelineViewModel @Inject constructor(
|
||||
// private val repository: TimelineRepo,
|
||||
private val accountManager: AccountManager,
|
||||
private val db: AppDatabase,
|
||||
fediverseApi: FediverseApi
|
||||
): ViewModel() {
|
||||
|
||||
private val accountId = MutableLiveData<Long>()
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
val currentAccountId = accountManager.activeAccount()?.id!!
|
||||
accountId.postValue(currentAccountId)
|
||||
}
|
||||
}
|
||||
|
||||
@ExperimentalPagingApi
|
||||
val flow = Pager(
|
||||
// Configure how data is loaded by passing additional properties to
|
||||
// PagingConfig, such as prefetchDistance.
|
||||
config = PagingConfig(pageSize = 10, enablePlaceholders = false),
|
||||
remoteMediator = TimelineRemoteMediator(0, fediverseApi, db),
|
||||
pagingSourceFactory = { db.statusDao().statuses(0) }
|
||||
).flow.cachedIn(viewModelScope)
|
||||
|
||||
fun onFavorite(status: StatusEntity) {
|
||||
viewModelScope.launch {
|
||||
// repository.onFavorite(status, accountManager.activeAccount()?.id!!)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
package at.connyduck.pixelcat.components.util
|
||||
|
||||
sealed class UiState<T>(open val data: T?)
|
||||
|
||||
class Loading<T> (override val data: T? = null) : UiState<T>(data)
|
||||
|
||||
class Success<T> (override val data: T? = null) : UiState<T>(data)
|
||||
|
||||
class Error<T> (override val data: T? = null,
|
||||
val errorMessage: String? = null,
|
||||
var consumed: Boolean = false,
|
||||
val cause: Throwable? = null
|
||||
): UiState<T>(data)
|
|
@ -0,0 +1,15 @@
|
|||
package at.connyduck.pixelcat.components.util
|
||||
|
||||
import android.content.Context
|
||||
import android.util.TypedValue
|
||||
import androidx.annotation.AttrRes
|
||||
import androidx.annotation.ColorInt
|
||||
|
||||
@ColorInt
|
||||
fun Context.getColorForAttr(@AttrRes attr: Int): Int {
|
||||
val value = TypedValue()
|
||||
if(theme.resolveAttribute(attr, value, true)) {
|
||||
return value.data
|
||||
}
|
||||
throw IllegalStateException("Attribute not found")
|
||||
}
|
|
@ -0,0 +1,25 @@
|
|||
package at.connyduck.pixelcat.components.util.extension
|
||||
|
||||
import android.content.Context
|
||||
import android.util.DisplayMetrics
|
||||
import android.util.TypedValue
|
||||
import android.view.WindowManager
|
||||
import androidx.annotation.AttrRes
|
||||
import androidx.annotation.ColorInt
|
||||
|
||||
fun Context.getDisplayWidthInPx(): Int {
|
||||
val metrics = DisplayMetrics()
|
||||
val windowManager = getSystemService(Context.WINDOW_SERVICE) as WindowManager
|
||||
windowManager.defaultDisplay.getMetrics(metrics)
|
||||
return metrics.widthPixels
|
||||
}
|
||||
|
||||
@ColorInt
|
||||
fun Context.getColorForAttr(@AttrRes attr: Int): Int {
|
||||
val value = TypedValue()
|
||||
return if (this.theme.resolveAttribute(attr, value, true)) {
|
||||
value.data
|
||||
} else {
|
||||
throw IllegalArgumentException()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,17 @@
|
|||
package at.connyduck.pixelcat.components.util.extension
|
||||
|
||||
import android.view.View
|
||||
|
||||
fun View.show() {
|
||||
this.visibility = View.VISIBLE
|
||||
}
|
||||
|
||||
fun View.hide() {
|
||||
this.visibility = View.GONE
|
||||
}
|
||||
|
||||
var View.visible
|
||||
get() = visibility == View.VISIBLE
|
||||
set(value) {
|
||||
visibility = if(value) View.VISIBLE else View.GONE
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
package at.connyduck.pixelcat.config
|
||||
|
||||
object Config {
|
||||
|
||||
const val website = "https://pixelcat.app"
|
||||
|
||||
const val oAuthScheme = "pixelcat"
|
||||
const val oAuthHost = "oauth"
|
||||
const val oAuthRedirect = "$oAuthScheme://$oAuthHost"
|
||||
const val oAuthScopes = "read write follow"
|
||||
|
||||
val domainExceptions = arrayOf("gab.com", "gab.ai", "gabfed.com")
|
||||
|
||||
}
|
|
@ -0,0 +1,42 @@
|
|||
package at.connyduck.pixelcat.dagger
|
||||
|
||||
import at.connyduck.pixelcat.components.main.MainActivity
|
||||
import at.connyduck.pixelcat.components.about.AboutActivity
|
||||
import at.connyduck.pixelcat.components.about.licenses.LicenseActivity
|
||||
import at.connyduck.pixelcat.components.compose.ComposeActivity
|
||||
import at.connyduck.pixelcat.components.login.LoginActivity
|
||||
import at.connyduck.pixelcat.components.profile.ProfileActivity
|
||||
import at.connyduck.pixelcat.components.settings.SettingsActivity
|
||||
import at.connyduck.pixelcat.components.splash.SplashActivity
|
||||
import dagger.Module
|
||||
import dagger.android.ContributesAndroidInjector
|
||||
|
||||
@Module
|
||||
abstract class ActivityModule {
|
||||
|
||||
//TODO order stuff here
|
||||
@ContributesAndroidInjector(modules = [FragmentModule::class])
|
||||
abstract fun contributesMainActivity(): MainActivity
|
||||
|
||||
@ContributesAndroidInjector
|
||||
abstract fun contributesLoginActivity(): LoginActivity
|
||||
|
||||
@ContributesAndroidInjector(modules = [FragmentModule::class])
|
||||
abstract fun contributesSettingsActivity(): SettingsActivity
|
||||
|
||||
@ContributesAndroidInjector
|
||||
abstract fun contributesLicenseActivity(): LicenseActivity
|
||||
|
||||
@ContributesAndroidInjector
|
||||
abstract fun contributesAboutActivity(): AboutActivity
|
||||
|
||||
@ContributesAndroidInjector
|
||||
abstract fun contributesSplashActivity(): SplashActivity
|
||||
|
||||
@ContributesAndroidInjector(modules = [FragmentModule::class])
|
||||
abstract fun contributesProfileActivity(): ProfileActivity
|
||||
|
||||
@ContributesAndroidInjector
|
||||
abstract fun contributesComposeActivity(): ComposeActivity
|
||||
|
||||
}
|
|
@ -0,0 +1,30 @@
|
|||
package at.connyduck.pixelcat.dagger
|
||||
|
||||
import at.connyduck.pixelcat.PixelcatApplication
|
||||
import dagger.BindsInstance
|
||||
import dagger.Component
|
||||
import dagger.android.AndroidInjectionModule
|
||||
import dagger.android.AndroidInjector
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
@Component(modules = [
|
||||
AppModule::class,
|
||||
NetworkModule::class,
|
||||
AndroidInjectionModule::class,
|
||||
ActivityModule::class,
|
||||
ViewModelModule::class,
|
||||
ServiceModule::class
|
||||
])
|
||||
interface AppComponent : AndroidInjector<PixelcatApplication> {
|
||||
|
||||
@Component.Builder
|
||||
interface Builder {
|
||||
@BindsInstance
|
||||
fun application(app: PixelcatApplication): Builder
|
||||
|
||||
fun build(): AppComponent
|
||||
}
|
||||
|
||||
override fun inject(app: PixelcatApplication)
|
||||
}
|
|
@ -0,0 +1,46 @@
|
|||
package at.connyduck.pixelcat.dagger
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.preference.PreferenceManager
|
||||
import androidx.room.Room
|
||||
import at.connyduck.pixelcat.PixelcatApplication
|
||||
import at.connyduck.pixelcat.db.AccountManager
|
||||
import at.connyduck.pixelcat.db.AppDatabase
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
class AppModule {
|
||||
|
||||
@Provides
|
||||
fun providesApp(app: PixelcatApplication): Application = app
|
||||
|
||||
@Provides
|
||||
fun providesContext(app: Application): Context = app
|
||||
|
||||
@Provides
|
||||
fun providesSharedPreferences(app: Application): SharedPreferences {
|
||||
return PreferenceManager.getDefaultSharedPreferences(app)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun providesDatabase(app: PixelcatApplication): AppDatabase {
|
||||
return Room
|
||||
.databaseBuilder(app, AppDatabase::class.java, "pixelcat.db")
|
||||
.build()
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun providesAccountManager(db: AppDatabase): AccountManager {
|
||||
return AccountManager(db)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
|
@ -0,0 +1,25 @@
|
|||
package at.connyduck.pixelcat.dagger
|
||||
|
||||
import at.connyduck.pixelcat.components.notifications.NotificationsFragment
|
||||
import at.connyduck.pixelcat.components.profile.ProfileFragment
|
||||
import at.connyduck.pixelcat.components.search.SearchFragment
|
||||
import at.connyduck.pixelcat.components.timeline.TimelineFragment
|
||||
import dagger.Module
|
||||
import dagger.android.ContributesAndroidInjector
|
||||
|
||||
@Module
|
||||
abstract class FragmentModule {
|
||||
|
||||
@ContributesAndroidInjector
|
||||
abstract fun timelineFragment(): TimelineFragment
|
||||
|
||||
@ContributesAndroidInjector
|
||||
abstract fun searchFragment(): SearchFragment
|
||||
|
||||
@ContributesAndroidInjector
|
||||
abstract fun notificationsFragment(): NotificationsFragment
|
||||
|
||||
@ContributesAndroidInjector
|
||||
abstract fun profileFragment(): ProfileFragment
|
||||
|
||||
}
|
|
@ -0,0 +1,70 @@
|
|||
package at.connyduck.pixelcat.dagger
|
||||
|
||||
import at.connyduck.pixelcat.BuildConfig
|
||||
import at.connyduck.pixelcat.db.AccountManager
|
||||
import at.connyduck.pixelcat.network.FediverseApi
|
||||
import at.connyduck.pixelcat.network.InstanceSwitchAuthInterceptor
|
||||
import at.connyduck.pixelcat.network.RefreshTokenAuthenticator
|
||||
import at.connyduck.pixelcat.network.UserAgentInterceptor
|
||||
import at.connyduck.pixelcat.network.calladapter.NetworkResponseAdapterFactory
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.squareup.moshi.adapters.Rfc3339DateJsonAdapter
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.moshi.MoshiConverterFactory
|
||||
import retrofit2.create
|
||||
import java.util.*
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
class NetworkModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun providesOkHttpClient(accountManager: AccountManager): OkHttpClient {
|
||||
|
||||
val okHttpClientBuilder = OkHttpClient.Builder()
|
||||
.addInterceptor(UserAgentInterceptor())
|
||||
.addInterceptor(InstanceSwitchAuthInterceptor(accountManager))
|
||||
.authenticator(RefreshTokenAuthenticator(accountManager))
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.writeTimeout(30, TimeUnit.SECONDS)
|
||||
|
||||
if(BuildConfig.DEBUG) {
|
||||
okHttpClientBuilder.addInterceptor(HttpLoggingInterceptor().apply {
|
||||
level = HttpLoggingInterceptor.Level.HEADERS
|
||||
})
|
||||
}
|
||||
|
||||
return okHttpClientBuilder.build()
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun providesMoshi(): Moshi {
|
||||
return Moshi.Builder()
|
||||
.add(Date::class.java, Rfc3339DateJsonAdapter())
|
||||
.build()
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun providesRetrofit(httpClient: OkHttpClient, moshi: Moshi): Retrofit {
|
||||
|
||||
return Retrofit.Builder()
|
||||
.baseUrl("https://" + FediverseApi.PLACEHOLDER_DOMAIN)
|
||||
.client(httpClient)
|
||||
.addCallAdapterFactory(NetworkResponseAdapterFactory())
|
||||
.addConverterFactory(MoshiConverterFactory.create(moshi))
|
||||
.build()
|
||||
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun providesApi(retrofit: Retrofit): FediverseApi = retrofit.create()
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
package at.connyduck.pixelcat.dagger
|
||||
|
||||
import at.connyduck.pixelcat.components.compose.SendStatusService
|
||||
import dagger.Module
|
||||
import dagger.android.ContributesAndroidInjector
|
||||
|
||||
@Module
|
||||
abstract class ServiceModule {
|
||||
|
||||
@ContributesAndroidInjector
|
||||
abstract fun contributesSendStatusService(): SendStatusService
|
||||
|
||||
|
||||
}
|
|
@ -0,0 +1,76 @@
|
|||
// from https://proandroiddev.com/viewmodel-with-dagger2-architecture-components-2e06f06c9455
|
||||
|
||||
package at.connyduck.pixelcat.dagger
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import at.connyduck.pixelcat.components.compose.ComposeViewModel
|
||||
import at.connyduck.pixelcat.components.login.LoginViewModel
|
||||
import at.connyduck.pixelcat.components.main.MainViewModel
|
||||
import at.connyduck.pixelcat.components.notifications.NotificationsViewModel
|
||||
import at.connyduck.pixelcat.components.profile.ProfileViewModel
|
||||
import at.connyduck.pixelcat.components.search.SearchViewModel
|
||||
import at.connyduck.pixelcat.components.timeline.TimelineViewModel
|
||||
import dagger.Binds
|
||||
import dagger.MapKey
|
||||
import dagger.Module
|
||||
import dagger.multibindings.IntoMap
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Provider
|
||||
import javax.inject.Singleton
|
||||
import kotlin.reflect.KClass
|
||||
|
||||
@Singleton
|
||||
class ViewModelFactory @Inject constructor(private val viewModels: MutableMap<Class<out ViewModel>, Provider<ViewModel>>) : ViewModelProvider.Factory {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T = viewModels[modelClass]?.get() as T
|
||||
}
|
||||
|
||||
@Target(AnnotationTarget.FUNCTION, AnnotationTarget.PROPERTY_GETTER, AnnotationTarget.PROPERTY_SETTER)
|
||||
@Retention(AnnotationRetention.RUNTIME)
|
||||
@MapKey
|
||||
internal annotation class ViewModelKey(val value: KClass<out ViewModel>)
|
||||
|
||||
@Module
|
||||
abstract class ViewModelModule {
|
||||
|
||||
@Binds
|
||||
internal abstract fun bindViewModelFactory(factory: ViewModelFactory): ViewModelProvider.Factory
|
||||
|
||||
@Binds
|
||||
@IntoMap
|
||||
@ViewModelKey(LoginViewModel::class)
|
||||
internal abstract fun loginViewModel(viewModel: LoginViewModel): ViewModel
|
||||
|
||||
@Binds
|
||||
@IntoMap
|
||||
@ViewModelKey(MainViewModel::class)
|
||||
internal abstract fun mainViewModel(viewModel: MainViewModel): ViewModel
|
||||
|
||||
|
||||
@Binds
|
||||
@IntoMap
|
||||
@ViewModelKey(TimelineViewModel::class)
|
||||
internal abstract fun timelineViewModel(viewModel: TimelineViewModel): ViewModel
|
||||
|
||||
@Binds
|
||||
@IntoMap
|
||||
@ViewModelKey(SearchViewModel::class)
|
||||
internal abstract fun searchViewModel(viewModel: SearchViewModel): ViewModel
|
||||
|
||||
@Binds
|
||||
@IntoMap
|
||||
@ViewModelKey(NotificationsViewModel::class)
|
||||
internal abstract fun notificationsViewModel(viewModel: NotificationsViewModel): ViewModel
|
||||
|
||||
@Binds
|
||||
@IntoMap
|
||||
@ViewModelKey(ProfileViewModel::class)
|
||||
internal abstract fun profileViewModel(viewModel: ProfileViewModel): ViewModel
|
||||
|
||||
@Binds
|
||||
@IntoMap
|
||||
@ViewModelKey(ComposeViewModel::class)
|
||||
internal abstract fun composeViewModel(viewModel: ComposeViewModel): ViewModel
|
||||
//Add more ViewModels here
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
package at.connyduck.pixelcat.db
|
||||
|
||||
import androidx.room.*
|
||||
import at.connyduck.pixelcat.db.entitity.AccountEntity
|
||||
|
||||
@Dao
|
||||
interface AccountDao {
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertOrReplace(account: AccountEntity): Long
|
||||
|
||||
@Delete
|
||||
suspend fun delete(account: AccountEntity)
|
||||
|
||||
@Query("SELECT * FROM AccountEntity ORDER BY id ASC")
|
||||
suspend fun loadAll(): List<AccountEntity>
|
||||
|
||||
}
|
|
@ -0,0 +1,191 @@
|
|||
|
||||
|
||||
package at.connyduck.pixelcat.db
|
||||
|
||||
import android.util.Log
|
||||
import at.connyduck.pixelcat.db.entitity.AccountAuthData
|
||||
import at.connyduck.pixelcat.db.entitity.AccountEntity
|
||||
import at.connyduck.pixelcat.model.Account
|
||||
|
||||
/**
|
||||
* This class caches the account database and handles all account related operations
|
||||
* @author ConnyDuck
|
||||
*/
|
||||
|
||||
//TODO check if the comments are up to date
|
||||
|
||||
private const val TAG = "AccountManager"
|
||||
|
||||
class AccountManager(db: AppDatabase) {
|
||||
|
||||
private var activeAccount: AccountEntity? = null
|
||||
|
||||
private var accounts: MutableList<AccountEntity> = mutableListOf()
|
||||
private val accountDao: AccountDao = db.accountDao()
|
||||
|
||||
|
||||
suspend fun activeAccount(): AccountEntity? {
|
||||
if(activeAccount == null) {
|
||||
accounts = accountDao.loadAll().toMutableList()
|
||||
|
||||
activeAccount = accounts.find { acc ->
|
||||
acc.isActive
|
||||
}
|
||||
}
|
||||
return activeAccount
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Adds a new empty account and makes it the active account.
|
||||
* More account information has to be added later with [updateActiveAccount]
|
||||
* or the account wont be saved to the database.
|
||||
* @param accessToken the access token for the new account
|
||||
* @param domain the domain of the accounts Mastodon instance
|
||||
*/
|
||||
suspend fun addAccount(domain: String, authData: AccountAuthData) {
|
||||
|
||||
activeAccount?.let {
|
||||
it.isActive = false
|
||||
Log.d(TAG, "addAccount: saving account with id " + it.id)
|
||||
|
||||
accountDao.insertOrReplace(it)
|
||||
}
|
||||
|
||||
val maxAccountId = accounts.maxBy { it.id }?.id ?: 0
|
||||
val newAccountId = maxAccountId + 1
|
||||
activeAccount = AccountEntity(
|
||||
id = newAccountId,
|
||||
domain = domain,
|
||||
auth = authData,
|
||||
isActive = true
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves an already known account to the database.
|
||||
* New accounts must be created with [addAccount]
|
||||
* @param account the account to save
|
||||
*/
|
||||
suspend fun saveAccount(account: AccountEntity) {
|
||||
if (account.id != 0L) {
|
||||
Log.d(TAG, "saveAccount: saving account with id " + account.id)
|
||||
accountDao.insertOrReplace(account)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs the current account out by deleting all data of the account.
|
||||
* @return the new active account, or null if no other account was found
|
||||
*/
|
||||
suspend fun logActiveAccountOut(): AccountEntity? {
|
||||
|
||||
if (activeAccount == null) {
|
||||
return null
|
||||
} else {
|
||||
accounts.remove(activeAccount!!)
|
||||
accountDao.delete(activeAccount!!)
|
||||
|
||||
if (accounts.size > 0) {
|
||||
accounts[0].isActive = true
|
||||
activeAccount = accounts[0]
|
||||
Log.d(TAG, "logActiveAccountOut: saving account with id " + accounts[0].id)
|
||||
accountDao.insertOrReplace(accounts[0])
|
||||
} else {
|
||||
activeAccount = null
|
||||
}
|
||||
return activeAccount
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* updates the current account with new information from the mastodon api
|
||||
* and saves it in the database
|
||||
* @param account the [Account] object returned from the api
|
||||
*/
|
||||
suspend fun updateActiveAccount(account: Account) {
|
||||
activeAccount?.let {
|
||||
it.accountId = account.id
|
||||
it.username = account.username
|
||||
it.displayName = account.name
|
||||
it.profilePictureUrl = account.avatar
|
||||
// it.defaultPostPrivacy = account.source?.privacy ?: Status.Visibility.PUBLIC
|
||||
it.defaultMediaSensitivity = account.source?.sensitive ?: false
|
||||
// it.emojis = account.emojis ?: emptyList()
|
||||
|
||||
Log.d(TAG, "updateActiveAccount: saving account with id " + it.id)
|
||||
it.id = accountDao.insertOrReplace(it)
|
||||
|
||||
val accountIndex = accounts.indexOf(it)
|
||||
|
||||
if (accountIndex != -1) {
|
||||
//in case the user was already logged in with this account, remove the old information
|
||||
accounts.removeAt(accountIndex)
|
||||
accounts.add(accountIndex, it)
|
||||
} else {
|
||||
accounts.add(it)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* changes the active account
|
||||
* @param accountId the database id of the new active account
|
||||
*/
|
||||
suspend fun setActiveAccount(accountId: Long) {
|
||||
|
||||
activeAccount?.let {
|
||||
Log.d(TAG, "setActiveAccount: saving account with id " + it.id)
|
||||
it.isActive = false
|
||||
saveAccount(it)
|
||||
}
|
||||
|
||||
activeAccount = accounts.find { acc ->
|
||||
acc.id == accountId
|
||||
}
|
||||
|
||||
activeAccount?.let {
|
||||
it.isActive = true
|
||||
accountDao.insertOrReplace(it)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return an immutable list of all accounts in the database with the active account first
|
||||
*/
|
||||
suspend fun getAllAccounts(): List<AccountEntity> {
|
||||
|
||||
// TODO only load accounts on demand
|
||||
accounts = accountDao.loadAll().toMutableList()
|
||||
|
||||
activeAccount = accounts.find { acc ->
|
||||
acc.isActive
|
||||
}
|
||||
|
||||
return accounts.toList()
|
||||
}
|
||||
|
||||
/**
|
||||
* @return true if at least one account has notifications enabled
|
||||
*/
|
||||
fun areNotificationsEnabled(): Boolean {
|
||||
return accounts.any { it.notificationsEnabled }
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds an account by its database id
|
||||
* @param accountId the id of the account
|
||||
* @return the requested account or null if it was not found
|
||||
*/
|
||||
fun getAccountById(accountId: Long): AccountEntity? {
|
||||
return accounts.find { acc ->
|
||||
acc.id == accountId
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,16 @@
|
|||
package at.connyduck.pixelcat.db
|
||||
|
||||
import androidx.room.Database
|
||||
import androidx.room.RoomDatabase
|
||||
import at.connyduck.pixelcat.db.entitity.AccountEntity
|
||||
import at.connyduck.pixelcat.db.entitity.StatusEntity
|
||||
|
||||
|
||||
@Database(entities = [AccountEntity::class, StatusEntity::class], version = 1)
|
||||
abstract class AppDatabase : RoomDatabase() {
|
||||
|
||||
abstract fun accountDao(): AccountDao
|
||||
|
||||
abstract fun statusDao(): TimelineDao
|
||||
|
||||
}
|
|
@ -0,0 +1,57 @@
|
|||
package at.connyduck.pixelcat.db
|
||||
|
||||
|
||||
import androidx.room.TypeConverter
|
||||
import at.connyduck.pixelcat.model.Attachment
|
||||
import at.connyduck.pixelcat.model.Status
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.squareup.moshi.Types
|
||||
|
||||
import java.util.*
|
||||
|
||||
class Converters {
|
||||
|
||||
private val moshi = Moshi.Builder().build()
|
||||
|
||||
@TypeConverter
|
||||
fun visibilityToInt(visibility: Status.Visibility): String {
|
||||
return visibility.name
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
fun stringToVisibility(visibility: String): Status.Visibility {
|
||||
return Status.Visibility.valueOf(visibility)
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
fun attachmentListToJson(attachmentList: List<Attachment>?): String {
|
||||
val type = Types.newParameterizedType(
|
||||
List::class.java,
|
||||
Attachment::class.java
|
||||
)
|
||||
return moshi.adapter<List<Attachment>>(type).toJson(attachmentList)
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
fun jsonToAttachmentList(attachmentListJson: String?): List<Attachment>? {
|
||||
if(attachmentListJson == null) {
|
||||
return null
|
||||
}
|
||||
val type = Types.newParameterizedType(
|
||||
List::class.java,
|
||||
Attachment::class.java
|
||||
)
|
||||
return moshi.adapter<List<Attachment>>(type).fromJson(attachmentListJson)
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
fun dateToLong(date: Date): Long {
|
||||
return date.time
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
fun longToDate(date: Long): Date {
|
||||
return Date(date)
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,25 @@
|
|||
package at.connyduck.pixelcat.db
|
||||
|
||||
import androidx.paging.PagingSource
|
||||
import androidx.room.*
|
||||
import at.connyduck.pixelcat.db.entitity.StatusEntity
|
||||
|
||||
@Dao
|
||||
interface TimelineDao {
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertOrReplace(status: StatusEntity): Long
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertOrReplace(statuses: List<StatusEntity>)
|
||||
|
||||
@Delete
|
||||
suspend fun delete(status: StatusEntity)
|
||||
|
||||
@Query("SELECT * FROM StatusEntity WHERE accountId = :accountId ORDER BY LENGTH(id) DESC, id DESC")
|
||||
fun statuses(accountId: Long): PagingSource<Int, StatusEntity>
|
||||
|
||||
@Query("DELETE FROM StatusEntity WHERE accountId = :accountId")
|
||||
suspend fun clearAll(accountId: Long)
|
||||
|
||||
}
|
|
@ -0,0 +1,65 @@
|
|||
|
||||
|
||||
package at.connyduck.pixelcat.db.entitity
|
||||
|
||||
import androidx.room.*
|
||||
|
||||
@Entity(indices = [Index(value = ["domain", "accountId"],
|
||||
unique = true)])
|
||||
//@TypeConverters(Converters::class)
|
||||
data class AccountEntity(@field:PrimaryKey(autoGenerate = true) var id: Long,
|
||||
val domain: String,
|
||||
@Embedded(prefix = "auth_") var auth: AccountAuthData,
|
||||
var isActive: Boolean,
|
||||
var accountId: String = "",
|
||||
var username: String = "",
|
||||
var displayName: String = "",
|
||||
var profilePictureUrl: String = "",
|
||||
var notificationsEnabled: Boolean = true,
|
||||
var notificationsMentioned: Boolean = true,
|
||||
var notificationsFollowed: Boolean = true,
|
||||
var notificationsReblogged: Boolean = true,
|
||||
var notificationsFavorited: Boolean = true,
|
||||
var notificationSound: Boolean = true,
|
||||
var notificationVibration: Boolean = true,
|
||||
var notificationLight: Boolean = true,
|
||||
var defaultMediaSensitivity: Boolean = false,
|
||||
var alwaysShowSensitiveMedia: Boolean = false,
|
||||
var mediaPreviewEnabled: Boolean = true,
|
||||
var lastNotificationId: String = "0",
|
||||
var activeNotifications: String = "[]",
|
||||
var notificationsFilter: String = "[]") {
|
||||
|
||||
val identifier: String
|
||||
get() = "$domain:$accountId"
|
||||
|
||||
val fullName: String
|
||||
get() = "@$username@$domain"
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as AccountEntity
|
||||
|
||||
if (id == other.id) return true
|
||||
if (domain == other.domain && accountId == other.accountId) return true
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = id.hashCode()
|
||||
result = 31 * result + domain.hashCode()
|
||||
result = 31 * result + accountId.hashCode()
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
data class AccountAuthData(
|
||||
val accessToken: String,
|
||||
val refreshToken: String?,
|
||||
val tokenExpiresAt: Long,
|
||||
val clientId: String,
|
||||
val clientSecret: String
|
||||
)
|
|
@ -0,0 +1,27 @@
|
|||
package at.connyduck.pixelcat.db.entitity
|
||||
|
||||
import androidx.room.Entity
|
||||
import at.connyduck.pixelcat.model.Account
|
||||
|
||||
@Entity(
|
||||
primaryKeys = ["serverId", "timelineUserId"]
|
||||
)
|
||||
data class TimelineAccountEntity(
|
||||
val serverId: Long,
|
||||
val id: String,
|
||||
val localUsername: String,
|
||||
val username: String,
|
||||
val displayName: String,
|
||||
val url: String,
|
||||
val avatar: String
|
||||
)
|
||||
|
||||
fun Account.toEntity(serverId: Long) = TimelineAccountEntity(
|
||||
serverId = serverId,
|
||||
id = id,
|
||||
localUsername = localUsername,
|
||||
username = username,
|
||||
displayName = displayName,
|
||||
url = url,
|
||||
avatar = avatar
|
||||
)
|
|
@ -0,0 +1,54 @@
|
|||
|
||||
|
||||
package at.connyduck.pixelcat.db.entitity
|
||||
|
||||
import androidx.room.Embedded
|
||||
import androidx.room.Entity
|
||||
import androidx.room.TypeConverters
|
||||
import at.connyduck.pixelcat.db.Converters
|
||||
import at.connyduck.pixelcat.model.Attachment
|
||||
import at.connyduck.pixelcat.model.Status
|
||||
import java.util.*
|
||||
|
||||
@Entity(
|
||||
primaryKeys = ["accountId", "id"]
|
||||
)
|
||||
@TypeConverters(Converters::class)
|
||||
data class StatusEntity(
|
||||
val accountId: Long,
|
||||
val id: String,
|
||||
val url: String?, // not present if it's reblog
|
||||
@Embedded(prefix = "a_") val account: TimelineAccountEntity,
|
||||
val content: String,
|
||||
val createdAt: Date,
|
||||
val reblogsCount: Int,
|
||||
val favouritesCount: Int,
|
||||
val reblogged: Boolean,
|
||||
val favourited: Boolean,
|
||||
val sensitive: Boolean,
|
||||
val spoilerText: String,
|
||||
val visibility: Status.Visibility,
|
||||
val attachments: List<Attachment>,
|
||||
|
||||
val mediaPosition: Int,
|
||||
val mediaVisible: Boolean
|
||||
)
|
||||
|
||||
fun Status.toEntity(accountId: Long, mediaPosition: Int = 0, mediaVisible: Boolean = this.sensitive) = StatusEntity(
|
||||
accountId = accountId,
|
||||
id = id,
|
||||
url = actionableStatus.url,
|
||||
account = actionableStatus.account.toEntity(accountId),
|
||||
content = actionableStatus.content,
|
||||
createdAt = actionableStatus.createdAt,
|
||||
reblogsCount = actionableStatus.reblogsCount,
|
||||
favouritesCount = actionableStatus.favouritesCount,
|
||||
reblogged = actionableStatus.reblogged,
|
||||
favourited = actionableStatus.favourited,
|
||||
sensitive = actionableStatus.sensitive,
|
||||
spoilerText = actionableStatus.spoilerText,
|
||||
visibility = actionableStatus.visibility,
|
||||
attachments = actionableStatus.attachments,
|
||||
mediaPosition = mediaPosition,
|
||||
mediaVisible = mediaVisible
|
||||
)
|
|
@ -0,0 +1,12 @@
|
|||
package at.connyduck.pixelcat.model
|
||||
|
||||
import com.squareup.moshi.Json
|
||||
import com.squareup.moshi.JsonClass
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class AccessToken(
|
||||
@Json(name = "access_token") val accessToken: String,
|
||||
@Json(name = "refresh_token") val refreshToken: String?,
|
||||
@Json(name = "expires_in") val expiresIn: Long?,
|
||||
@Json(name = "created_at") val createdAt: Long?
|
||||
)
|
|
@ -0,0 +1,73 @@
|
|||
package at.connyduck.pixelcat.model
|
||||
|
||||
import android.os.Parcel
|
||||
import android.os.Parcelable
|
||||
import android.text.Spanned
|
||||
import androidx.core.text.HtmlCompat
|
||||
import com.squareup.moshi.Json
|
||||
import com.squareup.moshi.JsonClass
|
||||
import kotlinx.android.parcel.Parceler
|
||||
import kotlinx.android.parcel.Parcelize
|
||||
import java.util.*
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class Account(
|
||||
val id: String,
|
||||
@Json(name = "username") val localUsername: String,
|
||||
@Json(name = "acct") val username: String,
|
||||
@Json(name = "display_name") val displayName: String,
|
||||
val note: String,
|
||||
val url: String,
|
||||
val avatar: String,
|
||||
val header: String,
|
||||
val locked: Boolean = false,
|
||||
@Json(name = "followers_count") val followersCount: Int,
|
||||
@Json(name = "following_count") val followingCount: Int,
|
||||
@Json(name = "statuses_count") val statusesCount: Int,
|
||||
val source: AccountSource?,
|
||||
val bot: Boolean,
|
||||
// val emojis: List<Emoji>, // nullable for backward compatibility
|
||||
val fields: List<Field>?, //nullable for backward compatibility
|
||||
val moved: Account?
|
||||
|
||||
) {
|
||||
|
||||
val name: String
|
||||
get() = if (displayName.isEmpty()) {
|
||||
localUsername
|
||||
} else displayName
|
||||
|
||||
fun isRemote(): Boolean = this.username != this.localUsername
|
||||
}
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
@Parcelize
|
||||
data class AccountSource(
|
||||
// val privacy: Status.Visibility,
|
||||
val sensitive: Boolean,
|
||||
val note: String,
|
||||
val fields: List<StringField>?
|
||||
): Parcelable
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
@Parcelize
|
||||
data class Field (
|
||||
val name: String,
|
||||
// val value: @WriteWith<SpannedParceler>() Spanned,
|
||||
@Json(name = "verified_at") val verifiedAt: Date?
|
||||
): Parcelable
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
@Parcelize
|
||||
data class StringField (
|
||||
val name: String,
|
||||
val value: String
|
||||
): Parcelable
|
||||
|
||||
object SpannedParceler : Parceler<Spanned> {
|
||||
override fun create(parcel: Parcel): Spanned = HtmlCompat.fromHtml(parcel.readString() ?: "", HtmlCompat.FROM_HTML_SEPARATOR_LINE_BREAK_PARAGRAPH)
|
||||
|
||||
override fun Spanned.write(parcel: Parcel, flags: Int) {
|
||||
parcel.writeString(HtmlCompat.toHtml(this, HtmlCompat.TO_HTML_PARAGRAPH_LINES_INDIVIDUAL))
|
||||
}
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
package at.connyduck.pixelcat.model
|
||||
|
||||
import com.squareup.moshi.Json
|
||||
import com.squareup.moshi.JsonClass
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class AppCredentials(
|
||||
@Json(name = "client_id") val clientId: String,
|
||||
@Json(name = "client_secret") val clientSecret: String
|
||||
)
|
|
@ -0,0 +1,68 @@
|
|||
|
||||
package at.connyduck.pixelcat.model
|
||||
|
||||
import android.os.Parcelable
|
||||
import com.squareup.moshi.Json
|
||||
import com.squareup.moshi.JsonClass
|
||||
import kotlinx.android.parcel.Parcelize
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
@Parcelize
|
||||
data class Attachment(
|
||||
val id: String,
|
||||
val url: String,
|
||||
@Json(name = "preview_url") val previewUrl: String,
|
||||
val meta: MetaData?,
|
||||
val type: Type,
|
||||
val description: String?
|
||||
) : Parcelable {
|
||||
|
||||
enum class Type {
|
||||
@Json(name = "image")
|
||||
IMAGE,
|
||||
@Json(name = "gifv")
|
||||
GIFV,
|
||||
@Json(name = "video")
|
||||
VIDEO,
|
||||
@Json(name = "audio")
|
||||
AUDIO,
|
||||
@Json(name = "unknown")
|
||||
UNKNOWN
|
||||
}
|
||||
|
||||
/*class MediaTypeDeserializer : JsonDeserializer<Type> {
|
||||
@Throws(JsonParseException::class)
|
||||
override fun deserialize(json: JsonElement, classOfT: java.lang.reflect.Type, context: JsonDeserializationContext): Type {
|
||||
return when (json.toString()) {
|
||||
"\"image\"" -> Type.IMAGE
|
||||
"\"gifv\"" -> Type.GIFV
|
||||
"\"video\"" -> Type.VIDEO
|
||||
"\"audio\"" -> Type.AUDIO
|
||||
else -> Type.UNKNOWN
|
||||
}
|
||||
}
|
||||
}*/
|
||||
|
||||
/**
|
||||
* The meta data of an [Attachment].
|
||||
*/
|
||||
@JsonClass(generateAdapter = true)
|
||||
@Parcelize
|
||||
data class MetaData (
|
||||
val focus: Focus?,
|
||||
val duration: Float?
|
||||
) : Parcelable
|
||||
|
||||
/**
|
||||
* The Focus entity, used to specify the focal point of an image.
|
||||
*
|
||||
* See here for more details what the x and y mean:
|
||||
* https://github.com/jonom/jquery-focuspoint#1-calculate-your-images-focus-point
|
||||
*/
|
||||
@JsonClass(generateAdapter = true)
|
||||
@Parcelize
|
||||
data class Focus (
|
||||
val x: Float,
|
||||
val y: Float
|
||||
) : Parcelable
|
||||
}
|
|
@ -0,0 +1,15 @@
|
|||
package at.connyduck.pixelcat.model
|
||||
|
||||
import com.squareup.moshi.Json
|
||||
import com.squareup.moshi.JsonClass
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class NewStatus(
|
||||
val status: String,
|
||||
//@Json(name = "spoiler_text") val warningText: String,
|
||||
@Json(name = "in_reply_to_id") val inReplyToId: String?,
|
||||
val visibility: String,
|
||||
val sensitive: Boolean,
|
||||
@Json(name = "media_ids") val mediaIds: List<String>?
|
||||
)
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
package at.connyduck.pixelcat.model
|
||||
|
||||
import com.squareup.moshi.Json
|
||||
import com.squareup.moshi.JsonClass
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class Relationship (
|
||||
val id: String,
|
||||
val following: Boolean,
|
||||
@Json(name = "followed_by") val followedBy: Boolean,
|
||||
val blocking: Boolean,
|
||||
val muting: Boolean,
|
||||
val requested: Boolean,
|
||||
@Json(name = "showing_reblogs") val showingReblogs: Boolean
|
||||
)
|
|
@ -0,0 +1,78 @@
|
|||
|
||||
package at.connyduck.pixelcat.model
|
||||
|
||||
import com.squareup.moshi.Json
|
||||
import com.squareup.moshi.JsonClass
|
||||
import java.util.*
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class Status(
|
||||
val id: String,
|
||||
val url: String?, // not present if it's reblog
|
||||
val account: Account,
|
||||
@Json(name = "in_reply_to_id") val inReplyToId: String?,
|
||||
@Json(name = "in_reply_to_account_id") val inReplyToAccountId: String?,
|
||||
val reblog: Status?,
|
||||
val content: String,
|
||||
@Json(name = "created_at") val createdAt: Date,
|
||||
@Json(name = "reblogs_count") val reblogsCount: Int,
|
||||
@Json(name = "favourites_count") val favouritesCount: Int,
|
||||
val reblogged: Boolean,
|
||||
val favourited: Boolean,
|
||||
val sensitive: Boolean,
|
||||
@Json(name = "spoiler_text") val spoilerText: String,
|
||||
val visibility: Visibility,
|
||||
@Json(name = "media_attachments") val attachments: List<Attachment>,
|
||||
val mentions: List<Mention>,
|
||||
val application: Application?
|
||||
) {
|
||||
|
||||
val actionableId: String
|
||||
get() = reblog?.id ?: id
|
||||
|
||||
val actionableStatus: Status
|
||||
get() = reblog ?: this
|
||||
|
||||
enum class Visibility {
|
||||
UNKNOWN,
|
||||
@Json(name = "public")
|
||||
PUBLIC,
|
||||
@Json(name = "unlisted")
|
||||
UNLISTED,
|
||||
@Json(name = "private")
|
||||
PRIVATE,
|
||||
@Json(name = "direct")
|
||||
DIRECT
|
||||
}
|
||||
|
||||
fun rebloggingAllowed(): Boolean {
|
||||
return (visibility != Visibility.DIRECT && visibility != Visibility.UNKNOWN)
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other == null || javaClass != other.javaClass) return false
|
||||
|
||||
val status = other as Status?
|
||||
return id == status?.id
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
return id.hashCode()
|
||||
}
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class Mention (
|
||||
val id: String,
|
||||
val url: String,
|
||||
val acct: String,
|
||||
val username: String
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class Application (
|
||||
val name: String,
|
||||
val website: String?
|
||||
)
|
||||
|
||||
}
|
|
@ -0,0 +1,144 @@
|
|||
package at.connyduck.pixelcat.network
|
||||
|
||||
import at.connyduck.pixelcat.model.*
|
||||
import at.connyduck.pixelcat.network.calladapter.NetworkResponse
|
||||
import okhttp3.MultipartBody
|
||||
import retrofit2.http.*
|
||||
import retrofit2.http.Field
|
||||
|
||||
interface FediverseApi {
|
||||
|
||||
companion object {
|
||||
const val PLACEHOLDER_DOMAIN = "x.placeholder"
|
||||
const val DOMAIN_HEADER = "Domain"
|
||||
}
|
||||
|
||||
@FormUrlEncoded
|
||||
@POST("api/v1/apps")
|
||||
suspend fun authenticateAppAsync(
|
||||
@Header(DOMAIN_HEADER) domain: String,
|
||||
@Field("client_name") clientName: String,
|
||||
@Field("website") clientWebsite: String,
|
||||
@Field("redirect_uris") redirectUris: String,
|
||||
@Field("scopes") scopes: String
|
||||
): NetworkResponse<AppCredentials>
|
||||
|
||||
@FormUrlEncoded
|
||||
@POST("oauth/token")
|
||||
suspend fun fetchOAuthToken(
|
||||
@Header(DOMAIN_HEADER) domain: String,
|
||||
@Field("client_id") clientId: String,
|
||||
@Field("client_secret") clientSecret: String,
|
||||
@Field("redirect_uri") redirectUri: String,
|
||||
@Field("code") code: String,
|
||||
@Field("grant_type") grantType: String = "authorization_code"
|
||||
): NetworkResponse<AccessToken>
|
||||
|
||||
@FormUrlEncoded
|
||||
@POST("oauth/token")
|
||||
suspend fun refreshOAuthToken(
|
||||
@Header(DOMAIN_HEADER) domain: String,
|
||||
@Field("client_id") clientId: String,
|
||||
@Field("client_secret") clientSecret: String,
|
||||
@Field("refresh_token") refreshToken: String,
|
||||
@Field("grant_type") grantType: String = "refresh_token"
|
||||
): NetworkResponse<AccessToken>
|
||||
|
||||
|
||||
@GET("api/v1/accounts/verify_credentials")
|
||||
suspend fun accountVerifyCredentials(): NetworkResponse<Account>
|
||||
|
||||
@GET("api/v1/timelines/home")
|
||||
suspend fun homeTimeline(
|
||||
@Query("max_id") maxId: String? = null,
|
||||
@Query("since_id") sinceId: String? = null,
|
||||
@Query("limit") limit: Int? = null
|
||||
): NetworkResponse<List<Status>>
|
||||
|
||||
@GET("api/v1/accounts/{id}/statuses")
|
||||
suspend fun accountTimeline(
|
||||
@Path("id") accountId: String,
|
||||
@Query("max_id") maxId: String? = null,
|
||||
@Query("since_id") sinceId: String? = null,
|
||||
@Query("limit") limit: Int? = null,
|
||||
@Query("exclude_replies") excludeReplies: Boolean? = false,
|
||||
@Query("only_media") onlyMedia: Boolean? = true,
|
||||
@Query("pinned") pinned: Boolean? = false
|
||||
): NetworkResponse<Status>
|
||||
|
||||
@GET("api/v1/accounts/{id}")
|
||||
suspend fun account(
|
||||
@Path("id") accountId: String
|
||||
): NetworkResponse<Account>
|
||||
|
||||
@GET("api/v1/accounts/{id}/statuses")
|
||||
suspend fun accountStatuses(
|
||||
@Path("id") accountId: String,
|
||||
@Query("max_id") maxId: String? = null,
|
||||
@Query("since_id") sinceId: String? = null,
|
||||
@Query("limit") limit: Int? = null,
|
||||
@Query("only_media") onlyMedia: Boolean? = null,
|
||||
@Query("exclude_reblogs") excludeReblogs: Boolean? = null
|
||||
): NetworkResponse<List<Status>>
|
||||
|
||||
@FormUrlEncoded
|
||||
@POST("api/v1/accounts/{id}/follow")
|
||||
suspend fun followAccount(
|
||||
@Path("id") accountId: String,
|
||||
@Field("reblogs") showReblogs: Boolean
|
||||
): NetworkResponse<Relationship>
|
||||
|
||||
@POST("api/v1/accounts/{id}/unfollow")
|
||||
suspend fun unfollowAccount(
|
||||
@Path("id") accountId: String
|
||||
): NetworkResponse<Relationship>
|
||||
|
||||
@POST("api/v1/accounts/{id}/block")
|
||||
suspend fun blockAccount(
|
||||
@Path("id") accountId: String
|
||||
): NetworkResponse<Relationship>
|
||||
|
||||
@POST("api/v1/accounts/{id}/unblock")
|
||||
suspend fun unblockAccount(
|
||||
@Path("id") accountId: String
|
||||
): NetworkResponse<Relationship>
|
||||
|
||||
@POST("api/v1/accounts/{id}/mute")
|
||||
suspend fun muteAccount(
|
||||
@Path("id") accountId: String
|
||||
): NetworkResponse<Relationship>
|
||||
|
||||
@POST("api/v1/accounts/{id}/unmute")
|
||||
suspend fun unmuteAccount(
|
||||
@Path("id") accountId: String
|
||||
): NetworkResponse<Relationship>
|
||||
|
||||
@GET("api/v1/accounts/relationships")
|
||||
suspend fun relationships(
|
||||
@Query("id[]") accountIds: List<String>
|
||||
): NetworkResponse<List<Relationship>>
|
||||
|
||||
@Multipart
|
||||
@POST("api/v1/media")
|
||||
suspend fun uploadMedia(
|
||||
@Part file: MultipartBody.Part
|
||||
): NetworkResponse<Attachment>
|
||||
|
||||
@POST("api/v1/statuses")
|
||||
suspend fun createStatus(
|
||||
@Header("Authorization") auth: String,
|
||||
@Header(DOMAIN_HEADER) domain: String,
|
||||
@Header("Idempotency-Key") idempotencyKey: String,
|
||||
@Body status: NewStatus
|
||||
): NetworkResponse<Status>
|
||||
|
||||
@POST("api/v1/statuses/{id}/favourite")
|
||||
suspend fun favouriteStatus(
|
||||
@Path("id") statusId: String
|
||||
): NetworkResponse<Status>
|
||||
|
||||
@POST("api/v1/statuses/{id}/unfavourite")
|
||||
suspend fun unfavouriteStatus(
|
||||
@Path("id") statusId: String
|
||||
): NetworkResponse<Status>
|
||||
}
|
|
@ -0,0 +1,51 @@
|
|||
package at.connyduck.pixelcat.network
|
||||
|
||||
import at.connyduck.pixelcat.db.AccountManager
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
|
||||
import java.io.IOException
|
||||
|
||||
class InstanceSwitchAuthInterceptor(private val accountManager: AccountManager) : Interceptor {
|
||||
|
||||
@Throws(IOException::class)
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
|
||||
val originalRequest = chain.request()
|
||||
|
||||
// only switch domains if the request comes from retrofit
|
||||
if (originalRequest.url.host == FediverseApi.PLACEHOLDER_DOMAIN) {
|
||||
|
||||
val currentAccount = runBlocking { accountManager.activeAccount() }
|
||||
val builder = originalRequest.newBuilder()
|
||||
|
||||
val instanceHeader = originalRequest.header(FediverseApi.DOMAIN_HEADER)
|
||||
if (instanceHeader != null) {
|
||||
// use domain explicitly specified in custom header
|
||||
builder.url(swapHost(originalRequest.url, instanceHeader))
|
||||
builder.removeHeader(FediverseApi.DOMAIN_HEADER)
|
||||
} else if (currentAccount != null) {
|
||||
//use domain of current account
|
||||
builder.url(swapHost(originalRequest.url, currentAccount.domain))
|
||||
.header(
|
||||
"Authorization",
|
||||
"Bearer ${currentAccount.auth.accessToken}"
|
||||
)
|
||||
}
|
||||
val newRequest = builder.build()
|
||||
|
||||
return chain.proceed(newRequest)
|
||||
|
||||
} else {
|
||||
return chain.proceed(originalRequest)
|
||||
}
|
||||
}
|
||||
|
||||
private fun swapHost(url: HttpUrl, host: String): HttpUrl {
|
||||
return url.newBuilder().host(host).build()
|
||||
}
|
||||
|
||||
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
package at.connyduck.pixelcat.network
|
||||
|
||||
import at.connyduck.pixelcat.db.AccountManager
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import okhttp3.Authenticator
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.Route
|
||||
|
||||
class RefreshTokenAuthenticator(private val accountManager: AccountManager) : Authenticator {
|
||||
override fun authenticate(route: Route?, response: Response): Request? {
|
||||
|
||||
|
||||
val currentAccount = runBlocking { accountManager.activeAccount() }
|
||||
|
||||
|
||||
// TODO
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
package at.connyduck.pixelcat.network
|
||||
|
||||
import android.os.Build
|
||||
import at.connyduck.pixelcat.BuildConfig
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
|
||||
class UserAgentInterceptor: Interceptor {
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val requestWithUserAgent = chain.request()
|
||||
.newBuilder()
|
||||
.header("User-Agent", "PixelCat/${BuildConfig.VERSION_NAME} Android/${Build.VERSION.RELEASE}")
|
||||
.build()
|
||||
return chain.proceed(requestWithUserAgent)
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,16 @@
|
|||
package at.connyduck.pixelcat.network.calladapter
|
||||
|
||||
import retrofit2.Call
|
||||
import retrofit2.CallAdapter
|
||||
import java.lang.reflect.Type
|
||||
|
||||
class NetworkCallAdapter<S : Any>(
|
||||
private val successType: Type
|
||||
) : CallAdapter<S, Call<NetworkResponse<S>>> {
|
||||
|
||||
override fun responseType(): Type = successType
|
||||
|
||||
override fun adapt(call: Call<S>): Call<NetworkResponse<S>> {
|
||||
return NetworkResponseCall(call)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,30 @@
|
|||
package at.connyduck.pixelcat.network.calladapter
|
||||
|
||||
import java.io.IOException
|
||||
|
||||
sealed class NetworkResponse<out A : Any> {
|
||||
|
||||
data class Success<T : Any>(val body: T) : NetworkResponse<T>()
|
||||
|
||||
data class Failure(val reason: NetworkResponseError) : NetworkResponse<Nothing>()
|
||||
|
||||
inline fun <B> fold(onSuccess: (A) -> B, onFailure: (NetworkResponseError) -> B): B = when (this) {
|
||||
is Success -> onSuccess(body)
|
||||
is Failure -> onFailure(reason)
|
||||
}
|
||||
}
|
||||
|
||||
sealed class NetworkResponseError: Throwable() {
|
||||
|
||||
data class ApiError(val code: Int) : NetworkResponseError()
|
||||
|
||||
/**
|
||||
* Network error
|
||||
*/
|
||||
data class NetworkError(val error: IOException) : NetworkResponseError()
|
||||
|
||||
/**
|
||||
* For example, json parsing error
|
||||
*/
|
||||
data class UnknownError(val error: Throwable?) : NetworkResponseError()
|
||||
}
|
|
@ -0,0 +1,41 @@
|
|||
package at.connyduck.pixelcat.network.calladapter
|
||||
|
||||
import retrofit2.Call
|
||||
import retrofit2.CallAdapter
|
||||
import retrofit2.Retrofit
|
||||
import java.lang.reflect.ParameterizedType
|
||||
import java.lang.reflect.Type
|
||||
|
||||
class NetworkResponseAdapterFactory : CallAdapter.Factory() {
|
||||
|
||||
override fun get(
|
||||
returnType: Type,
|
||||
annotations: Array<Annotation>,
|
||||
retrofit: Retrofit
|
||||
): CallAdapter<*, *>? {
|
||||
|
||||
// suspend functions wrap the response type in `Call`
|
||||
if (Call::class.java != getRawType(returnType)) {
|
||||
return null
|
||||
}
|
||||
|
||||
// check first that the return type is `ParameterizedType`
|
||||
check(returnType is ParameterizedType) {
|
||||
"return type must be parameterized as Call<NetworkResponse<<Foo>> or Call<NetworkResponse<out Foo>>"
|
||||
}
|
||||
|
||||
// get the response type inside the `Call` type
|
||||
val responseType = getParameterUpperBound(0, returnType)
|
||||
// if the response type is not ApiResponse then we can't handle this type, so we return null
|
||||
if (getRawType(responseType) != NetworkResponse::class.java) {
|
||||
return null
|
||||
}
|
||||
|
||||
// the response type is ApiResponse and should be parameterized
|
||||
check(responseType is ParameterizedType) { "Response must be parameterized as NetworkResponse<Foo> or NetworkResponse<out Foo>" }
|
||||
|
||||
val successBodyType = getParameterUpperBound(0, responseType)
|
||||
|
||||
return NetworkCallAdapter<Any>(successBodyType)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,68 @@
|
|||
package at.connyduck.pixelcat.network.calladapter
|
||||
|
||||
import android.util.Log
|
||||
import okhttp3.Request
|
||||
import okio.Timeout
|
||||
import retrofit2.Call
|
||||
import retrofit2.Callback
|
||||
import retrofit2.Response
|
||||
import java.io.IOException
|
||||
|
||||
internal class NetworkResponseCall<S : Any>(
|
||||
private val delegate: Call<S>
|
||||
) : Call<NetworkResponse<S>> {
|
||||
|
||||
override fun enqueue(callback: Callback<NetworkResponse<S>>) {
|
||||
return delegate.enqueue(object : Callback<S> {
|
||||
override fun onResponse(call: Call<S>, response: Response<S>) {
|
||||
val body = response.body()
|
||||
|
||||
val errorbody = response.errorBody()?.string()
|
||||
if (response.isSuccessful) {
|
||||
if (body != null) {
|
||||
callback.onResponse(
|
||||
this@NetworkResponseCall,
|
||||
Response.success(NetworkResponse.Success(body))
|
||||
)
|
||||
} else {
|
||||
// Response is successful but the body is null
|
||||
callback.onResponse(
|
||||
this@NetworkResponseCall,
|
||||
Response.success(NetworkResponse.Failure(NetworkResponseError.ApiError(response.code())))
|
||||
)
|
||||
}
|
||||
} else {
|
||||
callback.onResponse(
|
||||
this@NetworkResponseCall,
|
||||
Response.success(NetworkResponse.Failure(NetworkResponseError.ApiError(response.code())))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFailure(call: Call<S>, throwable: Throwable) {
|
||||
Log.d("NetworkResponseCall", "Network response failed", throwable)
|
||||
val networkResponse = when (throwable) {
|
||||
is IOException -> NetworkResponse.Failure(NetworkResponseError.NetworkError(throwable))
|
||||
else -> NetworkResponse.Failure(NetworkResponseError.UnknownError(throwable))
|
||||
}
|
||||
callback.onResponse(this@NetworkResponseCall, Response.success(networkResponse))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
override fun isExecuted() = delegate.isExecuted
|
||||
|
||||
override fun clone() = NetworkResponseCall(delegate.clone())
|
||||
|
||||
override fun isCanceled() = delegate.isCanceled
|
||||
|
||||
override fun cancel() = delegate.cancel()
|
||||
|
||||
override fun execute(): Response<NetworkResponse<S>> {
|
||||
throw UnsupportedOperationException("NetworkResponseCall doesn't support synchronized execution")
|
||||
}
|
||||
|
||||
override fun request(): Request = delegate.request()
|
||||
|
||||
override fun timeout(): Timeout = delegate.timeout()
|
||||
}
|
|
@ -0,0 +1,20 @@
|
|||
package at.connyduck.pixelcat.util
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.fragment.app.Fragment
|
||||
import java.lang.IllegalStateException
|
||||
|
||||
inline fun <reified T> Fragment.arg(key: String): T {
|
||||
val value = arguments?.get(key)
|
||||
|
||||
if(value !is T) {
|
||||
throw IllegalStateException("Argument $key is of wrong type")
|
||||
}
|
||||
return value
|
||||
|
||||
}
|
||||
|
||||
inline fun Fragment.withArgs(argsBuilder: Bundle.() -> Unit): Fragment {
|
||||
this.arguments = Bundle().apply(argsBuilder)
|
||||
return this
|
||||
}
|
|
@ -0,0 +1,62 @@
|
|||
package at.connyduck.pixelcat.util
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.Observer
|
||||
import androidx.viewbinding.ViewBinding
|
||||
import kotlin.properties.ReadOnlyProperty
|
||||
import kotlin.reflect.KProperty
|
||||
|
||||
/**
|
||||
* https://medium.com/@Zhuinden/simple-one-liner-viewbinding-in-fragments-and-activities-with-kotlin-961430c6c07c
|
||||
*/
|
||||
|
||||
inline fun <T : ViewBinding> AppCompatActivity.viewBinding(
|
||||
crossinline bindingInflater: (LayoutInflater) -> T) =
|
||||
lazy(LazyThreadSafetyMode.NONE) {
|
||||
bindingInflater(layoutInflater)
|
||||
}
|
||||
|
||||
class FragmentViewBindingDelegate<T : ViewBinding>(
|
||||
val fragment: Fragment,
|
||||
val viewBindingFactory: (View) -> T
|
||||
) : ReadOnlyProperty<Fragment, T> {
|
||||
private var binding: T? = null
|
||||
|
||||
init {
|
||||
fragment.lifecycle.addObserver(object : DefaultLifecycleObserver {
|
||||
override fun onCreate(owner: LifecycleOwner) {
|
||||
fragment.viewLifecycleOwnerLiveData.observe(fragment,
|
||||
Observer { t ->
|
||||
t?.lifecycle?.addObserver(object : DefaultLifecycleObserver {
|
||||
override fun onDestroy(owner: LifecycleOwner) {
|
||||
binding = null
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
override fun getValue(thisRef: Fragment, property: KProperty<*>): T {
|
||||
val binding = binding
|
||||
if (binding != null) {
|
||||
return binding
|
||||
}
|
||||
|
||||
val lifecycle = fragment.viewLifecycleOwner.lifecycle
|
||||
if (!lifecycle.currentState.isAtLeast(Lifecycle.State.INITIALIZED)) {
|
||||
throw IllegalStateException("Should not attempt to get bindings when Fragment views are destroyed.")
|
||||
}
|
||||
|
||||
return viewBindingFactory(thisRef.requireView()).also { this@FragmentViewBindingDelegate.binding = it }
|
||||
}
|
||||
}
|
||||
|
||||
fun <T : ViewBinding> Fragment.viewBinding(viewBindingFactory: (View) -> T) =
|
||||
FragmentViewBindingDelegate(this, viewBindingFactory)
|
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<alpha xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:interpolator="@android:anim/linear_interpolator"
|
||||
android:fromAlpha="0"
|
||||
android:toAlpha="1"
|
||||
android:duration="300" />
|
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<alpha xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:interpolator="@android:anim/linear_interpolator"
|
||||
android:fromAlpha="1"
|
||||
android:toAlpha="0"
|
||||
android:duration="300" />
|
|
@ -0,0 +1,7 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<corners
|
||||
android:topLeftRadius="12dp"
|
||||
android:topRightRadius="12dp" />
|
||||
<solid android:color="#f0f" />
|
||||
</shape>
|
|
@ -0,0 +1,33 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:color="?attr/colorPrimary">
|
||||
|
||||
<!-- mask here... -->
|
||||
<item android:id="@android:id/mask">
|
||||
<shape>
|
||||
<corners android:radius="10dp" />
|
||||
<solid android:color="#000" />
|
||||
</shape>
|
||||
</item>
|
||||
|
||||
<item>
|
||||
|
||||
<selector>
|
||||
<item android:state_selected="true">
|
||||
<shape>
|
||||
<corners android:radius="10dp" />
|
||||
<solid android:color="?attr/colorPrimary" />
|
||||
</shape>
|
||||
</item>
|
||||
<item android:state_selected="false">
|
||||
<shape>
|
||||
<corners android:radius="10dp" />
|
||||
<solid android:color="@color/transparent" />
|
||||
</shape>
|
||||
</item>
|
||||
|
||||
</selector>
|
||||
</item>
|
||||
|
||||
|
||||
</ripple>
|
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<corners android:radius="6dp"/>
|
||||
<solid android:color="?android:attr/textColorTertiary"/>
|
||||
</shape>
|
|
@ -0,0 +1,8 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:state_selected="false"
|
||||
android:drawable="@drawable/ic_heart" />
|
||||
|
||||
<item android:state_selected="true"
|
||||
android:drawable="@drawable/ic_heart_filled" />
|
||||
</selector>
|
|
@ -0,0 +1,11 @@
|
|||
<vector android:autoMirrored="true" android:height="24dp"
|
||||
android:viewportHeight="24" android:viewportWidth="24"
|
||||
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<path android:fillColor="#00000000" android:pathData="M19,12L5,12"
|
||||
android:strokeColor="?attr/colorControlNormal" android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round" android:strokeWidth="2"/>
|
||||
<path android:fillColor="#00000000"
|
||||
android:pathData="M12,19l-7,-7l7,-7"
|
||||
android:strokeColor="?attr/colorControlNormal" android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round" android:strokeWidth="2"/>
|
||||
</vector>
|
|
@ -0,0 +1,11 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:height="108dp"
|
||||
android:viewportHeight="512"
|
||||
android:viewportWidth="512"
|
||||
android:width="108dp">
|
||||
<path android:fillAlpha="1"
|
||||
android:fillColor="#ffffff"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="m158.563,143.937c-3.271,-0.001 -6.757,0.208 -9.375,3.184 -9.386,12.825 -2.432,73.438 -0.018,92.197 -9.755,11.711 -15.287,28.342 -15.463,42.721l-38.051,-0.857c-16.954,-0.641 -19.318,24.499 -0.768,24.914l44.299,0.992c0.719,1.527 1.5,3.041 2.338,4.537l-43.395,15.145c-15.722,5.115 -8.814,28.747 7.953,23.627l51.379,-17.834c23.008,22.257 59.597,35.425 98.537,35.463 39.112,0.158 -39.147,-0.239 0,0 38.94,-0.037 75.529,-13.206 98.537,-35.463l51.379,17.834c16.767,5.12 23.675,-18.512 7.953,-23.627l-43.395,-15.145c0.838,-1.496 1.619,-3.01 2.338,-4.537l44.299,-0.992c18.551,-0.415 16.187,-25.555 -0.768,-24.914l-38.051,0.857c-0.176,-14.378 -5.708,-31.01 -15.463,-42.721 2.414,-18.759 9.369,-79.372 -0.018,-92.197 -2.618,-2.976 -6.104,-3.184 -9.375,-3.184 -11.681,0.003 -25.677,11.239 -36.646,21.975l-29.117,31.826c-11.292,-2.122 -20.76,-3.331 -31.674,-3.32 -10.847,0.326 -22.443,1.656 -31.674,3.32l-29.117,-31.826c-10.97,-10.735 -24.965,-21.972 -36.646,-21.975zM201.924,257.189c9.475,0.007 17.213,7.576 17.432,17.049 -0.198,9.488 -7.942,17.078 -17.432,17.086 -9.474,-0.004 -17.213,-7.566 -17.438,-17.037 0.193,-9.494 7.942,-17.093 17.438,-17.098zM310.076,257.189c9.496,0.005 17.245,7.604 17.438,17.098 -0.224,9.471 -7.964,17.033 -17.438,17.037 -9.49,-0.008 -17.234,-7.598 -17.432,-17.086 0.219,-9.472 7.957,-17.042 17.432,-17.049z"
|
||||
android:strokeWidth="1.0"/>
|
||||
</vector>
|
|
@ -0,0 +1,22 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:height="64dp"
|
||||
android:viewportHeight="512"
|
||||
android:viewportWidth="500"
|
||||
android:width="64dp">
|
||||
<path android:fillAlpha="1"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="m158.563,143.937c-3.271,-0.001 -6.757,0.208 -9.375,3.184 -9.386,12.825 -2.432,73.438 -0.018,92.197 -9.755,11.711 -15.287,28.342 -15.463,42.721l-38.051,-0.857c-16.954,-0.641 -19.318,24.499 -0.768,24.914l44.299,0.992c0.719,1.527 1.5,3.041 2.338,4.537l-43.395,15.145c-15.722,5.115 -8.814,28.747 7.953,23.627l51.379,-17.834c23.008,22.257 59.597,35.425 98.537,35.463 39.112,0.158 -39.147,-0.239 0,0 38.94,-0.037 75.529,-13.206 98.537,-35.463l51.379,17.834c16.767,5.12 23.675,-18.512 7.953,-23.627l-43.395,-15.145c0.838,-1.496 1.619,-3.01 2.338,-4.537l44.299,-0.992c18.551,-0.415 16.187,-25.555 -0.768,-24.914l-38.051,0.857c-0.176,-14.378 -5.708,-31.01 -15.463,-42.721 2.414,-18.759 9.369,-79.372 -0.018,-92.197 -2.618,-2.976 -6.104,-3.184 -9.375,-3.184 -11.681,0.003 -25.677,11.239 -36.646,21.975l-29.117,31.826c-11.292,-2.122 -20.76,-3.331 -31.674,-3.32 -10.847,0.326 -22.443,1.656 -31.674,3.32l-29.117,-31.826c-10.97,-10.735 -24.965,-21.972 -36.646,-21.975zM201.924,257.189c9.475,0.007 17.213,7.576 17.432,17.049 -0.198,9.488 -7.942,17.078 -17.432,17.086 -9.474,-0.004 -17.213,-7.566 -17.438,-17.037 0.193,-9.494 7.942,-17.093 17.438,-17.098zM310.076,257.189c9.496,0.005 17.245,7.604 17.438,17.098 -0.224,9.471 -7.964,17.033 -17.438,17.037 -9.49,-0.008 -17.234,-7.598 -17.432,-17.086 0.219,-9.472 7.957,-17.042 17.432,-17.049z"
|
||||
android:strokeWidth="1.0">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:endColor="?attr/pixelcat_gradient_color_start"
|
||||
android:startColor="?attr/pixelcat_gradient_color_end"
|
||||
android:endX="500"
|
||||
android:endY="0"
|
||||
android:startX="0"
|
||||
android:startY="0"
|
||||
android:type="linear"/>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
</vector>
|
|
@ -0,0 +1,9 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24.0"
|
||||
android:viewportHeight="24.0">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M3,13h8L11,3L3,3v10zM3,21h8v-6L3,15v6zM13,21h8L21,11h-8v10zM13,3v6h8L21,3h-8z"/>
|
||||
</vector>
|
|
@ -0,0 +1,13 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:pathData="M20.84,4.61a5.5,5.5 0,0 0,-7.78 0L12,5.67l-1.06,-1.06a5.5,5.5 0,0 0,-7.78 7.78l1.06,1.06L12,21.23l7.78,-7.78 1.06,-1.06a5.5,5.5 0,0 0,0 -7.78z"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="2"
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="?android:attr/textColorTertiary"
|
||||
android:strokeLineCap="round"/>
|
||||
</vector>
|
|
@ -0,0 +1,13 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:pathData="M20.84,4.61a5.5,5.5 0,0 0,-7.78 0L12,5.67l-1.06,-1.06a5.5,5.5 0,0 0,-7.78 7.78l1.06,1.06L12,21.23l7.78,-7.78 1.06,-1.06a5.5,5.5 0,0 0,0 -7.78z"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="2"
|
||||
android:fillColor="#f00"
|
||||
android:strokeColor="#f00"
|
||||
android:strokeLineCap="round"/>
|
||||
</vector>
|
|
@ -0,0 +1,20 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:pathData="M3,9l9,-7 9,7v11a2,2 0,0 1,-2 2H5a2,2 0,0 1,-2 -2z"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="2"
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#000"
|
||||
android:strokeLineCap="round"/>
|
||||
<path
|
||||
android:pathData="M9,22l0,-10l6,0l0,10"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="2"
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#000"
|
||||
android:strokeLineCap="round"/>
|
||||
</vector>
|
|
@ -0,0 +1,9 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24.0"
|
||||
android:viewportHeight="24.0">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M10,20v-6h4v6h5v-8h3L12,3 2,12h3v8z"/>
|
||||
</vector>
|
|
@ -0,0 +1,27 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:pathData="M12,12m-10,0a10,10 0,1 1,20 0a10,10 0,1 1,-20 0"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="2"
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#fff"
|
||||
android:strokeLineCap="round"/>
|
||||
<path
|
||||
android:pathData="M12,16L12,12"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="2"
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#fff"
|
||||
android:strokeLineCap="round"/>
|
||||
<path
|
||||
android:pathData="M12,8L12,8"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="2"
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#fff"
|
||||
android:strokeLineCap="round"/>
|
||||
</vector>
|
|
@ -0,0 +1,10 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle" >
|
||||
<gradient
|
||||
android:angle="0"
|
||||
android:endColor="@color/pixelcat_gradient_warm_end"
|
||||
android:centerColor="#E94057"
|
||||
android:startColor="@color/pixelcat_gradient_warm_start"
|
||||
android:type="linear" />
|
||||
</shape>
|
|
@ -0,0 +1,11 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:height="108dp"
|
||||
android:viewportHeight="512"
|
||||
android:viewportWidth="512"
|
||||
android:width="108dp">
|
||||
<path android:fillAlpha="1"
|
||||
android:fillColor="#ffffff"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="m158.563,143.937c-3.271,-0.001 -6.757,0.208 -9.375,3.184 -9.386,12.825 -2.432,73.438 -0.018,92.197 -9.755,11.711 -15.287,28.342 -15.463,42.721l-38.051,-0.857c-16.954,-0.641 -19.318,24.499 -0.768,24.914l44.299,0.992c0.719,1.527 1.5,3.041 2.338,4.537l-43.395,15.145c-15.722,5.115 -8.814,28.747 7.953,23.627l51.379,-17.834c23.008,22.257 59.597,35.425 98.537,35.463 39.112,0.158 -39.147,-0.239 0,0 38.94,-0.037 75.529,-13.206 98.537,-35.463l51.379,17.834c16.767,5.12 23.675,-18.512 7.953,-23.627l-43.395,-15.145c0.838,-1.496 1.619,-3.01 2.338,-4.537l44.299,-0.992c18.551,-0.415 16.187,-25.555 -0.768,-24.914l-38.051,0.857c-0.176,-14.378 -5.708,-31.01 -15.463,-42.721 2.414,-18.759 9.369,-79.372 -0.018,-92.197 -2.618,-2.976 -6.104,-3.184 -9.375,-3.184 -11.681,0.003 -25.677,11.239 -36.646,21.975l-29.117,31.826c-11.292,-2.122 -20.76,-3.331 -31.674,-3.32 -10.847,0.326 -22.443,1.656 -31.674,3.32l-29.117,-31.826c-10.97,-10.735 -24.965,-21.972 -36.646,-21.975zM201.924,257.189c9.475,0.007 17.213,7.576 17.432,17.049 -0.198,9.488 -7.942,17.078 -17.432,17.086 -9.474,-0.004 -17.213,-7.566 -17.438,-17.037 0.193,-9.494 7.942,-17.093 17.438,-17.098zM310.076,257.189c9.496,0.005 17.245,7.604 17.438,17.098 -0.224,9.471 -7.964,17.033 -17.438,17.037 -9.49,-0.008 -17.234,-7.598 -17.432,-17.086 0.219,-9.472 7.957,-17.042 17.432,-17.049z"
|
||||
android:strokeWidth="1.0"/>
|
||||
</vector>
|
|
@ -0,0 +1,13 @@
|
|||
<vector android:autoMirrored="true" android:height="24dp"
|
||||
android:viewportHeight="24" android:viewportWidth="24"
|
||||
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<path android:fillColor="#00000000" android:pathData="M3,12L21,12"
|
||||
android:strokeColor="?attr/colorControlNormal" android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round" android:strokeWidth="2"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M3,6L21,6"
|
||||
android:strokeColor="?attr/colorControlNormal" android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round" android:strokeWidth="2"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M3,18L21,18"
|
||||
android:strokeColor="?attr/colorControlNormal" android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round" android:strokeWidth="2"/>
|
||||
</vector>
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue