Merge branch 'view_model_fixes' into 'master'

Tablet mode

Closes #389

See merge request pixeldroid/PixelDroid!593
This commit is contained in:
Matthieu 2024-08-11 15:38:55 +00:00
commit 579c75417d
43 changed files with 1664 additions and 804 deletions

View File

@ -46,7 +46,7 @@ android {
defaultConfig { defaultConfig {
minSdkVersion 23 minSdkVersion 23
targetSdkVersion 34 targetSdkVersion 34
versionCode 33 versionCode 34
versionName "1.0.beta" + versionCode versionName "1.0.beta" + versionCode
//TODO add resConfigs("en", "fr", "ja",...) ? //TODO add resConfigs("en", "fr", "ja",...) ?
@ -164,15 +164,17 @@ android {
dependencies { dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar']) implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation 'androidx.hilt:hilt-common:1.2.0'
implementation 'androidx.hilt:hilt-work:1.2.0'
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.4' coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.4'
/** /**
* AndroidX dependencies: * AndroidX dependencies:
*/ */
implementation 'androidx.appcompat:appcompat:1.6.1' implementation 'androidx.appcompat:appcompat:1.7.0'
implementation 'androidx.core:core-splashscreen:1.0.1' implementation 'androidx.core:core-splashscreen:1.0.1'
implementation 'androidx.core:core-ktx:1.12.0' implementation 'androidx.core:core-ktx:1.13.1'
implementation 'androidx.preference:preference-ktx:1.2.1' implementation 'androidx.preference:preference-ktx:1.2.1'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4' implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation 'androidx.navigation:navigation-fragment-ktx:2.7.7' implementation 'androidx.navigation:navigation-fragment-ktx:2.7.7'
@ -182,23 +184,23 @@ dependencies {
implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.1.0" implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.1.0"
implementation 'androidx.navigation:navigation-fragment-ktx:2.7.7' implementation 'androidx.navigation:navigation-fragment-ktx:2.7.7'
implementation 'androidx.navigation:navigation-ui-ktx:2.7.7' implementation 'androidx.navigation:navigation-ui-ktx:2.7.7'
implementation 'androidx.paging:paging-runtime-ktx:3.2.1' implementation 'androidx.paging:paging-runtime-ktx:3.3.2'
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.7.0' implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.8.4'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0' implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.4'
implementation 'androidx.lifecycle:lifecycle-viewmodel-savedstate:2.7.0' implementation 'androidx.lifecycle:lifecycle-viewmodel-savedstate:2.8.4'
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.7.0' implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.8.4'
implementation "androidx.lifecycle:lifecycle-common-java8:2.7.0" implementation "androidx.lifecycle:lifecycle-common-java8:2.8.4"
implementation "androidx.annotation:annotation:1.7.1" implementation "androidx.annotation:annotation:1.8.2"
implementation 'androidx.gridlayout:gridlayout:1.0.0' implementation 'androidx.gridlayout:gridlayout:1.0.0'
implementation "androidx.activity:activity-ktx:1.8.2" implementation "androidx.activity:activity-ktx:1.9.1"
implementation 'androidx.fragment:fragment-ktx:1.6.2' implementation 'androidx.fragment:fragment-ktx:1.8.2'
implementation 'androidx.work:work-runtime-ktx:2.9.0' implementation 'androidx.work:work-runtime-ktx:2.9.1'
implementation 'androidx.media2:media2-widget:1.3.0' implementation 'androidx.media2:media2-widget:1.3.0'
implementation 'androidx.media2:media2-player:1.3.0' implementation 'androidx.media2:media2-player:1.3.0'
// Use the most recent version of CameraX // Use the most recent version of CameraX
def cameraX_version = '1.3.2' def cameraX_version = '1.3.4'
implementation "androidx.camera:camera-core:$cameraX_version" implementation "androidx.camera:camera-core:$cameraX_version"
implementation "androidx.camera:camera-camera2:$cameraX_version" implementation "androidx.camera:camera-camera2:$cameraX_version"
// CameraX Lifecycle library // CameraX Lifecycle library
@ -220,11 +222,11 @@ dependencies {
implementation 'com.davemorrissey.labs:subsampling-scale-image-view-androidx:3.10.0' implementation 'com.davemorrissey.labs:subsampling-scale-image-view-androidx:3.10.0'
implementation 'com.google.android.material:material:1.11.0' implementation 'com.google.android.material:material:1.12.0'
//Dagger (dependency injection) //Dagger (dependency injection)
implementation 'com.google.dagger:dagger:2.51' implementation 'com.google.dagger:dagger:2.51.1'
ksp 'com.google.dagger:dagger-compiler:2.51' ksp 'com.google.dagger:dagger-compiler:2.51.1'
implementation('com.google.dagger:hilt-android:2.51') implementation('com.google.dagger:hilt-android:2.51')
ksp 'com.google.dagger:hilt-compiler:2.51' ksp 'com.google.dagger:hilt-compiler:2.51'
@ -237,7 +239,7 @@ dependencies {
implementation 'io.reactivex.rxjava3:rxandroid:3.0.2' implementation 'io.reactivex.rxjava3:rxandroid:3.0.2'
implementation 'com.github.connyduck:sparkbutton:4.1.0' implementation 'com.github.connyduck:sparkbutton:4.1.0'
implementation 'org.pixeldroid.pixeldroid:android-media-editor:2.0' implementation 'org.pixeldroid.pixeldroid:android-media-editor:3.1'
implementation project(path: ':scrambler') implementation project(path: ':scrambler')
implementation project(path: ':pixel_common') implementation project(path: ':pixel_common')
@ -260,12 +262,6 @@ dependencies {
// Add for NavController support // Add for NavController support
implementation 'com.mikepenz:materialdrawer-nav:9.0.2' implementation 'com.mikepenz:materialdrawer-nav:9.0.2'
//iconics
implementation 'com.mikepenz:iconics-core:5.4.0'
implementation 'com.mikepenz:materialdrawer-iconics:9.0.2'
implementation 'com.mikepenz:iconics-views:5.4.0'
implementation 'com.mikepenz:google-material-typeface:4.0.0.2-kotlin@aar'
implementation 'com.github.ligi:tracedroid:4.1' implementation 'com.github.ligi:tracedroid:4.1'
implementation 'me.relex:circleindicator:2.1.6' implementation 'me.relex:circleindicator:2.1.6'
@ -280,7 +276,7 @@ dependencies {
androidTestImplementation 'com.linkedin.testbutler:test-butler-library:2.2.1' androidTestImplementation 'com.linkedin.testbutler:test-butler-library:2.2.1'
androidTestUtil 'com.linkedin.testbutler:test-butler-app:2.2.1' androidTestUtil 'com.linkedin.testbutler:test-butler-app:2.2.1'
androidTestImplementation 'androidx.work:work-testing:2.9.0' androidTestImplementation 'androidx.work:work-testing:2.9.1'
testImplementation 'com.github.tomakehurst:wiremock-jre8:2.34.0' testImplementation 'com.github.tomakehurst:wiremock-jre8:2.34.0'
testImplementation "com.nhaarman.mockitokotlin2:mockito-kotlin:2.2.0" testImplementation "com.nhaarman.mockitokotlin2:mockito-kotlin:2.2.0"
testImplementation 'junit:junit:4.13.2' testImplementation 'junit:junit:4.13.2'
@ -288,13 +284,13 @@ dependencies {
androidTestImplementation 'androidx.test.uiautomator:uiautomator:2.3.0' androidTestImplementation 'androidx.test.uiautomator:uiautomator:2.3.0'
androidTestImplementation 'androidx.test:runner:1.5.2' androidTestImplementation 'androidx.test:runner:1.6.1'
androidTestImplementation 'androidx.test:rules:1.5.0' androidTestImplementation 'androidx.test:rules:1.6.1'
androidTestImplementation 'androidx.test.ext:junit:1.1.5' androidTestImplementation 'androidx.test.ext:junit:1.2.1'
androidTestImplementation 'androidx.test:runner:1.5.2' androidTestImplementation 'androidx.test:runner:1.6.1'
androidTestImplementation 'androidx.test:rules:1.5.0' androidTestImplementation 'androidx.test:rules:1.6.1'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1'
androidTestImplementation 'androidx.test.espresso:espresso-intents:3.5.1' androidTestImplementation 'androidx.test.espresso:espresso-intents:3.6.1'
androidTestImplementation 'com.android.support.test.espresso:espresso-contrib:3.0.2' androidTestImplementation 'com.android.support.test.espresso:espresso-contrib:3.0.2'
androidTestImplementation 'com.squareup.okhttp3:mockwebserver:4.12.0' androidTestImplementation 'com.squareup.okhttp3:mockwebserver:4.12.0'

View File

@ -53,9 +53,7 @@
android:theme="@style/BaseAppTheme"/> android:theme="@style/BaseAppTheme"/>
<activity <activity
android:name=".posts.ReportActivity" android:name=".posts.ReportActivity"
android:screenOrientation="sensorPortrait" android:theme="@style/BaseAppTheme" />
android:theme="@style/BaseAppTheme"
tools:ignore="LockedOrientationActivity" />
<activity <activity
android:name=".stories.StoriesActivity" /> android:name=".stories.StoriesActivity" />
@ -76,39 +74,29 @@
</activity> </activity>
<activity <activity
android:name=".profile.FollowsActivity" android:name=".profile.FollowsActivity"
android:theme="@style/BaseAppTheme" android:theme="@style/BaseAppTheme" />
android:screenOrientation="sensorPortrait"
tools:ignore="LockedOrientationActivity" />
<activity <activity
android:name=".posts.feeds.uncachedFeeds.hashtags.HashTagActivity" android:name=".posts.feeds.uncachedFeeds.hashtags.HashTagActivity"
android:theme="@style/BaseAppTheme" android:theme="@style/BaseAppTheme" />
android:screenOrientation="sensorPortrait"
tools:ignore="LockedOrientationActivity" />
<activity <activity
android:name=".posts.PostActivity" android:name=".posts.PostActivity"
android:screenOrientation="sensorPortrait"
tools:ignore="LockedOrientationActivity"
android:theme="@style/BaseAppTheme" /> android:theme="@style/BaseAppTheme" />
<activity <activity
android:name=".profile.ProfileActivity" android:name=".profile.ProfileActivity"
android:screenOrientation="sensorPortrait"
tools:ignore="LockedOrientationActivity"
android:theme="@style/BaseAppTheme"/> android:theme="@style/BaseAppTheme"/>
<activity android:name=".profile.CollectionActivity" <activity android:name=".profile.CollectionActivity"
android:theme="@style/BaseAppTheme"/> android:theme="@style/BaseAppTheme"/>
<activity <activity
android:name=".settings.SettingsActivity" android:name=".settings.SettingsActivity"
android:label="@string/title_activity_settings2" android:label="@string/title_activity_settings2"
android:parentActivityName=".MainActivity" android:parentActivityName=".main.MainActivity"
android:theme="@style/BaseAppTheme" /> android:theme="@style/BaseAppTheme" />
<activity <activity
android:name=".MainActivity" android:name=".main.MainActivity"
android:exported="true" android:exported="true"
android:theme="@style/Theme.App.Starting" android:theme="@style/Theme.App.Starting"
android:screenOrientation="sensorPortrait" android:windowSoftInputMode="adjustPan">
android:windowSoftInputMode="adjustPan"
tools:ignore="LockedOrientationActivity">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
@ -122,12 +110,10 @@
android:resource="@xml/shortcuts" /> android:resource="@xml/shortcuts" />
</activity> </activity>
<activity <activity
android:name=".LoginActivity" android:name=".login.LoginActivity"
android:exported="true" android:exported="true"
android:screenOrientation="sensorPortrait"
android:theme="@style/BaseAppTheme" android:theme="@style/BaseAppTheme"
android:windowSoftInputMode="adjustResize" android:windowSoftInputMode="adjustResize">
tools:ignore="LockedOrientationActivity">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.VIEW" /> <action android:name="android.intent.action.VIEW" />
@ -143,9 +129,7 @@
android:name=".searchDiscover.SearchActivity" android:name=".searchDiscover.SearchActivity"
android:exported="true" android:exported="true"
android:theme="@style/BaseAppTheme" android:theme="@style/BaseAppTheme"
android:launchMode="singleTop" android:launchMode="singleTop">
android:screenOrientation="sensorPortrait"
tools:ignore="LockedOrientationActivity">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.SEARCH" /> <action android:name="android.intent.action.SEARCH" />
</intent-filter> </intent-filter>
@ -166,6 +150,16 @@
android:name="android.support.FILE_PROVIDER_PATHS" android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" /> android:resource="@xml/file_paths" />
</provider> </provider>
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false"
tools:node="merge">
<meta-data
android:name="androidx.work.WorkManagerInitializer"
android:value="androidx.startup"
tools:node="remove" />
</provider>
</application> </application>
</manifest> </manifest>

View File

@ -1,345 +0,0 @@
package org.pixeldroid.app
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.net.Uri
import android.os.Bundle
import android.view.View
import android.view.inputmethod.InputMethodManager
import androidx.lifecycle.lifecycleScope
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.gson.Gson
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import org.pixeldroid.app.databinding.ActivityLoginBinding
import org.pixeldroid.app.utils.BaseActivity
import org.pixeldroid.app.utils.api.PixelfedAPI
import org.pixeldroid.app.utils.api.objects.Application
import org.pixeldroid.app.utils.api.objects.Instance
import org.pixeldroid.app.utils.api.objects.NodeInfo
import org.pixeldroid.app.utils.db.addUser
import org.pixeldroid.app.utils.db.storeInstance
import org.pixeldroid.app.utils.hasInternet
import org.pixeldroid.app.utils.normalizeDomain
import org.pixeldroid.app.utils.notificationsWorker.makeChannelGroupId
import org.pixeldroid.app.utils.notificationsWorker.makeNotificationChannels
import org.pixeldroid.app.utils.openUrl
import org.pixeldroid.app.utils.validDomain
/**
Overview of the flow of the login process: (boxes are requests done in parallel,
since they do not depend on each other)
_________________________________
|[PixelfedAPI.registerApplication]|
|[PixelfedAPI.wellKnownNodeInfo] |
̅̅̅̅̅̅̅̅̅̅̅̅̅̅̅̅̅̅̅̅̅̅̅̅̅̅̅̅̅̅̅̅̅
+----> [PixelfedAPI.nodeInfoSchema] (and then [PixelfedAPI.instance] if needed)
+----> [promptOAuth]
+----> [PixelfedAPI.obtainToken]
+----> [PixelfedAPI.verifyCredentials]
*/
class LoginActivity : BaseActivity() {
companion object {
private const val PACKAGE_ID = BuildConfig.APPLICATION_ID
private const val PREFERENCE_NAME = "$PACKAGE_ID.loginPref"
private const val SCOPE = "read write follow"
}
private lateinit var oauthScheme: String
private lateinit var appName: String
private lateinit var preferences: SharedPreferences
private lateinit var pixelfedAPI: PixelfedAPI
private var inputVisibility: Int = View.GONE
private lateinit var binding: ActivityLoginBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityLoginBinding.inflate(layoutInflater)
setContentView(binding.root)
loadingAnimation(true)
appName = getString(R.string.app_name)
oauthScheme = getString(R.string.auth_scheme)
preferences = getSharedPreferences(PREFERENCE_NAME, Context.MODE_PRIVATE)
if (hasInternet(applicationContext)) {
binding.connectInstanceButton.setOnClickListener {
registerAppToServer(normalizeDomain(binding.editText.text.toString()))
}
binding.whatsAnInstanceTextView.setOnClickListener{ whatsAnInstance() }
inputVisibility = View.VISIBLE
} else {
binding.loginActivityConnectionRequired.visibility = View.VISIBLE
binding.loginActivityConnectionRequiredButton.setOnClickListener {
finish()
startActivity(intent)
}
}
loadingAnimation(false)
}
override fun onStart() {
super.onStart()
val url: Uri? = intent.data
//Check if the activity was started after the authentication
if (url == null || !url.toString().startsWith("$oauthScheme://$PACKAGE_ID")) return
loadingAnimation(true)
val code = url.getQueryParameter("code")
authenticate(code)
}
override fun onStop() {
super.onStop()
loadingAnimation(false)
}
private fun whatsAnInstance() {
MaterialAlertDialogBuilder(this)
.setView(layoutInflater.inflate(R.layout.whats_an_instance_explanation, null))
.setPositiveButton(android.R.string.ok) { _, _ -> }
// Create the AlertDialog
.show()
}
private fun hideKeyboard() {
val view = currentFocus
if (view != null) {
(getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager).hideSoftInputFromWindow(
view.windowToken,
InputMethodManager.HIDE_NOT_ALWAYS
)
}
}
private fun registerAppToServer(normalizedDomain: String) {
if(!validDomain(normalizedDomain)) return failedRegistration(getString(R.string.invalid_domain))
hideKeyboard()
loadingAnimation(true)
pixelfedAPI = PixelfedAPI.createFromUrl(normalizedDomain)
lifecycleScope.launch {
try {
val credentialsDeferred: Deferred<Application?> = async {
try {
pixelfedAPI.registerApplication(
appName, "$oauthScheme://$PACKAGE_ID", SCOPE, "https://pixeldroid.org"
)
} catch (exception: Exception) {
return@async null
}
}
val nodeInfoJRD = pixelfedAPI.wellKnownNodeInfo()
val credentials = credentialsDeferred.await()
val clientId = credentials?.client_id ?: return@launch failedRegistration()
preferences.edit()
.putString("clientID", clientId)
.putString("clientSecret", credentials.client_secret)
.apply()
// c.f. https://nodeinfo.diaspora.software/protocol.html for more info
val nodeInfoSchemaUrl = nodeInfoJRD.links.firstOrNull {
it.rel == "http://nodeinfo.diaspora.software/ns/schema/2.0"
}?.href ?: return@launch failedRegistration(getString(R.string.instance_error))
nodeInfoSchema(normalizedDomain, clientId, nodeInfoSchemaUrl)
} catch (exception: Exception) {
return@launch failedRegistration()
}
}
}
private suspend fun nodeInfoSchema(
normalizedDomain: String,
clientId: String,
nodeInfoSchemaUrl: String
) = coroutineScope {
val nodeInfo: NodeInfo = try {
pixelfedAPI.nodeInfoSchema(nodeInfoSchemaUrl)
} catch (exception: Exception) {
return@coroutineScope failedRegistration(getString(R.string.instance_error))
}
val domain: String = try {
if (nodeInfo.hasInstanceEndpointInfo()) {
preferences.edit().putString("nodeInfo", Gson().toJson(nodeInfo)).remove("instance").apply()
nodeInfo.metadata?.config?.site?.url
} else {
val instance: Instance = try {
pixelfedAPI.instance()
} catch (exception: Exception) {
return@coroutineScope failedRegistration(getString(R.string.instance_error))
}
preferences.edit().putString("instance", Gson().toJson(instance)).remove("nodeInfo").apply()
instance.uri
}
} catch (e: IllegalArgumentException){ null }
?: return@coroutineScope failedRegistration(getString(R.string.instance_error))
preferences.edit()
.putString("domain", normalizeDomain(domain))
.apply()
if (!nodeInfo.software?.name.orEmpty().contains("pixelfed")) {
MaterialAlertDialogBuilder(this@LoginActivity).apply {
setMessage(R.string.instance_not_pixelfed_warning)
setPositiveButton(R.string.instance_not_pixelfed_continue) { _, _ ->
promptOAuth(normalizedDomain, clientId)
}
setNegativeButton(R.string.instance_not_pixelfed_cancel) { _, _ ->
loadingAnimation(false)
wipeSharedSettings()
}
}.show()
} else if (nodeInfo.metadata?.config?.features?.mobile_apis != true) {
MaterialAlertDialogBuilder(this@LoginActivity).apply {
setMessage(R.string.api_not_enabled_dialog)
setNegativeButton(android.R.string.ok) { _, _ ->
loadingAnimation(false)
wipeSharedSettings()
}
}.show()
} else {
promptOAuth(normalizedDomain, clientId)
}
}
private fun promptOAuth(normalizedDomain: String, client_id: String) {
val url = "$normalizedDomain/oauth/authorize?" +
"client_id" + "=" + client_id + "&" +
"redirect_uri" + "=" + "$oauthScheme://$PACKAGE_ID" + "&" +
"response_type=code" + "&" +
"scope=${SCOPE.replace(" ", "%20")}"
if (!openUrl(url)) return failedRegistration(getString(R.string.browser_launch_failed))
}
private fun authenticate(code: String?) {
// Get previous values from preferences
val domain = preferences.getString("domain", "") as String
val clientId = preferences.getString("clientID", "") as String
val clientSecret = preferences.getString("clientSecret", "") as String
if (code.isNullOrBlank() || domain.isBlank() || clientId.isBlank() || clientSecret.isBlank()) {
return failedRegistration(getString(R.string.auth_failed))
}
//Successful authorization
pixelfedAPI = PixelfedAPI.createFromUrl(domain)
val gson = Gson()
val nodeInfo: NodeInfo? = gson.fromJson(preferences.getString("nodeInfo", null), NodeInfo::class.java)
val instance: Instance? = gson.fromJson(preferences.getString("instance", null), Instance::class.java)
lifecycleScope.launch {
try {
val token = pixelfedAPI.obtainToken(
clientId, clientSecret, "$oauthScheme://$PACKAGE_ID", SCOPE, code,
"authorization_code"
)
if (token.access_token == null) {
return@launch failedRegistration(getString(R.string.token_error))
}
storeInstance(db, nodeInfo, instance)
storeUser(
token.access_token,
token.refresh_token,
clientId,
clientSecret,
domain
)
wipeSharedSettings()
} catch (exception: Exception) {
return@launch failedRegistration(getString(R.string.token_error))
}
}
}
private fun failedRegistration(message: String = getString(R.string.registration_failed)) {
loadingAnimation(false)
binding.editText.error = message
wipeSharedSettings()
}
private fun wipeSharedSettings(){
preferences.edit().clear().apply()
}
private fun loadingAnimation(on: Boolean){
if(on) {
binding.loginActivityInstanceInputLayout.visibility = View.GONE
binding.progressLayout.visibility = View.VISIBLE
}
else {
binding.loginActivityInstanceInputLayout.visibility = inputVisibility
binding.progressLayout.visibility = View.GONE
}
}
private suspend fun storeUser(accessToken: String, refreshToken: String?, clientId: String, clientSecret: String, instance: String) {
try {
val user = pixelfedAPI.verifyCredentials("Bearer $accessToken")
db.userDao().deActivateActiveUsers()
addUser(
db,
user,
instance,
activeUser = true,
accessToken = accessToken,
refreshToken = refreshToken,
clientId = clientId,
clientSecret = clientSecret
)
apiHolder.setToCurrentUser()
} catch (exception: Exception) {
return failedRegistration(getString(R.string.verify_credentials))
}
fetchNotifications()
val intent = Intent(this@LoginActivity, MainActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
startActivity(intent)
}
// Fetch the latest notifications of this account, to avoid launching old notifications
private suspend fun fetchNotifications() {
val user = db.userDao().getActiveUser()!!
try {
val notifications = apiHolder.api!!.notifications()
notifications.forEach{it.user_id = user.user_id; it.instance_uri = user.instance_uri}
db.notificationDao().insertAll(notifications)
} catch (exception: Exception) {
return failedRegistration(getString(R.string.login_notifications))
}
makeNotificationChannels(
applicationContext,
user.fullHandle,
makeChannelGroupId(user)
)
}
}

View File

@ -0,0 +1,189 @@
package org.pixeldroid.app.login
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.net.Uri
import android.os.Bundle
import android.view.View
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputMethodManager
import androidx.activity.viewModels
import androidx.annotation.StringRes
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import org.pixeldroid.app.BuildConfig
import org.pixeldroid.app.R
import org.pixeldroid.app.databinding.ActivityLoginBinding
import org.pixeldroid.app.main.MainActivity
import org.pixeldroid.app.utils.BaseActivity
import org.pixeldroid.app.utils.api.PixelfedAPI
import org.pixeldroid.app.utils.openUrl
/**
Overview of the flow of the login process: (boxes are requests done in parallel,
since they do not depend on each other)
_________________________________
|[PixelfedAPI.registerApplication]|
|[PixelfedAPI.wellKnownNodeInfo] |
̅̅̅̅̅̅̅̅̅̅̅̅̅̅̅̅̅̅̅̅̅̅̅̅̅̅̅̅̅̅̅̅̅
+----> [PixelfedAPI.nodeInfoSchema] (and then [PixelfedAPI.instance] if needed)
+----> [promptOAuth]
+----> [PixelfedAPI.obtainToken]
+----> [PixelfedAPI.verifyCredentials]
*/
class LoginActivity : BaseActivity() {
companion object {
private const val PACKAGE_ID = BuildConfig.APPLICATION_ID
private const val PREFERENCE_NAME = "$PACKAGE_ID.loginPref"
private const val SCOPE = "read write follow"
}
private lateinit var oauthScheme: String
private lateinit var preferences: SharedPreferences
private lateinit var binding: ActivityLoginBinding
val model: LoginActivityViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityLoginBinding.inflate(layoutInflater)
setContentView(binding.root)
oauthScheme = getString(R.string.auth_scheme)
preferences = getSharedPreferences(PREFERENCE_NAME, Context.MODE_PRIVATE)
binding.connectInstanceButton.setOnClickListener {
hideKeyboard()
model.registerAppToServer(binding.editText.text.toString())
}
binding.whatsAnInstanceTextView.setOnClickListener{ whatsAnInstance() }
// Enter button on keyboard should press the connect button
binding.editText.setOnEditorActionListener { _, actionId, _ ->
if (actionId == EditorInfo.IME_ACTION_DONE) {
binding.connectInstanceButton.performClick()
return@setOnEditorActionListener true
}
false
}
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
model.promptOauth.collectLatest {
it?.let {
if (it.launch) promptOAuth(it.normalizedDomain, it.clientId)
}
}
}
}
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
model.finishedLogin.collectLatest {
if (it) {
val intent = Intent(this@LoginActivity, MainActivity::class.java)
intent.flags =
Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
startActivity(intent)
}
}
}
}
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
model.loadingState.collectLatest {
when(it.loginState){
LoginActivityViewModel.LoginState.LoadingState.Resting -> loadingAnimation(false)
LoginActivityViewModel.LoginState.LoadingState.Busy -> loadingAnimation(true)
LoginActivityViewModel.LoginState.LoadingState.Error -> failedRegistration(it.error!!)
}
}
}
}
}
override fun onStart() {
super.onStart()
val url: Uri? = intent.data
//Check if the activity was started after the authentication
if (url == null || !url.toString().startsWith("$oauthScheme://$PACKAGE_ID")) return
val code = url.getQueryParameter("code")
model.authenticate(code)
}
private fun whatsAnInstance() {
MaterialAlertDialogBuilder(this)
.setView(layoutInflater.inflate(R.layout.whats_an_instance_explanation, null))
.setPositiveButton(android.R.string.ok) { _, _ -> }
// Create the AlertDialog
.show()
}
private fun hideKeyboard() {
val view = currentFocus
if (view != null) {
(getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager).hideSoftInputFromWindow(
view.windowToken,
InputMethodManager.HIDE_NOT_ALWAYS
)
}
}
private fun promptOAuth(normalizedDomain: String, client_id: String) {
val url = "$normalizedDomain/oauth/authorize?" +
"client_id" + "=" + client_id + "&" +
"redirect_uri" + "=" + "$oauthScheme://$PACKAGE_ID" + "&" +
"response_type=code" + "&" +
"scope=${SCOPE.replace(" ", "%20")}"
if (!openUrl(url)) model.oauthLaunchFailed()
else model.oauthLaunched()
}
private fun failedRegistration(@StringRes message: Int = R.string.registration_failed) {
when (message) {
R.string.instance_not_pixelfed_warning -> MaterialAlertDialogBuilder(this@LoginActivity).apply {
setMessage(R.string.instance_not_pixelfed_warning)
setPositiveButton(R.string.instance_not_pixelfed_continue) { _, _ ->
model.dialogAckedContinueAnyways()
}
setNegativeButton(R.string.instance_not_pixelfed_cancel) { _, _ ->
model.dialogNegativeButtonClicked()
}
}.show()
R.string.api_not_enabled_dialog -> MaterialAlertDialogBuilder(this@LoginActivity).apply {
setMessage(R.string.api_not_enabled_dialog)
setNegativeButton(android.R.string.ok) { _, _ ->
model.dialogNegativeButtonClicked()
}
}.show()
else -> binding.editText.error = getString(message)
}
loadingAnimation(false)
}
private fun loadingAnimation(on: Boolean){
if(on) {
binding.loginActivityInstanceInputLayout.visibility = View.GONE
binding.progressLayout.visibility = View.VISIBLE
}
else {
binding.loginActivityInstanceInputLayout.visibility = View.VISIBLE
binding.progressLayout.visibility = View.GONE
}
}
}

View File

@ -0,0 +1,283 @@
package org.pixeldroid.app.login
import android.content.Context
import android.content.SharedPreferences
import androidx.annotation.StringRes
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.google.gson.Gson
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import org.pixeldroid.app.BuildConfig
import org.pixeldroid.app.R
import org.pixeldroid.app.utils.api.PixelfedAPI
import org.pixeldroid.app.utils.api.objects.Application
import org.pixeldroid.app.utils.api.objects.Instance
import org.pixeldroid.app.utils.api.objects.NodeInfo
import org.pixeldroid.app.utils.db.AppDatabase
import org.pixeldroid.app.utils.db.addUser
import org.pixeldroid.app.utils.db.storeInstance
import org.pixeldroid.app.utils.di.PixelfedAPIHolder
import org.pixeldroid.app.utils.normalizeDomain
import org.pixeldroid.app.utils.notificationsWorker.makeChannelGroupId
import org.pixeldroid.app.utils.notificationsWorker.makeNotificationChannels
import org.pixeldroid.app.utils.validDomain
import javax.inject.Inject
@HiltViewModel
class LoginActivityViewModel @Inject constructor(
private val apiHolder: PixelfedAPIHolder,
private val db: AppDatabase,
@ApplicationContext private val applicationContext: Context,
) : ViewModel() {
companion object {
private const val PACKAGE_ID = BuildConfig.APPLICATION_ID
private const val PREFERENCE_NAME = "$PACKAGE_ID.loginPref"
private const val SCOPE = "read write follow"
}
private val oauthScheme = applicationContext.getString(R.string.auth_scheme)
private lateinit var pixelfedAPI: PixelfedAPI
private val preferences: SharedPreferences = applicationContext.getSharedPreferences(
PREFERENCE_NAME, Context.MODE_PRIVATE)
private val _loadingState: MutableStateFlow<LoginState> = MutableStateFlow(LoginState(LoginState.LoadingState.Resting))
val loadingState = _loadingState.asStateFlow()
private val _finishedLogin = MutableStateFlow(false)
val finishedLogin = _finishedLogin.asStateFlow()
private val _promptOauth: MutableStateFlow<PromptOAuth?> = MutableStateFlow(null)
val promptOauth = _promptOauth.asStateFlow()
data class PromptOAuth(
val launch: Boolean,
val normalizedDomain: String,
val clientId: String,
)
data class LoginState(
val loginState: LoadingState,
@StringRes
val error: Int? = null,
) {
init {
if (loginState == LoadingState.Error && error == null) throw IllegalArgumentException()
}
enum class LoadingState {
Resting, Busy, Error
}
}
fun registerAppToServer(rawDomain: String) {
val normalizedDomain = normalizeDomain(rawDomain)
if(!validDomain(normalizedDomain)) return failedRegistration(R.string.invalid_domain)
_loadingState.value = LoginState(LoginState.LoadingState.Busy)
pixelfedAPI = PixelfedAPI.createFromUrl(normalizedDomain)
viewModelScope.launch {
try {
val credentialsDeferred: Deferred<Application?> = async {
try {
pixelfedAPI.registerApplication(
applicationContext.getString(R.string.app_name),
"$oauthScheme://$PACKAGE_ID", SCOPE, "https://pixeldroid.org"
)
} catch (exception: Exception) {
return@async null
}
}
val nodeInfoJRD = pixelfedAPI.wellKnownNodeInfo()
val credentials = credentialsDeferred.await()
val clientId = credentials?.client_id ?: return@launch failedRegistration()
preferences.edit()
.putString("clientID", clientId)
.putString("clientSecret", credentials.client_secret)
.apply()
// c.f. https://nodeinfo.diaspora.software/protocol.html for more info
val nodeInfoSchemaUrl = nodeInfoJRD.links.firstOrNull {
it.rel == "http://nodeinfo.diaspora.software/ns/schema/2.0"
}?.href ?: return@launch failedRegistration(R.string.instance_error)
nodeInfoSchema(normalizedDomain, clientId, nodeInfoSchemaUrl)
} catch (exception: Exception) {
return@launch failedRegistration()
}
}
}
private suspend fun nodeInfoSchema(
normalizedDomain: String,
clientId: String,
nodeInfoSchemaUrl: String
) = coroutineScope {
val nodeInfo: NodeInfo = try {
pixelfedAPI.nodeInfoSchema(nodeInfoSchemaUrl)
} catch (exception: Exception) {
return@coroutineScope failedRegistration(R.string.instance_error)
}
val domain: String = try {
if (nodeInfo.hasInstanceEndpointInfo()) {
preferences.edit().putString("nodeInfo", Gson().toJson(nodeInfo)).remove("instance").apply()
nodeInfo.metadata?.config?.site?.url
} else {
val instance: Instance = try {
pixelfedAPI.instance()
} catch (exception: Exception) {
return@coroutineScope failedRegistration(R.string.instance_error)
}
preferences.edit().putString("instance", Gson().toJson(instance)).remove("nodeInfo").apply()
instance.uri
}
} catch (e: IllegalArgumentException){ null }
?: return@coroutineScope failedRegistration(R.string.instance_error)
preferences.edit()
.putString("domain", normalizeDomain(domain))
.apply()
if (!nodeInfo.software?.name.orEmpty().contains("pixelfed")) {
_loadingState.value = LoginState(LoginState.LoadingState.Error, R.string.instance_not_pixelfed_warning)
_promptOauth.value = PromptOAuth(false, normalizedDomain, clientId)
} else if (nodeInfo.metadata?.config?.features?.mobile_apis != true) {
_loadingState.value = LoginState(LoginState.LoadingState.Error, R.string.api_not_enabled_dialog)
} else {
_promptOauth.value = PromptOAuth(true, normalizedDomain, clientId)
_loadingState.value = LoginState(LoginState.LoadingState.Busy)
}
}
fun authenticate(code: String?) {
_loadingState.value = LoginState(LoginState.LoadingState.Busy)
// Get previous values from preferences
val domain = preferences.getString("domain", "") as String
val clientId = preferences.getString("clientID", "") as String
val clientSecret = preferences.getString("clientSecret", "") as String
if (code.isNullOrBlank() || domain.isBlank() || clientId.isBlank() || clientSecret.isBlank()) {
return failedRegistration(R.string.auth_failed)
}
//Successful authorization
pixelfedAPI = PixelfedAPI.createFromUrl(domain)
val gson = Gson()
val nodeInfo: NodeInfo? = gson.fromJson(preferences.getString("nodeInfo", null), NodeInfo::class.java)
val instance: Instance? = gson.fromJson(preferences.getString("instance", null), Instance::class.java)
viewModelScope.launch {
try {
val token = pixelfedAPI.obtainToken(
clientId, clientSecret, "$oauthScheme://$PACKAGE_ID",
SCOPE, code,
"authorization_code"
)
if (token.access_token == null) {
return@launch failedRegistration(R.string.token_error)
}
storeInstance(db, nodeInfo, instance)
storeUser(
token.access_token,
token.refresh_token,
clientId,
clientSecret,
domain
)
wipeSharedSettings()
} catch (exception: Exception) {
return@launch failedRegistration(R.string.token_error)
}
}
}
private suspend fun storeUser(accessToken: String, refreshToken: String?, clientId: String, clientSecret: String, instance: String) {
try {
val user = pixelfedAPI.verifyCredentials("Bearer $accessToken")
db.userDao().deActivateActiveUsers()
addUser(
db,
user,
instance,
activeUser = true,
accessToken = accessToken,
refreshToken = refreshToken,
clientId = clientId,
clientSecret = clientSecret
)
apiHolder.setToCurrentUser()
} catch (exception: Exception) {
return failedRegistration(R.string.verify_credentials)
}
fetchNotifications()
_finishedLogin.value = true
}
// Fetch the latest notifications of this account, to avoid launching old notifications
private suspend fun fetchNotifications() {
val user = db.userDao().getActiveUser()!!
try {
val notifications = apiHolder.api!!.notifications()
notifications.forEach{it.user_id = user.user_id; it.instance_uri = user.instance_uri}
db.notificationDao().insertAll(notifications)
} catch (exception: Exception) {
return failedRegistration(R.string.login_notifications)
}
makeNotificationChannels(
applicationContext,
user.fullHandle,
makeChannelGroupId(user)
)
}
private fun wipeSharedSettings(){
preferences.edit().clear().apply()
}
private fun failedRegistration(@StringRes message: Int = R.string.registration_failed) {
_loadingState.value = LoginState(LoginState.LoadingState.Error, message)
when (message) {
R.string.instance_not_pixelfed_warning, R.string.api_not_enabled_dialog -> return
else -> wipeSharedSettings()
}
}
fun oauthLaunched() {
_promptOauth.value = null
}
fun oauthLaunchFailed() {
_promptOauth.value = null
_loadingState.value = LoginState(LoginState.LoadingState.Error, R.string.browser_launch_failed)
}
fun dialogAckedContinueAnyways() {
_promptOauth.value = _promptOauth.value?.copy(launch = true)
_loadingState.value = LoginState(LoginState.LoadingState.Busy)
}
fun dialogNegativeButtonClicked() {
wipeSharedSettings()
_loadingState.value = LoginState(LoginState.LoadingState.Resting)
}
}

View File

@ -0,0 +1,62 @@
package org.pixeldroid.app.main
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.lifecycle.LifecycleCoroutineScope
import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import org.pixeldroid.app.R
import org.pixeldroid.app.databinding.AccountListItemBinding
import org.pixeldroid.app.utils.db.entities.UserDatabaseEntity
class AccountListAdapter(
private val items: StateFlow<List<UserDatabaseEntity>>,
lifecycleScope: LifecycleCoroutineScope,
private val onClick: (UserDatabaseEntity?) -> Unit
) : RecyclerView.Adapter<AccountListAdapter.ViewHolder>() {
private val itemsList: MutableList<UserDatabaseEntity> = mutableListOf()
init {
lifecycleScope.launch {
items.collect {
itemsList.clear()
itemsList.addAll(it.filter { !it.isActive })
notifyDataSetChanged()
}
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val view = AccountListItemBinding.inflate(
LayoutInflater.from(parent.context), parent, false
)
return ViewHolder(view)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.binding.root.setOnClickListener{onClick(itemsList.getOrNull(position))}
if (position == itemsList.size) {
Glide.with(holder.itemView)
.load(R.drawable.add)
.into(holder.binding.imageView)
holder.binding.accountName.setText(R.string.add_account_name)
holder.binding.accountUsername.setText(R.string.add_account_description)
return
}
val user = itemsList[position]
Glide.with(holder.itemView)
.load(user.avatar_static)
.placeholder(R.drawable.ic_default_user)
.circleCrop()
.into(holder.binding.imageView)
holder.binding.accountName.text = user.display_name
holder.binding.accountUsername.text = user.fullHandle
}
override fun getItemCount(): Int = itemsList.size + 1
class ViewHolder(val binding: AccountListItemBinding) : RecyclerView.ViewHolder(binding.root)
}

View File

@ -1,4 +1,4 @@
package org.pixeldroid.app package org.pixeldroid.app.main
import android.Manifest import android.Manifest
import android.content.Context import android.content.Context
@ -16,31 +16,37 @@ import androidx.activity.addCallback
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.app.ActivityCompat import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.core.view.GravityCompat import androidx.core.view.GravityCompat
import androidx.core.view.children
import androidx.core.view.isVisible
import androidx.core.view.marginEnd
import androidx.core.view.marginTop
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle import androidx.lifecycle.repeatOnLifecycle
import androidx.paging.ExperimentalPagingApi import androidx.paging.ExperimentalPagingApi
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import androidx.viewpager2.adapter.FragmentStateAdapter import androidx.viewpager2.adapter.FragmentStateAdapter
import androidx.viewpager2.widget.ViewPager2 import androidx.viewpager2.widget.ViewPager2
import androidx.viewpager2.widget.ViewPager2.OnPageChangeCallback import androidx.viewpager2.widget.ViewPager2.OnPageChangeCallback
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.bumptech.glide.request.RequestOptions
import com.google.android.material.bottomnavigation.BottomNavigationView
import com.google.android.material.color.DynamicColors import com.google.android.material.color.DynamicColors
import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial import com.google.android.material.navigation.NavigationBarView
import com.mikepenz.materialdrawer.iconics.iconicsIcon import com.google.android.material.navigation.NavigationView
import com.mikepenz.materialdrawer.model.PrimaryDrawerItem import com.mikepenz.materialdrawer.model.PrimaryDrawerItem
import com.mikepenz.materialdrawer.model.ProfileDrawerItem import com.mikepenz.materialdrawer.model.ProfileDrawerItem
import com.mikepenz.materialdrawer.model.ProfileSettingDrawerItem import com.mikepenz.materialdrawer.model.ProfileSettingDrawerItem
import com.mikepenz.materialdrawer.model.interfaces.IProfile import com.mikepenz.materialdrawer.model.interfaces.IProfile
import com.mikepenz.materialdrawer.model.interfaces.descriptionRes import com.mikepenz.materialdrawer.model.interfaces.descriptionRes
import com.mikepenz.materialdrawer.model.interfaces.descriptionText import com.mikepenz.materialdrawer.model.interfaces.descriptionText
import com.mikepenz.materialdrawer.model.interfaces.iconRes
import com.mikepenz.materialdrawer.model.interfaces.iconUrl import com.mikepenz.materialdrawer.model.interfaces.iconUrl
import com.mikepenz.materialdrawer.model.interfaces.nameRes import com.mikepenz.materialdrawer.model.interfaces.nameRes
import com.mikepenz.materialdrawer.model.interfaces.nameText import com.mikepenz.materialdrawer.model.interfaces.nameText
@ -49,6 +55,8 @@ import com.mikepenz.materialdrawer.util.DrawerImageLoader
import com.mikepenz.materialdrawer.widget.AccountHeaderView import com.mikepenz.materialdrawer.widget.AccountHeaderView
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.ligi.tracedroid.sending.sendTraceDroidStackTracesIfExist import org.ligi.tracedroid.sending.sendTraceDroidStackTracesIfExist
import org.pixeldroid.app.login.LoginActivity
import org.pixeldroid.app.R
import org.pixeldroid.app.databinding.ActivityMainBinding import org.pixeldroid.app.databinding.ActivityMainBinding
import org.pixeldroid.app.postCreation.camera.CameraFragment import org.pixeldroid.app.postCreation.camera.CameraFragment
import org.pixeldroid.app.posts.NestedScrollableHost import org.pixeldroid.app.posts.NestedScrollableHost
@ -70,6 +78,7 @@ import org.pixeldroid.app.utils.notificationsWorker.NotificationsWorker.Companio
import org.pixeldroid.app.utils.notificationsWorker.NotificationsWorker.Companion.USER_NOTIFICATION_TAG import org.pixeldroid.app.utils.notificationsWorker.NotificationsWorker.Companion.USER_NOTIFICATION_TAG
import org.pixeldroid.app.utils.notificationsWorker.enablePullNotifications import org.pixeldroid.app.utils.notificationsWorker.enablePullNotifications
import org.pixeldroid.app.utils.notificationsWorker.removeNotificationChannelsFromAccount import org.pixeldroid.app.utils.notificationsWorker.removeNotificationChannelsFromAccount
import org.pixeldroid.common.dpToPx
import java.time.Instant import java.time.Instant
@ -179,22 +188,53 @@ class MainActivity : BaseActivity() {
} }
private fun setupDrawer() { private fun setupDrawer() {
binding.mainDrawerButton.setOnClickListener{ binding.mainDrawerButton?.setOnClickListener {
binding.drawerLayout.openDrawer(binding.drawer) binding.drawer?.let { drawer -> binding.drawerLayout.openDrawer(drawer) }
} }
header = AccountHeaderView(this).apply { val navigationHeader = binding.navigation?.getHeaderView(0) as? AccountHeaderView
attachToSliderView(binding.drawer) val headerview = navigationHeader ?: AccountHeaderView(this)
navigationHeader?.onAccountHeaderSelectionViewClickListener = { _: View, _: IProfile ->
// update the arrow image within the drawer
navigationHeader!!.accountSwitcherArrow.clearAnimation()
if(binding.accountList?.isVisible == true) {
navigationHeader.accountSwitcherArrow.animate().rotation(0f).start()
} else {
navigationHeader.accountSwitcherArrow.animate().rotation(180f).start()
fun onAccountClick(user: UserDatabaseEntity?){
clickProfile(user?.user_id, user?.instance_uri, false)
}
val adapter = AccountListAdapter(model.users, lifecycleScope, ::onAccountClick)
binding.accountList?.adapter = adapter
val location = IntArray(2)
navigationHeader.getLocationOnScreen(location)
// Set the position of textView within constraintLayout2
val textViewLayoutParams = binding.accountList?.layoutParams as? ConstraintLayout.LayoutParams
textViewLayoutParams?.topMargin = location[1] + (navigationHeader as ConstraintLayout).height - 6.dpToPx(this)
binding.accountList?.layoutParams = textViewLayoutParams
}
binding.accountList?.isVisible = !(binding.accountList?.isVisible ?: false)
true
}
header = headerview.apply {
binding.drawer?.let { attachToSliderView(it) }
headerBackgroundScaleType = ImageView.ScaleType.CENTER_CROP headerBackgroundScaleType = ImageView.ScaleType.CENTER_CROP
currentHiddenInList = true currentHiddenInList = true
onAccountHeaderListener = { _: View?, profile: IProfile, current: Boolean -> onAccountHeaderListener = { _: View?, profile: IProfile, current: Boolean ->
clickProfile(profile, current) val userId: String? = if (profile.identifier == ADD_ACCOUNT_IDENTIFIER) null else profile.identifier.toString()
clickProfile(userId, profile.tag?.toString(), current)
} }
addProfile(ProfileSettingDrawerItem().apply { addProfile(ProfileSettingDrawerItem().apply {
identifier = ADD_ACCOUNT_IDENTIFIER identifier = ADD_ACCOUNT_IDENTIFIER
nameRes = R.string.add_account_name nameRes = R.string.add_account_name
descriptionRes = R.string.add_account_description descriptionRes = R.string.add_account_description
iconicsIcon = GoogleMaterial.Icon.gmd_add iconRes = R.drawable.add
}, 0) }, 0)
dividerBelowHeader = false dividerBelowHeader = false
closeDrawerOnProfileListClick = true closeDrawerOnProfileListClick = true
@ -202,11 +242,11 @@ class MainActivity : BaseActivity() {
DrawerImageLoader.init(object : AbstractDrawerImageLoader() { DrawerImageLoader.init(object : AbstractDrawerImageLoader() {
override fun set(imageView: ImageView, uri: Uri, placeholder: Drawable, tag: String?) { override fun set(imageView: ImageView, uri: Uri, placeholder: Drawable, tag: String?) {
Glide.with(this@MainActivity) Glide.with(this@MainActivity)
.load(uri) .load(uri)
.placeholder(placeholder) .placeholder(placeholder)
.circleCrop() .circleCrop()
.into(imageView) .into(imageView)
} }
override fun cancel(imageView: ImageView) { override fun cancel(imageView: ImageView) {
@ -228,22 +268,23 @@ class MainActivity : BaseActivity() {
//with the received one. This happens asynchronously. //with the received one. This happens asynchronously.
getUpdatedAccount() getUpdatedAccount()
binding.drawer.itemAdapter.add( binding.drawer?.itemAdapter?.add(
primaryDrawerItem { primaryDrawerItem {
nameRes = R.string.menu_account nameRes = R.string.menu_account
iconicsIcon = GoogleMaterial.Icon.gmd_person iconRes = R.drawable.person
}, },
primaryDrawerItem { primaryDrawerItem {
nameRes = R.string.menu_settings nameRes = R.string.menu_settings
iconicsIcon = GoogleMaterial.Icon.gmd_settings iconRes = R.drawable.settings
}, },
primaryDrawerItem { primaryDrawerItem {
nameRes = R.string.logout nameRes = R.string.logout
iconicsIcon = GoogleMaterial.Icon.gmd_close iconRes = R.drawable.logout
}, },
) )
binding.drawer.onDrawerItemClickListener = { v, drawerItem, position ->
when (position){ binding.drawer?.onDrawerItemClickListener = { v, drawerItem, position ->
when (position) {
1 -> launchActivity(ProfileActivity()) 1 -> launchActivity(ProfileActivity())
2 -> launchActivity(SettingsActivity()) 2 -> launchActivity(SettingsActivity())
3 -> logOut() 3 -> logOut()
@ -254,10 +295,9 @@ class MainActivity : BaseActivity() {
// Closes the drawer if it is open, when we press the back button // Closes the drawer if it is open, when we press the back button
onBackPressedDispatcher.addCallback(this) { onBackPressedDispatcher.addCallback(this) {
// Handle the back button event // Handle the back button event
if(binding.drawerLayout.isDrawerOpen(GravityCompat.START)){ if (binding.drawerLayout.isDrawerOpen(GravityCompat.START)) {
binding.drawerLayout.closeDrawer(GravityCompat.START) binding.drawerLayout.closeDrawer(GravityCompat.START)
} } else {
else {
this.isEnabled = false this.isEnabled = false
super.onBackPressedDispatcher.onBackPressed() super.onBackPressedDispatcher.onBackPressed()
} }
@ -305,18 +345,19 @@ class MainActivity : BaseActivity() {
} }
//called when switching profiles, or when clicking on current profile //called when switching profiles, or when clicking on current profile
private fun clickProfile(profile: IProfile, current: Boolean): Boolean { @Suppress("SameReturnValue")
private fun clickProfile(id: String?, instance: String?, current: Boolean): Boolean {
if(current){ if(current){
launchActivity(ProfileActivity()) launchActivity(ProfileActivity())
return false return false
} }
//Clicked on add new account //Clicked on add new account
if(profile.identifier == ADD_ACCOUNT_IDENTIFIER){ if(id == null || instance == null){
launchActivity(LoginActivity()) launchActivity(LoginActivity())
return false return false
} }
switchUser(profile.identifier.toString(), profile.tag as String) switchUser(id, instance)
val intent = Intent(this, MainActivity::class.java) val intent = Intent(this, MainActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
@ -396,6 +437,34 @@ class MainActivity : BaseActivity() {
touchSlopField.set(recyclerView, touchSlop*NestedScrollableHost.touchSlopModifier) touchSlopField.set(recyclerView, touchSlop*NestedScrollableHost.touchSlopModifier)
} }
private fun NavigationView.unSelectAll() {
for (i in 0 until menu.size()) {
val menuItem = menu.getItem(i)
menuItem.isChecked = false
}
}
private fun MenuItem.itemPos(): Int? {
return when(itemId){
R.id.page_1 -> 0
R.id.page_2 -> 1
R.id.page_3 -> 2
R.id.page_4 -> 3
R.id.page_5 -> 4
else -> null
}
}
private fun reclick(item: MenuItem) {
item.itemPos()?.let { position ->
val page =
//No clue why this works but it does. F to pay respects
supportFragmentManager.findFragmentByTag("f$position")
(page as? CachedFeedFragment<*>)?.onTabReClicked()
}
}
private fun setupTabs(tab_array: List<() -> Fragment>){ private fun setupTabs(tab_array: List<() -> Fragment>){
binding.viewPager.reduceDragSensitivity() binding.viewPager.reduceDragSensitivity()
binding.viewPager.adapter = object : FragmentStateAdapter(this) { binding.viewPager.adapter = object : FragmentStateAdapter(this) {
@ -422,37 +491,49 @@ class MainActivity : BaseActivity() {
else -> null else -> null
} }
if (selected != null) { if (selected != null) {
binding.tabs.selectedItemId = selected // Disable and re-enable reselected listener so that it's not triggered by this
(binding.tabs as? NavigationBarView)?.setOnItemReselectedListener(null)
(binding.tabs as? NavigationBarView)?.selectedItemId = selected
(binding.tabs as? NavigationBarView)?.setOnItemReselectedListener(::reclick)
binding.navigation?.unSelectAll()
binding.navigation?.menu?.getItem(position)?.setChecked(true)
} }
super.onPageSelected(position) super.onPageSelected(position)
} }
}) })
fun MenuItem.itemPos(): Int? {
return when(itemId){ fun MenuItem.buttonPos() {
R.id.page_1 -> 0 when(itemId){
R.id.page_2 -> 1 R.id.my_profile -> launchActivity(ProfileActivity())
R.id.page_3 -> 2 R.id.settings -> launchActivity(SettingsActivity())
R.id.page_4 -> 3 R.id.log_out -> logOut()
R.id.page_5 -> 4
else -> null
} }
} }
binding.tabs.setOnItemSelectedListener {item -> (binding.tabs as? NavigationBarView)?.setOnItemSelectedListener { item ->
item.itemPos()?.let { item.itemPos()?.let {
binding.viewPager.currentItem = it binding.viewPager.currentItem = it
true true
} ?: false } ?: false
} }
binding.tabs.setOnItemReselectedListener { item -> (binding.tabs as? NavigationBarView)?.setOnItemReselectedListener(::reclick)
item.itemPos()?.let { position ->
val page = binding.navigation?.setNavigationItemSelectedListener { item ->
//No clue why this works but it does. F to pay respects if (binding.navigation?.menu?.children?.find { it.itemId == item.itemId }?.isChecked == true) {
supportFragmentManager.findFragmentByTag("f$position") reclick(item)
(page as? CachedFeedFragment<*>)?.onTabReClicked() } else {
item.itemPos()?.let {
binding.navigation?.unSelectAll()
item.isChecked = true
binding.viewPager.currentItem = it
true
} ?: item.buttonPos()
} }
true
} }
// Fetch one notification to show a badge if there are new notifications // Fetch one notification to show a badge if there are new notifications
@ -479,20 +560,13 @@ class MainActivity : BaseActivity() {
} }
} }
private fun setNotificationBadge(show: Boolean, count: Int? = null){ private fun setNotificationBadge(show: Boolean, count: Int? = null) {
//TODO add badge to NavigationView... not implemented yet: https://github.com/material-components/material-components-android/issues/2860
if(show){ if(show){
val badge = binding.tabs.getOrCreateBadge(R.id.page_4) val badge = (binding.tabs as? NavigationBarView)?.getOrCreateBadge(R.id.page_4)
if (count != null) badge.number = count if (count != null) badge?.number = count
} }
else binding.tabs.removeBadge(R.id.page_4) else (binding.tabs as? NavigationBarView)?.removeBadge(R.id.page_4)
}
fun BottomNavigationView.uncheckAllItems() {
menu.setGroupCheckable(0, true, false)
for (i in 0 until menu.size()) {
menu.getItem(i).isChecked = false
}
menu.setGroupCheckable(0, true, true)
} }
/** /**

View File

@ -1,4 +1,4 @@
package org.pixeldroid.app package org.pixeldroid.app.main
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope

View File

@ -31,7 +31,7 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import okhttp3.MultipartBody import okhttp3.MultipartBody
import org.pixeldroid.app.MainActivity import org.pixeldroid.app.main.MainActivity
import org.pixeldroid.app.R import org.pixeldroid.app.R
import org.pixeldroid.app.postCreation.camera.CameraFragment import org.pixeldroid.app.postCreation.camera.CameraFragment
import org.pixeldroid.app.utils.api.objects.Attachment import org.pixeldroid.app.utils.api.objects.Attachment

View File

@ -3,7 +3,7 @@ package org.pixeldroid.app.postCreation.camera
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.view.MenuItem import android.view.MenuItem
import org.pixeldroid.app.MainActivity import org.pixeldroid.app.main.MainActivity
import org.pixeldroid.app.R import org.pixeldroid.app.R
import org.pixeldroid.app.databinding.ActivityCameraBinding import org.pixeldroid.app.databinding.ActivityCameraBinding
import org.pixeldroid.app.postCreation.camera.CameraFragment.Companion.CAMERA_ACTIVITY import org.pixeldroid.app.postCreation.camera.CameraFragment.Companion.CAMERA_ACTIVITY

View File

@ -6,7 +6,7 @@ import android.view.View
import android.view.WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE import android.view.WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE
import android.widget.Toast import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.constraintlayout.widget.ConstraintLayout import androidx.fragment.app.commit
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.pixeldroid.app.R import org.pixeldroid.app.R
@ -23,7 +23,7 @@ import org.pixeldroid.app.utils.api.objects.Status.Companion.VIEW_COMMENTS_TAG
import org.pixeldroid.app.utils.displayDimensionsInPx import org.pixeldroid.app.utils.displayDimensionsInPx
class PostActivity : BaseActivity() { class PostActivity : BaseActivity() {
private lateinit var binding: ActivityPostBinding lateinit var binding: ActivityPostBinding
private lateinit var commentFragment: CommentFragment private lateinit var commentFragment: CommentFragment
@ -33,7 +33,7 @@ class PostActivity : BaseActivity() {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
binding = ActivityPostBinding.inflate(layoutInflater) binding = ActivityPostBinding.inflate(layoutInflater)
commentFragment = CommentFragment(binding.swipeRefreshLayout) commentFragment = CommentFragment()
setContentView(binding.root) setContentView(binding.root)
setSupportActionBar(binding.topBar) setSupportActionBar(binding.topBar)
@ -48,14 +48,15 @@ class PostActivity : BaseActivity() {
supportActionBar?.title = getString(R.string.post_title).format(status.account?.getDisplayName()) supportActionBar?.title = getString(R.string.post_title).format(status.account?.getDisplayName())
val holder = StatusViewHolder(binding.postFragmentSingle) val holder = StatusViewHolder(binding.postFragmentSingle)
val (width, height) = displayDimensionsInPx()
holder.bind( holder.bind(
status, apiHolder, db, lifecycleScope, displayDimensionsInPx(), status, apiHolder, db, lifecycleScope, Pair((width*.7).toInt(), height),
requestPermissionDownloadPic, isActivity = true requestPermissionDownloadPic, isActivity = true
) )
activateCommenter() activateCommenter()
initCommentsFragment(domain = user?.instance_uri.orEmpty()) initCommentsFragment(domain = user?.instance_uri.orEmpty(), savedInstanceState)
if(viewComments || postComment){ if(viewComments || postComment){
//Scroll already down as much as possible (since comments are not loaded yet) //Scroll already down as much as possible (since comments are not loaded yet)
@ -100,15 +101,21 @@ class PostActivity : BaseActivity() {
} }
} }
private fun initCommentsFragment(domain: String) { private fun initCommentsFragment(domain: String, savedInstanceState: Bundle?) {
val arguments = Bundle() val arguments = Bundle()
arguments.putSerializable(COMMENT_STATUS_ID, status.id) arguments.putSerializable(COMMENT_STATUS_ID, status.id)
arguments.putSerializable(COMMENT_DOMAIN, domain) arguments.putSerializable(COMMENT_DOMAIN, domain)
commentFragment.arguments = arguments commentFragment.arguments = arguments
supportFragmentManager.beginTransaction() //TODO finish work here! commentFragment needs the swiperefreshlayout.. how??
.add(R.id.commentFragment, commentFragment).commit() //Maybe read https://archive.ph/G9VHW#selection-1324.2-1322.3 or further research
if (savedInstanceState == null) {
supportFragmentManager.commit {
setReorderingAllowed(true)
replace(R.id.commentFragment, commentFragment)
}
}
binding.swipeRefreshLayout.setOnRefreshListener { binding.swipeRefreshLayout.setOnRefreshListener {
commentFragment.adapter.refresh() commentFragment.adapter.refresh()

View File

@ -2,15 +2,22 @@ package org.pixeldroid.app.posts
import android.os.Bundle import android.os.Bundle
import android.view.View import android.view.View
import androidx.activity.viewModels
import androidx.core.widget.doAfterTextChanged
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import kotlinx.coroutines.launch
import org.pixeldroid.app.R import org.pixeldroid.app.R
import org.pixeldroid.app.databinding.ActivityReportBinding import org.pixeldroid.app.databinding.ActivityReportBinding
import org.pixeldroid.app.posts.ReportActivityViewModel.UploadState
import org.pixeldroid.app.utils.BaseActivity import org.pixeldroid.app.utils.BaseActivity
import org.pixeldroid.app.utils.api.objects.Status import org.pixeldroid.app.utils.api.objects.Status
class ReportActivity : BaseActivity() { class ReportActivity : BaseActivity() {
private lateinit var binding: ActivityReportBinding private lateinit var binding: ActivityReportBinding
private val model: ReportActivityViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -24,42 +31,47 @@ class ReportActivity : BaseActivity() {
binding.reportTargetTextview.text = getString(R.string.report_target).format(status?.account?.acct) binding.reportTargetTextview.text = getString(R.string.report_target).format(status?.account?.acct)
binding.textInputLayout.editText?.text = model.editable
binding.reportButton.setOnClickListener{ binding.textInputLayout.editText?.doAfterTextChanged { model.textChanged(it) }
binding.reportButton.visibility = View.INVISIBLE
binding.reportProgressBar.visibility = View.VISIBLE
binding.textInputLayout.editText?.isEnabled = false binding.reportButton.setOnClickListener {
model.sendReport(status, binding.textInputLayout.editText?.text.toString())
}
val api = apiHolder.api ?: apiHolder.setToCurrentUser() lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.RESUMED) {
lifecycleScope.launchWhenCreated { model.reportSent.collect {
try { reportStatus(it)
api.report(
status?.account?.id!!,
listOf(status),
binding.textInputLayout.editText?.text.toString()
)
reportStatus(true)
} catch (exception: Exception) {
reportStatus(false)
} }
} }
} }
} }
private fun reportStatus(success: Boolean){ private fun reportStatus(success: UploadState){
if(success){ when (success) {
binding.reportProgressBar.visibility = View.GONE UploadState.initial -> {
binding.reportButton.visibility = View.INVISIBLE binding.reportProgressBar.visibility = View.GONE
binding.reportSuccess.visibility = View.VISIBLE binding.reportButton.visibility = View.VISIBLE
} else { binding.reportSuccess.visibility = View.INVISIBLE
binding.textInputLayout.error = getString(R.string.report_error) }
binding.reportButton.visibility = View.VISIBLE UploadState.success -> {
binding.textInputLayout.editText?.isEnabled = true binding.reportProgressBar.visibility = View.GONE
binding.reportProgressBar.visibility = View.GONE binding.reportButton.visibility = View.INVISIBLE
binding.reportSuccess.visibility = View.GONE binding.reportSuccess.visibility = View.VISIBLE
}
UploadState.failed -> {
binding.textInputLayout.error = getString(R.string.report_error)
binding.reportButton.visibility = View.VISIBLE
binding.textInputLayout.editText?.isEnabled = true
binding.reportProgressBar.visibility = View.GONE
binding.reportSuccess.visibility = View.GONE
}
UploadState.inProgress -> {
binding.reportButton.visibility = View.INVISIBLE
binding.reportProgressBar.visibility = View.VISIBLE
binding.textInputLayout.editText?.isEnabled = false
}
} }
} }
} }

View File

@ -0,0 +1,47 @@
package org.pixeldroid.app.posts
import android.text.Editable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import org.pixeldroid.app.utils.api.objects.Status
import org.pixeldroid.app.utils.di.PixelfedAPIHolder
import javax.inject.Inject
@HiltViewModel
class ReportActivityViewModel @Inject constructor(val apiHolder: PixelfedAPIHolder): ViewModel() {
var editable: Editable? = null
private set
private val _reportSent: MutableStateFlow<UploadState> = MutableStateFlow(UploadState.initial)
val reportSent = _reportSent.asStateFlow()
enum class UploadState {
initial, success, failed, inProgress
}
fun textChanged(it: Editable?) {
editable = it
}
fun sendReport(status: Status?, text: String) {
_reportSent.value = UploadState.inProgress
viewModelScope.launch {
val api = apiHolder.api ?: apiHolder.setToCurrentUser()
try {
api.report(
status?.account?.id!!,
listOf(status),
text
)
_reportSent.value = UploadState.success
} catch (exception: Exception) {
_reportSent.value = UploadState.failed
}
}
}
}

View File

@ -152,8 +152,7 @@ class StatusViewHolder(val binding: PostFragmentBinding) : RecyclerView.ViewHold
if(!status?.media_attachments.isNullOrEmpty()) { if(!status?.media_attachments.isNullOrEmpty()) {
setupPostPics(binding, request) setupPostPics(binding, request)
} else { } else {
binding.postPager.visibility = View.GONE binding.postConstraint.visibility = View.GONE
binding.postIndicator.visibility = View.GONE
} }
} }

View File

@ -55,16 +55,16 @@ open class CachedFeedFragment<T: FeedContentDatabase> : BaseFragment() {
//TODO rename function to something that makes sense //TODO rename function to something that makes sense
internal fun initSearch() { internal fun initSearch() {
// Scroll to top when the list is refreshed from network. // Scroll to top when the list is refreshed from network.
lifecycleScope.launchWhenStarted { // lifecycleScope.launchWhenStarted {
adapter.loadStateFlow // adapter.loadStateFlow
// Only emit when REFRESH LoadState for RemoteMediator changes. // // Only emit when REFRESH LoadState for RemoteMediator changes.
.distinctUntilChangedBy { // .distinctUntilChangedBy {
it.refresh // it.refresh
} // }
// Only react to cases where Remote REFRESH completes i.e., NotLoading. // // Only react to cases where Remote REFRESH completes i.e., NotLoading.
.filter { it.refresh is NotLoading} // .filter { it.refresh is NotLoading}
.collect { binding.list.scrollToPosition(0) } // .collect { binding.list.scrollToPosition(0) }
} // }
} }
override fun onCreateView( override fun onCreateView(

View File

@ -40,8 +40,6 @@ class PostFeedFragment<T: FeedContentDatabase>: CachedFeedFragment<T>() {
home = requireArguments().getBoolean("home") home = requireArguments().getBoolean("home")
adapter = PostsAdapter(requireContext().displayDimensionsInPx())
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
if (home){ if (home){
mediator = HomeFeedRemoteMediator(apiHolder, db) as RemoteMediator<Int, T> mediator = HomeFeedRemoteMediator(apiHolder, db) as RemoteMediator<Int, T>
@ -61,6 +59,7 @@ class PostFeedFragment<T: FeedContentDatabase>: CachedFeedFragment<T>() {
inflater: LayoutInflater, container: ViewGroup?, inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?, savedInstanceState: Bundle?,
): View? { ): View? {
adapter = PostsAdapter(requireContext().displayDimensionsInPx())
val view = super.onCreateView(inflater, container, savedInstanceState) val view = super.onCreateView(inflater, container, savedInstanceState)

View File

@ -42,15 +42,15 @@ open class UncachedFeedFragment<T: FeedContent> : BaseFragment() {
} }
internal fun initSearch() { internal fun initSearch() {
// Scroll to top when the list is refreshed from network. // // Scroll to top when the list is refreshed from network.
lifecycleScope.launch { // lifecycleScope.launch {
adapter.loadStateFlow // adapter.loadStateFlow
// Only emit when REFRESH LoadState for RemoteMediator changes. // // Only emit when REFRESH LoadState for RemoteMediator changes.
.distinctUntilChangedBy { it.refresh } // .distinctUntilChangedBy { it.refresh }
// Only react to cases where Remote REFRESH completes i.e., NotLoading. // // Only react to cases where Remote REFRESH completes i.e., NotLoading.
.filter { it.refresh is LoadState.NotLoading } // .filter { it.refresh is LoadState.NotLoading }
.collect { binding?.list?.scrollToPosition(0) } // .collect { binding?.list?.scrollToPosition(0) }
} // }
} }
fun onCreateView( fun onCreateView(

View File

@ -28,7 +28,7 @@ import org.pixeldroid.app.utils.setProfileImageFromURL
/** /**
* Fragment to show a list of [Status]s, in form of comments * Fragment to show a list of [Status]s, in form of comments
*/ */
class CommentFragment(val swipeRefreshLayout: SwipeRefreshLayout): UncachedFeedFragment<Status>() { class CommentFragment: UncachedFeedFragment<Status>() {
private lateinit var id: String private lateinit var id: String
private lateinit var domain: String private lateinit var domain: String
@ -48,8 +48,10 @@ class CommentFragment(val swipeRefreshLayout: SwipeRefreshLayout): UncachedFeedF
savedInstanceState: Bundle?, savedInstanceState: Bundle?,
): View? { ): View? {
val view = super.onCreateView(
val view = super.onCreateView(inflater, container, savedInstanceState, swipeRefreshLayout) inflater, container, savedInstanceState,
(activity as? PostActivity)?.binding?.swipeRefreshLayout
)
// Get the view model // Get the view model
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")

View File

@ -1,18 +1,20 @@
package org.pixeldroid.app.posts.feeds.uncachedFeeds.hashtags package org.pixeldroid.app.posts.feeds.uncachedFeeds.hashtags
import android.os.Bundle import android.os.Bundle
import androidx.fragment.app.add
import androidx.fragment.app.commit
import androidx.fragment.app.replace
import org.pixeldroid.app.R import org.pixeldroid.app.R
import org.pixeldroid.app.databinding.ActivityFollowersBinding import org.pixeldroid.app.databinding.ActivityFollowersBinding
import org.pixeldroid.app.posts.feeds.uncachedFeeds.UncachedPostsFragment import org.pixeldroid.app.posts.feeds.uncachedFeeds.UncachedPostsFragment
import org.pixeldroid.app.posts.feeds.uncachedFeeds.accountLists.AccountListFragment
import org.pixeldroid.app.utils.BaseActivity import org.pixeldroid.app.utils.BaseActivity
import org.pixeldroid.app.utils.api.objects.Tag.Companion.HASHTAG_TAG import org.pixeldroid.app.utils.api.objects.Tag.Companion.HASHTAG_TAG
class HashTagActivity : BaseActivity() { class HashTagActivity : BaseActivity() {
private var tagFragment = UncachedPostsFragment()
private lateinit var binding: ActivityFollowersBinding private lateinit var binding: ActivityFollowersBinding
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
binding = ActivityFollowersBinding.inflate(layoutInflater) binding = ActivityFollowersBinding.inflate(layoutInflater)
@ -32,10 +34,10 @@ class HashTagActivity : BaseActivity() {
val arguments = Bundle() val arguments = Bundle()
arguments.putSerializable(HASHTAG_TAG, tag) arguments.putSerializable(HASHTAG_TAG, tag)
tagFragment.arguments = arguments
supportFragmentManager.beginTransaction()
.add(R.id.followsFragment, tagFragment).commit()
supportFragmentManager.commit {
setReorderingAllowed(true)
replace<UncachedPostsFragment>(R.id.followsFragment, args = arguments)
}
} }
} }

View File

@ -87,7 +87,7 @@ class CollectionActivity : BaseActivity() {
return true return true
} }
override fun onNewIntent(intent: Intent?) { override fun onNewIntent(intent: Intent) {
// Relaunch same activity, to avoid duplicates in history // Relaunch same activity, to avoid duplicates in history
super.onNewIntent(intent) super.onNewIntent(intent)
finish() finish()

View File

@ -1,6 +1,8 @@
package org.pixeldroid.app.profile package org.pixeldroid.app.profile
import android.os.Bundle import android.os.Bundle
import androidx.fragment.app.commit
import androidx.fragment.app.replace
import org.pixeldroid.app.R import org.pixeldroid.app.R
import org.pixeldroid.app.databinding.ActivityFollowersBinding import org.pixeldroid.app.databinding.ActivityFollowersBinding
import org.pixeldroid.app.posts.feeds.uncachedFeeds.accountLists.AccountListFragment import org.pixeldroid.app.posts.feeds.uncachedFeeds.accountLists.AccountListFragment
@ -12,10 +14,8 @@ import org.pixeldroid.app.utils.api.objects.Account.Companion.FOLLOWERS_TAG
class FollowsActivity : BaseActivity() { class FollowsActivity : BaseActivity() {
private var followsFragment = AccountListFragment()
private lateinit var binding: ActivityFollowersBinding private lateinit var binding: ActivityFollowersBinding
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
binding = ActivityFollowersBinding.inflate(layoutInflater) binding = ActivityFollowersBinding.inflate(layoutInflater)
@ -47,10 +47,10 @@ class FollowsActivity : BaseActivity() {
val arguments = Bundle() val arguments = Bundle()
arguments.putSerializable(ACCOUNT_ID_TAG, id) arguments.putSerializable(ACCOUNT_ID_TAG, id)
arguments.putSerializable(FOLLOWERS_TAG, followers) arguments.putSerializable(FOLLOWERS_TAG, followers)
followsFragment.arguments = arguments
supportFragmentManager.beginTransaction()
.add(R.id.followsFragment, followsFragment).commit()
supportFragmentManager.commit {
setReorderingAllowed(true)
replace<AccountListFragment>(R.id.followsFragment, args = arguments)
}
} }
} }

View File

@ -15,7 +15,7 @@ import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat import androidx.preference.PreferenceFragmentCompat
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.pixeldroid.app.MainActivity import org.pixeldroid.app.main.MainActivity
import org.pixeldroid.app.R import org.pixeldroid.app.R
import org.pixeldroid.app.databinding.SettingsBinding import org.pixeldroid.app.databinding.SettingsBinding
import org.pixeldroid.common.ThemedActivity import org.pixeldroid.common.ThemedActivity

View File

@ -1,13 +1,29 @@
package org.pixeldroid.app.utils package org.pixeldroid.app.utils
import android.app.Application import android.app.Application
import androidx.hilt.work.HiltWorkerFactory
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import androidx.work.Configuration
import com.google.android.material.color.DynamicColors import com.google.android.material.color.DynamicColors
import dagger.hilt.EntryPoint
import dagger.hilt.EntryPoints
import dagger.hilt.InstallIn
import dagger.hilt.android.HiltAndroidApp import dagger.hilt.android.HiltAndroidApp
import dagger.hilt.components.SingletonComponent
import org.ligi.tracedroid.TraceDroid import org.ligi.tracedroid.TraceDroid
import javax.inject.Inject
@HiltAndroidApp @HiltAndroidApp
class PixelDroidApplication: Application() { class PixelDroidApplication : Application(), Configuration.Provider {
@EntryPoint
@InstallIn(SingletonComponent::class)
interface HiltWorkerFactoryEntryPoint {
fun workerFactory(): HiltWorkerFactory
}
override val workManagerConfiguration =
Configuration.Builder()
.setWorkerFactory(EntryPoints.get(this, HiltWorkerFactoryEntryPoint::class.java).workerFactory()) .build()
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()

View File

@ -12,36 +12,41 @@ import android.os.Build
import androidx.core.app.ActivityCompat import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import androidx.hilt.work.HiltWorker
import androidx.work.CoroutineWorker import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters import androidx.work.WorkerParameters
import org.pixeldroid.app.MainActivity import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import org.pixeldroid.app.main.MainActivity
import org.pixeldroid.app.R import org.pixeldroid.app.R
import org.pixeldroid.app.posts.PostActivity import org.pixeldroid.app.posts.PostActivity
import org.pixeldroid.app.posts.fromHtml import org.pixeldroid.app.posts.fromHtml
import org.pixeldroid.app.utils.PixelDroidApplication
import org.pixeldroid.app.utils.api.PixelfedAPI.Companion.apiForUser import org.pixeldroid.app.utils.api.PixelfedAPI.Companion.apiForUser
import org.pixeldroid.app.utils.api.objects.Notification import org.pixeldroid.app.utils.api.objects.Notification
import org.pixeldroid.app.utils.api.objects.Notification.NotificationType.* import org.pixeldroid.app.utils.api.objects.Notification.NotificationType.comment
import org.pixeldroid.app.utils.api.objects.Notification.NotificationType.entries
import org.pixeldroid.app.utils.api.objects.Notification.NotificationType.favourite
import org.pixeldroid.app.utils.api.objects.Notification.NotificationType.follow
import org.pixeldroid.app.utils.api.objects.Notification.NotificationType.follow_request
import org.pixeldroid.app.utils.api.objects.Notification.NotificationType.mention
import org.pixeldroid.app.utils.api.objects.Notification.NotificationType.poll
import org.pixeldroid.app.utils.api.objects.Notification.NotificationType.reblog
import org.pixeldroid.app.utils.api.objects.Notification.NotificationType.status
import org.pixeldroid.app.utils.api.objects.Status import org.pixeldroid.app.utils.api.objects.Status
import org.pixeldroid.app.utils.db.AppDatabase import org.pixeldroid.app.utils.db.AppDatabase
import org.pixeldroid.app.utils.db.entities.UserDatabaseEntity import org.pixeldroid.app.utils.db.entities.UserDatabaseEntity
import org.pixeldroid.app.utils.di.PixelfedAPIHolder import org.pixeldroid.app.utils.di.PixelfedAPIHolder
import org.pixeldroid.app.utils.getColorFromAttr import org.pixeldroid.app.utils.getColorFromAttr
import retrofit2.HttpException
import java.io.IOException
import java.time.Instant import java.time.Instant
import javax.inject.Inject
class NotificationsWorker( @HiltWorker
context: Context, class NotificationsWorker @AssistedInject constructor(
params: WorkerParameters @Assisted context: Context,
@Assisted params: WorkerParameters,
private val db: AppDatabase,
private val apiHolder: PixelfedAPIHolder
) : CoroutineWorker(context, params) { ) : CoroutineWorker(context, params) {
@Inject
lateinit var db: AppDatabase
@Inject
lateinit var apiHolder: PixelfedAPIHolder
override suspend fun doWork(): Result { override suspend fun doWork(): Result {
val users: List<UserDatabaseEntity> = db.userDao().getAll() val users: List<UserDatabaseEntity> = db.userDao().getAll()

View File

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#FFFFFF" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M19,13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/>
</vector>

View File

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:autoMirrored="true" android:height="24dp" android:tint="#FFFFFF" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M17,7l-1.41,1.41L18.17,11H8v2h10.17l-2.58,2.58L17,17l5,-5zM4,5h8V3H4c-1.1,0 -2,0.9 -2,2v14c0,1.1 0.9,2 2,2h8v-2H4V5z"/>
</vector>

View File

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#FFFFFF" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M12,12c2.21,0 4,-1.79 4,-4s-1.79,-4 -4,-4 -4,1.79 -4,4 1.79,4 4,4zM12,14c-2.67,0 -8,1.34 -8,4v2h16v-2c0,-2.66 -5.33,-4 -8,-4z"/>
</vector>

View File

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#FFFFFF" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M19.14,12.94c0.04,-0.3 0.06,-0.61 0.06,-0.94c0,-0.32 -0.02,-0.64 -0.07,-0.94l2.03,-1.58c0.18,-0.14 0.23,-0.41 0.12,-0.61l-1.92,-3.32c-0.12,-0.22 -0.37,-0.29 -0.59,-0.22l-2.39,0.96c-0.5,-0.38 -1.03,-0.7 -1.62,-0.94L14.4,2.81c-0.04,-0.24 -0.24,-0.41 -0.48,-0.41h-3.84c-0.24,0 -0.43,0.17 -0.47,0.41L9.25,5.35C8.66,5.59 8.12,5.92 7.63,6.29L5.24,5.33c-0.22,-0.08 -0.47,0 -0.59,0.22L2.74,8.87C2.62,9.08 2.66,9.34 2.86,9.48l2.03,1.58C4.84,11.36 4.8,11.69 4.8,12s0.02,0.64 0.07,0.94l-2.03,1.58c-0.18,0.14 -0.23,0.41 -0.12,0.61l1.92,3.32c0.12,0.22 0.37,0.29 0.59,0.22l2.39,-0.96c0.5,0.38 1.03,0.7 1.62,0.94l0.36,2.54c0.05,0.24 0.24,0.41 0.48,0.41h3.84c0.24,0 0.44,-0.17 0.47,-0.41l0.36,-2.54c0.59,-0.24 1.13,-0.56 1.62,-0.94l2.39,0.96c0.22,0.08 0.47,0 0.59,-0.22l1.92,-3.32c0.12,-0.22 0.07,-0.47 -0.12,-0.61L19.14,12.94zM12,15.6c-1.98,0 -3.6,-1.62 -3.6,-3.6s1.62,-3.6 3.6,-3.6s3.6,1.62 3.6,3.6S13.98,15.6 12,15.6z"/>
</vector>

View File

@ -0,0 +1,58 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.drawerlayout.widget.DrawerLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/drawer_layout"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context="org.pixeldroid.app.main.MainActivity">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:id="@+id/main_activity_main_linear_layout"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:background="?android:attr/selectableItemBackground"
android:orientation="vertical"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.0"
app:layout_constraintStart_toStartOf="parent">
<ImageButton
android:id="@+id/main_drawer_button"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/colorSurface"
android:contentDescription="@string/open_drawer_menu"
android:src="@drawable/ic_baseline_menu_24" />
<com.google.android.material.navigationrail.NavigationRailView
android:id="@+id/tabs"
android:layout_width="wrap_content"
android:layout_height="match_parent"
app:menu="@menu/navigation_main" />
</LinearLayout>
<androidx.viewpager2.widget.ViewPager2
android:id="@+id/view_pager"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/main_activity_main_linear_layout"
app:layout_constraintTop_toTopOf="parent"/>
</androidx.constraintlayout.widget.ConstraintLayout>
<com.mikepenz.materialdrawer.widget.MaterialDrawerSliderView
android:id="@+id/drawer"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_gravity="start"
android:fitsSystemWindows="true" />
</androidx.drawerlayout.widget.DrawerLayout>

View File

@ -0,0 +1,258 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:sparkbutton="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="5dp"
android:layout_marginBottom="5dp">
<ImageView
android:id="@+id/profilePic"
android:layout_width="50dp"
android:layout_height="50dp"
android:layout_marginStart="10dp"
android:layout_marginTop="10dp"
android:layout_marginEnd="10dp"
android:contentDescription="@string/profile_picture"
android:src="@drawable/ic_default_user"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/username"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
app:layout_constraintBottom_toBottomOf="@+id/profilePic"
app:layout_constraintStart_toEndOf="@+id/profilePic"
app:layout_constraintTop_toTopOf="@+id/profilePic"
tools:text="username" />
<TextView
android:id="@+id/postDomain"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:textColor="#b3b3b3"
app:layout_constraintBottom_toBottomOf="@+id/profilePic"
app:layout_constraintStart_toEndOf="@+id/username"
app:layout_constraintTop_toTopOf="@+id/profilePic"
tools:text="from domain.tld" />
<ImageButton
android:id="@+id/status_more"
style="?android:attr/actionOverflowButtonStyle"
android:layout_width="24dp"
android:layout_height="30dp"
android:layout_marginEnd="16dp"
android:contentDescription="@string/status_more_options"
android:padding="4dp"
app:layout_constraintBottom_toBottomOf="@+id/profilePic"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@+id/profilePic" />
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/postConstraint"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
app:layout_constraintEnd_toStartOf="@+id/postDetails"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintHorizontal_chainStyle="packed"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/profilePic"
app:layout_constraintWidth_default="percent"
app:layout_constraintWidth_percent="0.7">
<org.pixeldroid.app.posts.NestedScrollableHost
android:id="@+id/postPagerHost"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="1.0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<androidx.viewpager2.widget.ViewPager2
android:id="@+id/postPager"
android:layout_width="match_parent"
android:layout_height="200dp"
android:orientation="horizontal"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</org.pixeldroid.app.posts.NestedScrollableHost>
<me.relex.circleindicator.CircleIndicator3
android:id="@+id/postIndicator"
android:layout_width="wrap_content"
android:layout_height="32dp"
app:layout_constraintBottom_toBottomOf="@+id/postPagerHost"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
<ImageView
android:id="@+id/like_animation"
android:layout_width="100dp"
android:layout_height="100dp"
android:src="@drawable/heart_anim"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="@+id/postPagerHost"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@+id/postPagerHost"
tools:ignore="ContentDescription"
tools:visibility="visible" />
<FrameLayout
android:id="@+id/post_fragment_image_popup_menu_anchor"
android:layout_width="1dp"
android:layout_height="1dp"
app:layout_constraintBottom_toBottomOf="@+id/postPagerHost"
app:layout_constraintEnd_toEndOf="@+id/postPagerHost"
app:layout_constraintHorizontal_bias="0.1"
app:layout_constraintStart_toStartOf="@+id/postPagerHost"
app:layout_constraintTop_toTopOf="@+id/postPagerHost"
app:layout_constraintVertical_bias="0.1" />
<TextView
android:id="@+id/sensitiveWarning"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:adjustViewBounds="true"
android:background="@drawable/rounded_corner"
android:gravity="center|center_horizontal|center_vertical"
android:text="@string/cw_nsfw_hidden_media_n_click_to_show"
android:textAppearance="@style/TextAppearance.AppCompat.Medium"
android:textColor="@color/ic_launcher_background"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="@+id/postPagerHost"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@+id/postPagerHost"
tools:visibility="visible" />
</androidx.constraintlayout.widget.ConstraintLayout>
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/postDetails"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/postConstraint"
app:layout_constraintTop_toTopOf="@id/postConstraint">
<ImageView
android:id="@+id/commenter"
android:layout_width="30dp"
android:layout_height="30dp"
android:contentDescription="@string/add_comment"
android:padding="4dp"
android:src="@drawable/selector_commenter"
app:layout_constraintBottom_toBottomOf="@+id/liker"
app:layout_constraintEnd_toStartOf="@id/reblogger"
app:layout_constraintStart_toEndOf="@id/liker"
app:layout_constraintTop_toTopOf="@id/liker" />
<at.connyduck.sparkbutton.SparkButton
android:id="@+id/liker"
android:layout_width="30dp"
android:layout_height="30dp"
android:clipToPadding="false"
android:padding="4dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/commenter"
app:layout_constraintHorizontal_chainStyle="spread"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.20"
sparkbutton:activeImage="@drawable/ic_like_full"
sparkbutton:iconSize="28dp"
sparkbutton:inactiveImage="@drawable/ic_like_empty"
sparkbutton:primaryColor="@color/heart_red"
sparkbutton:secondaryColor="@color/black" />
<at.connyduck.sparkbutton.SparkButton
android:id="@+id/reblogger"
android:layout_width="30dp"
android:layout_height="30dp"
android:clipToPadding="false"
android:padding="4dp"
app:layout_constraintBottom_toBottomOf="@+id/commenter"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/commenter"
app:layout_constraintTop_toTopOf="@+id/commenter"
sparkbutton:activeImage="@drawable/ic_reblog_blue"
sparkbutton:iconSize="28dp"
sparkbutton:inactiveImage="@drawable/ic_reblog"
sparkbutton:primaryColor="@color/share_blue"
sparkbutton:secondaryColor="@color/black" />
<TextView
android:id="@+id/nlikes"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_weight="50"
app:layout_constraintEnd_toEndOf="@+id/liker"
app:layout_constraintStart_toStartOf="@+id/liker"
app:layout_constraintTop_toBottomOf="@+id/liker"
tools:text="2 Likes" />
<TextView
android:id="@+id/nshares"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_weight="50"
android:gravity="end"
app:layout_constraintEnd_toEndOf="@+id/reblogger"
app:layout_constraintStart_toStartOf="@+id/reblogger"
app:layout_constraintTop_toBottomOf="@+id/reblogger"
tools:text="3 Shares" />
<TextView
android:id="@+id/usernameDesc"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="10dp"
android:layout_marginTop="20dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/nlikes"
tools:text="Account" />
<TextView
android:id="@+id/description"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:hyphenationFrequency="full"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@+id/usernameDesc"
app:layout_constraintTop_toBottomOf="@+id/usernameDesc"
tools:text="This is a description, describing stuff.\nIt contains multiple lines, and that's okay. It's also got some really long lines, and we love it for it." />
<TextView
android:id="@+id/postDate"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:textColor="#b3b3b3"
app:layout_constraintStart_toStartOf="@+id/usernameDesc"
app:layout_constraintTop_toBottomOf="@+id/description"
tools:text="Yesterday" />
<TextView
android:id="@+id/viewComments"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="14dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="@+id/postDate"
app:layout_constraintTop_toBottomOf="@+id/postDate"
app:layout_constraintVertical_bias="0.0"
tools:text="3 comments" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -0,0 +1,51 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.drawerlayout.widget.DrawerLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/drawer_layout"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context="org.pixeldroid.app.main.MainActivity">
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/mainConstraint"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/accountList"
android:layout_width="0dp"
android:layout_height="0dp"
android:background="?attr/colorSurfaceContainerLow"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
android:elevation="10dp"
android:visibility="invisible"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="@+id/navigation"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:visibility="visible" />
<com.google.android.material.navigation.NavigationView
android:id="@+id/navigation"
android:layout_width="wrap_content"
android:layout_height="match_parent"
app:headerLayout="@layout/header_navigation"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.0"
app:menu="@menu/navigation_main" />
<androidx.viewpager2.widget.ViewPager2
android:id="@+id/view_pager"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/navigation"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.drawerlayout.widget.DrawerLayout>

View File

@ -0,0 +1,62 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.drawerlayout.widget.DrawerLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/drawer_layout"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context="org.pixeldroid.app.main.MainActivity">
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/mainConstraint"
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:id="@+id/main_activity_main_linear_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?android:attr/selectableItemBackground"
android:orientation="horizontal"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toStartOf="parent">
<ImageButton
android:id="@+id/main_drawer_button"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:background="?attr/colorSurfaceContainer"
android:contentDescription="@string/open_drawer_menu"
android:padding="12dp"
android:src="@drawable/ic_baseline_menu_24" />
<com.google.android.material.bottomnavigation.BottomNavigationView
android:id="@+id/tabs"
app:elevation="0dp"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:menu="@menu/navigation_main" />
</LinearLayout>
<androidx.viewpager2.widget.ViewPager2
android:id="@+id/view_pager"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toTopOf="@+id/main_activity_main_linear_layout"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"/>
</androidx.constraintlayout.widget.ConstraintLayout>
<com.mikepenz.materialdrawer.widget.MaterialDrawerSliderView
android:id="@+id/drawer"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_gravity="start"
android:fitsSystemWindows="true" />
</androidx.drawerlayout.widget.DrawerLayout>

View File

@ -0,0 +1,38 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
xmlns:tools="http://schemas.android.com/tools">
<ImageView
android:id="@+id/imageView"
android:layout_width="50dp"
android:layout_margin="12dp"
android:layout_height="50dp"
tools:src="@drawable/ic_launcher_foreground"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/accountName"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
tools:text="Account name"
android:layout_marginStart="12dp"
android:textSize="18sp"
app:layout_constraintBottom_toTopOf="@id/accountUsername"
app:layout_constraintStart_toEndOf="@+id/imageView"
app:layout_constraintTop_toTopOf="@+id/imageView" />
<TextView
android:id="@+id/accountUsername"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
tools:text="\@account@instance.tld"
android:textSize="14sp"
app:layout_constraintBottom_toBottomOf="@+id/imageView"
app:layout_constraintStart_toEndOf="@+id/imageView"
app:layout_constraintTop_toBottomOf="@+id/accountName" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -4,7 +4,7 @@
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
tools:context=".LoginActivity"> tools:context=".login.LoginActivity">
<ScrollView <ScrollView
android:layout_width="match_parent" android:layout_width="match_parent"
@ -67,30 +67,6 @@
</com.google.android.material.textfield.TextInputLayout> </com.google.android.material.textfield.TextInputLayout>
<LinearLayout
android:id="@+id/login_activity_connection_required"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:orientation="vertical"
android:visibility="gone">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/login_connection_required_once"
android:textAlignment="center"/>
<Button
android:id="@+id/login_activity_connection_required_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="15dp"
android:text="@string/retry"/>
</LinearLayout>
<LinearLayout <LinearLayout
android:id="@+id/progressLayout" android:id="@+id/progressLayout"
android:layout_width="match_parent" android:layout_width="match_parent"

View File

@ -5,7 +5,7 @@
android:id="@+id/drawer_layout" android:id="@+id/drawer_layout"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
tools:context="org.pixeldroid.app.MainActivity"> tools:context="org.pixeldroid.app.main.MainActivity">
<androidx.constraintlayout.widget.ConstraintLayout <androidx.constraintlayout.widget.ConstraintLayout
@ -37,7 +37,7 @@
app:elevation="0dp" app:elevation="0dp"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
app:menu="@menu/bottom_navigation_main" /> app:menu="@menu/navigation_main" />
</LinearLayout> </LinearLayout>
<androidx.viewpager2.widget.ViewPager2 <androidx.viewpager2.widget.ViewPager2

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<com.mikepenz.materialdrawer.widget.AccountHeaderView android:layout_width="match_parent"
android:layout_height="wrap_content"
xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/headerview"/>

View File

@ -1,238 +1,238 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:sparkbutton="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="5dp" android:layout_marginTop="5dp"
android:layout_marginBottom="5dp" android:layout_marginBottom="5dp">
xmlns:sparkbutton="http://schemas.android.com/apk/res-auto">
<ImageView <ImageView
android:id="@+id/profilePic" android:id="@+id/profilePic"
android:layout_width="50dp" android:layout_width="50dp"
android:layout_height="50dp" android:layout_height="50dp"
android:layout_marginTop="10dp" android:layout_marginStart="10dp"
android:layout_marginStart="10dp" android:layout_marginTop="10dp"
android:layout_marginEnd="10dp" android:layout_marginEnd="10dp"
android:src="@drawable/ic_default_user" android:contentDescription="@string/profile_picture"
app:layout_constraintStart_toStartOf="parent" android:src="@drawable/ic_default_user"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintStart_toStartOf="parent"
android:contentDescription="@string/profile_picture" /> app:layout_constraintTop_toTopOf="parent" />
<TextView <TextView
android:id="@+id/username" android:id="@+id/username"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginStart="12dp" android:layout_marginStart="12dp"
app:layout_constraintBottom_toBottomOf="@+id/profilePic" app:layout_constraintBottom_toBottomOf="@+id/profilePic"
app:layout_constraintStart_toEndOf="@+id/profilePic" app:layout_constraintStart_toEndOf="@+id/profilePic"
app:layout_constraintTop_toTopOf="@+id/profilePic" app:layout_constraintTop_toTopOf="@+id/profilePic"
tools:text="username" /> tools:text="username" />
<TextView <TextView
android:id="@+id/postDomain" android:id="@+id/postDomain"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginStart="8dp" android:layout_marginStart="8dp"
android:textColor="#b3b3b3" android:textColor="#b3b3b3"
app:layout_constraintBottom_toBottomOf="@+id/profilePic" app:layout_constraintBottom_toBottomOf="@+id/profilePic"
app:layout_constraintStart_toEndOf="@+id/username" app:layout_constraintStart_toEndOf="@+id/username"
app:layout_constraintTop_toTopOf="@+id/profilePic" app:layout_constraintTop_toTopOf="@+id/profilePic"
tools:text="from domain.tld" /> tools:text="from domain.tld" />
<androidx.constraintlayout.widget.ConstraintLayout <androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/postConstraint" android:id="@+id/postConstraint"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
app:layout_constraintTop_toBottomOf="@+id/profilePic">
<org.pixeldroid.app.posts.NestedScrollableHost
android:id="@+id/postPagerHost"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<androidx.viewpager2.widget.ViewPager2
android:id="@+id/postPager"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="200dp"
android:layout_marginTop="10dp" android:orientation="horizontal"
app:layout_constraintTop_toBottomOf="@+id/profilePic">
<org.pixeldroid.app.posts.NestedScrollableHost
android:id="@+id/postPagerHost"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<androidx.viewpager2.widget.ViewPager2
android:id="@+id/postPager"
android:layout_width="match_parent"
android:layout_height="200dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
android:orientation="horizontal" />
</org.pixeldroid.app.posts.NestedScrollableHost>
<me.relex.circleindicator.CircleIndicator3
android:id="@+id/postIndicator"
android:layout_width="wrap_content"
android:layout_height="32dp"
app:layout_constraintBottom_toBottomOf="@+id/postPagerHost"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"/>
<ImageView
android:id="@+id/like_animation"
android:layout_width="100dp"
android:layout_height="100dp"
android:src="@drawable/heart_anim"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="@+id/postPagerHost"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@+id/postPagerHost"
tools:ignore="ContentDescription"
tools:visibility="visible" />
<FrameLayout
android:id="@+id/post_fragment_image_popup_menu_anchor"
android:layout_width="1dp"
android:layout_height="1dp"
app:layout_constraintBottom_toBottomOf="@+id/postPagerHost"
app:layout_constraintEnd_toEndOf="@+id/postPagerHost"
app:layout_constraintHorizontal_bias="0.1"
app:layout_constraintStart_toStartOf="@+id/postPagerHost"
app:layout_constraintTop_toTopOf="@+id/postPagerHost"
app:layout_constraintVertical_bias="0.1" />
<TextView
android:id="@+id/sensitiveWarning"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:adjustViewBounds="true"
android:background="@drawable/rounded_corner"
android:gravity="center|center_horizontal|center_vertical"
android:text="@string/cw_nsfw_hidden_media_n_click_to_show"
android:textAppearance="@style/TextAppearance.AppCompat.Medium"
android:textColor="@color/ic_launcher_background"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="@+id/postPagerHost"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@+id/postPagerHost"
tools:visibility="visible"/>
</androidx.constraintlayout.widget.ConstraintLayout>
<ImageView
android:id="@+id/commenter"
android:layout_width="30dp"
android:layout_height="30dp"
android:padding="4dp"
android:src="@drawable/selector_commenter"
app:layout_constraintBottom_toBottomOf="@+id/liker"
app:layout_constraintEnd_toStartOf="@id/reblogger"
app:layout_constraintStart_toEndOf="@id/liker"
app:layout_constraintTop_toTopOf="@id/liker"
android:contentDescription="@string/add_comment" />
<at.connyduck.sparkbutton.SparkButton
android:id="@+id/liker"
android:layout_width="30dp"
android:layout_height="30dp"
android:layout_marginTop="4dp"
android:layout_marginBottom="4dp"
android:clipToPadding="false"
android:padding="4dp"
sparkbutton:activeImage="@drawable/ic_like_full"
sparkbutton:iconSize="28dp"
sparkbutton:inactiveImage="@drawable/ic_like_empty"
sparkbutton:primaryColor="@color/heart_red"
sparkbutton:secondaryColor="@color/black"
app:layout_constraintEnd_toStartOf="@id/commenter"
app:layout_constraintHorizontal_chainStyle="spread"
app:layout_constraintStart_toStartOf="@id/profilePic"
app:layout_constraintTop_toBottomOf="@id/postConstraint"/>
<at.connyduck.sparkbutton.SparkButton
android:id="@+id/reblogger"
android:layout_width="30dp"
android:layout_height="30dp"
android:clipToPadding="false"
android:padding="4dp"
app:layout_constraintBottom_toBottomOf="@+id/commenter"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/commenter"
app:layout_constraintTop_toTopOf="@+id/commenter"
sparkbutton:activeImage="@drawable/ic_reblog_blue"
sparkbutton:iconSize="28dp"
sparkbutton:inactiveImage="@drawable/ic_reblog"
sparkbutton:primaryColor="@color/share_blue"
sparkbutton:secondaryColor="@color/black"/>
<ImageButton
android:id="@+id/status_more"
android:layout_width="24dp"
android:layout_height="30dp"
android:layout_marginEnd="16dp"
android:contentDescription="@string/status_more_options"
android:padding="4dp"
style="?android:attr/actionOverflowButtonStyle"
app:layout_constraintBottom_toBottomOf="@+id/postDomain"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@+id/postDomain" />
<TextView
android:id="@+id/nlikes"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_weight="50"
app:layout_constraintEnd_toEndOf="@+id/liker"
app:layout_constraintStart_toStartOf="@+id/liker"
app:layout_constraintTop_toBottomOf="@+id/liker"
tools:text="2 Likes" />
<TextView
android:id="@+id/nshares"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_weight="50"
android:gravity="end"
app:layout_constraintEnd_toEndOf="@+id/reblogger"
app:layout_constraintStart_toStartOf="@+id/reblogger"
app:layout_constraintTop_toBottomOf="@+id/reblogger"
tools:text="3 Shares" />
<TextView
android:id="@+id/usernameDesc"
android:layout_height="wrap_content"
android:layout_width="wrap_content"
app:layout_constraintStart_toStartOf="@+id/profilePic"
app:layout_constraintTop_toBottomOf="@+id/nlikes"
tools:text="Account"/>
<TextView
android:id="@+id/description"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:hyphenationFrequency="full"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@+id/usernameDesc"
app:layout_constraintTop_toBottomOf="@+id/usernameDesc"
tools:text="This is a description, describing stuff.\nIt contains multiple lines, and that's okay. It's also got some really long lines, and we love it for it." />
<TextView
android:id="@+id/postDate"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="10dp"
android:textColor="#b3b3b3"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/description" app:layout_constraintTop_toTopOf="parent" />
tools:text="Yesterday" />
<TextView </org.pixeldroid.app.posts.NestedScrollableHost>
android:id="@+id/viewComments"
android:layout_width="match_parent" <me.relex.circleindicator.CircleIndicator3
android:layout_height="wrap_content" android:id="@+id/postIndicator"
android:layout_marginStart="10dp" android:layout_width="wrap_content"
app:layout_constraintStart_toStartOf="parent" android:layout_height="32dp"
app:layout_constraintTop_toBottomOf="@+id/postDate" app:layout_constraintBottom_toBottomOf="@+id/postPagerHost"
tools:text="3 comments" /> app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
<ImageView
android:id="@+id/like_animation"
android:layout_width="100dp"
android:layout_height="100dp"
android:src="@drawable/heart_anim"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="@+id/postPagerHost"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@+id/postPagerHost"
tools:ignore="ContentDescription"
tools:visibility="visible" />
<FrameLayout
android:id="@+id/post_fragment_image_popup_menu_anchor"
android:layout_width="1dp"
android:layout_height="1dp"
app:layout_constraintBottom_toBottomOf="@+id/postPagerHost"
app:layout_constraintEnd_toEndOf="@+id/postPagerHost"
app:layout_constraintHorizontal_bias="0.1"
app:layout_constraintStart_toStartOf="@+id/postPagerHost"
app:layout_constraintTop_toTopOf="@+id/postPagerHost"
app:layout_constraintVertical_bias="0.1" />
<TextView
android:id="@+id/sensitiveWarning"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:adjustViewBounds="true"
android:background="@drawable/rounded_corner"
android:gravity="center|center_horizontal|center_vertical"
android:text="@string/cw_nsfw_hidden_media_n_click_to_show"
android:textAppearance="@style/TextAppearance.AppCompat.Medium"
android:textColor="@color/ic_launcher_background"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="@+id/postPagerHost"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@+id/postPagerHost"
tools:visibility="visible" />
</androidx.constraintlayout.widget.ConstraintLayout>
<ImageView
android:id="@+id/commenter"
android:layout_width="30dp"
android:layout_height="30dp"
android:contentDescription="@string/add_comment"
android:padding="4dp"
android:src="@drawable/selector_commenter"
app:layout_constraintBottom_toBottomOf="@+id/liker"
app:layout_constraintEnd_toStartOf="@id/reblogger"
app:layout_constraintStart_toEndOf="@id/liker"
app:layout_constraintTop_toTopOf="@id/liker" />
<at.connyduck.sparkbutton.SparkButton
android:id="@+id/liker"
android:layout_width="30dp"
android:layout_height="30dp"
android:layout_marginTop="4dp"
android:layout_marginBottom="4dp"
android:clipToPadding="false"
android:padding="4dp"
app:layout_constraintEnd_toStartOf="@id/commenter"
app:layout_constraintHorizontal_chainStyle="spread"
app:layout_constraintStart_toStartOf="@id/profilePic"
app:layout_constraintTop_toBottomOf="@id/postConstraint"
sparkbutton:activeImage="@drawable/ic_like_full"
sparkbutton:iconSize="28dp"
sparkbutton:inactiveImage="@drawable/ic_like_empty"
sparkbutton:primaryColor="@color/heart_red"
sparkbutton:secondaryColor="@color/black" />
<at.connyduck.sparkbutton.SparkButton
android:id="@+id/reblogger"
android:layout_width="30dp"
android:layout_height="30dp"
android:clipToPadding="false"
android:padding="4dp"
app:layout_constraintBottom_toBottomOf="@+id/commenter"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/commenter"
app:layout_constraintTop_toTopOf="@+id/commenter"
sparkbutton:activeImage="@drawable/ic_reblog_blue"
sparkbutton:iconSize="28dp"
sparkbutton:inactiveImage="@drawable/ic_reblog"
sparkbutton:primaryColor="@color/share_blue"
sparkbutton:secondaryColor="@color/black" />
<ImageButton
android:id="@+id/status_more"
style="?android:attr/actionOverflowButtonStyle"
android:layout_width="24dp"
android:layout_height="30dp"
android:layout_marginEnd="16dp"
android:contentDescription="@string/status_more_options"
android:padding="4dp"
app:layout_constraintBottom_toBottomOf="@+id/postDomain"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@+id/postDomain" />
<TextView
android:id="@+id/nlikes"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_weight="50"
app:layout_constraintEnd_toEndOf="@+id/liker"
app:layout_constraintStart_toStartOf="@+id/liker"
app:layout_constraintTop_toBottomOf="@+id/liker"
tools:text="2 Likes" />
<TextView
android:id="@+id/nshares"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_weight="50"
android:gravity="end"
app:layout_constraintEnd_toEndOf="@+id/reblogger"
app:layout_constraintStart_toStartOf="@+id/reblogger"
app:layout_constraintTop_toBottomOf="@+id/reblogger"
tools:text="3 Shares" />
<TextView
android:id="@+id/usernameDesc"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintStart_toStartOf="@+id/profilePic"
app:layout_constraintTop_toBottomOf="@+id/nlikes"
tools:text="Account" />
<TextView
android:id="@+id/description"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:hyphenationFrequency="full"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@+id/usernameDesc"
app:layout_constraintTop_toBottomOf="@+id/usernameDesc"
tools:text="This is a description, describing stuff.\nIt contains multiple lines, and that's okay. It's also got some really long lines, and we love it for it." />
<TextView
android:id="@+id/postDate"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="10dp"
android:textColor="#b3b3b3"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/description"
tools:text="Yesterday" />
<TextView
android:id="@+id/viewComments"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="10dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/postDate"
tools:text="3 comments" />
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -0,0 +1,46 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<group android:id="@+id/tabsId">
<item
android:id="@+id/page_1"
android:enabled="true"
android:icon="@drawable/selector_home_feed"
android:title="@string/home_feed"/>
<item
android:id="@+id/page_2"
android:enabled="true"
android:icon="@drawable/ic_search_white_24dp"
android:title="@string/search_discover_feed"/>
<item
android:id="@+id/page_3"
android:enabled="true"
android:icon="@drawable/selector_camera"
android:title="@string/create_feed"/>
<item
android:id="@+id/page_4"
android:enabled="true"
android:icon="@drawable/selector_notifications"
android:title="@string/notifications_feed"/>
<item
android:id="@+id/page_5"
android:enabled="true"
android:icon="@drawable/ic_filter_black_24dp"
android:title="@string/public_feed"/>
</group>
<item
android:id="@+id/my_profile"
android:enabled="true"
android:icon="@drawable/person"
android:title="@string/menu_account"/>
<item
android:id="@+id/settings"
android:enabled="true"
android:icon="@drawable/settings"
android:title="@string/menu_settings"/>
<item
android:id="@+id/log_out"
android:enabled="true"
android:icon="@drawable/logout"
android:title="@string/logout"/>
</menu>

View File

@ -180,8 +180,8 @@ For more info about Pixelfed, you can check here: https://pixelfed.org"</string>
<item quantity="other">"%d\nFollowing"</item> <item quantity="other">"%d\nFollowing"</item>
</plurals> </plurals>
<string name="edit">Edit</string> <string name="edit">Edit</string>
<string name="save_image_failed">Unable to save image</string> <string name="save_image_failed">Unable to save</string>
<string name="save_image_success">Image successfully saved</string> <string name="save_image_success">Saved successfully</string>
<string name="follow_status_failed">Could not get follow status</string> <string name="follow_status_failed">Could not get follow status</string>
<string name="edit_link_failed">Failed to open edit page</string> <string name="edit_link_failed">Failed to open edit page</string>
<string name="new_collection_link_failed">Failed to open collection creation page</string> <string name="new_collection_link_failed">Failed to open collection creation page</string>

View File

@ -6,7 +6,7 @@ buildscript {
mavenCentral() mavenCentral()
} }
dependencies { dependencies {
classpath 'com.android.tools.build:gradle:8.3.1' classpath 'com.android.tools.build:gradle:8.5.2'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
// NOTE: Do not place your application dependencies here; they belong // NOTE: Do not place your application dependencies here; they belong

View File

@ -0,0 +1,4 @@
* Support for tablets, and rotation of all screens. There could still be some issues, please send us a message if there are
* Bug fixes: notably, fix notifications which broke a couple releases back.
* Translation updates
* Update dependencies

View File

@ -2,6 +2,6 @@
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionSha256Sum=3e1af3ae886920c3ac87f7a91f816c0c7c436f276a6eefdb3da152100fef72ae distributionSha256Sum=3e1af3ae886920c3ac87f7a91f816c0c7c436f276a6eefdb3da152100fef72ae
distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists