Super rudimentary support for stories

This commit is contained in:
Matthieu 2023-11-03 18:49:44 +01:00
parent 6876b1c449
commit 888c6328d9
18 changed files with 426 additions and 6888 deletions

View File

@ -131,14 +131,14 @@ android {
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.2'
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.3'
/**
* AndroidX dependencies:
*/
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'androidx.core:core-splashscreen:1.0.0'
implementation 'androidx.core:core-ktx:1.9.0'
implementation 'androidx.core:core-splashscreen:1.0.1'
implementation 'androidx.core:core-ktx:1.10.0'
implementation 'androidx.preference:preference-ktx:1.2.0'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation 'androidx.navigation:navigation-fragment-ktx:2.5.3'
@ -156,8 +156,8 @@ dependencies {
implementation "androidx.lifecycle:lifecycle-common-java8:2.6.1"
implementation "androidx.annotation:annotation:1.6.0"
implementation 'androidx.gridlayout:gridlayout:1.0.0'
implementation "androidx.activity:activity-ktx:1.7.0"
implementation 'androidx.fragment:fragment-ktx:1.5.6'
implementation "androidx.activity:activity-ktx:1.7.1"
implementation 'androidx.fragment:fragment-ktx:1.5.7'
implementation 'androidx.work:work-runtime-ktx:2.8.1'
implementation 'androidx.media2:media2-widget:1.2.1'
implementation 'androidx.media2:media2-player:1.2.1'

View File

@ -64,6 +64,9 @@
android:name=".posts.ReportActivity"
android:screenOrientation="sensorPortrait"
tools:ignore="LockedOrientationActivity" />
<activity
android:name=".stories.StoriesActivity" />
<activity
android:name=".postCreation.PostCreationActivity"
android:exported="true"

View File

@ -50,6 +50,7 @@ import org.pixeldroid.app.posts.feeds.cachedFeeds.postFeeds.PostFeedFragment
import org.pixeldroid.app.profile.ProfileActivity
import org.pixeldroid.app.searchDiscover.SearchDiscoverFragment
import org.pixeldroid.app.settings.SettingsActivity
import org.pixeldroid.app.stories.StoriesActivity
import org.pixeldroid.app.utils.BaseThemedWithoutBarActivity
import org.pixeldroid.app.utils.api.objects.Notification
import org.pixeldroid.app.utils.db.addUser
@ -229,12 +230,18 @@ class MainActivity : BaseThemedWithoutBarActivity() {
primaryDrawerItem {
nameRes = R.string.logout
iconicsIcon = GoogleMaterial.Icon.gmd_close
})
},
primaryDrawerItem {
nameRes = R.string.story_image
iconicsIcon = GoogleMaterial.Icon.gmd_auto_stories
},
)
binding.drawer.onDrawerItemClickListener = { v, drawerItem, position ->
when (position){
1 -> launchActivity(ProfileActivity())
2 -> launchActivity(SettingsActivity())
3 -> logOut()
4 -> launchActivity(StoriesActivity())
}
false
}

View File

@ -195,8 +195,10 @@ class PostCreationViewModel(
* and display it.
*/
val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE)
cursor.moveToFirst()
cursor.getLong(sizeIndex)
if(sizeIndex >= 0) {
cursor.moveToFirst()
cursor.getLong(sizeIndex)
} else null
} ?: 0
} else {
uri.toFile().length()

View File

@ -130,7 +130,7 @@ fun parseHTMLText(
}
fun setTextViewFromISO8601(date: Instant, textView: TextView, absoluteTime: Boolean, context: Context) {
fun setTextViewFromISO8601(date: Instant, textView: TextView, absoluteTime: Boolean) {
val now = Date.from(Instant.now()).time
try {
@ -140,7 +140,7 @@ fun setTextViewFromISO8601(date: Instant, textView: TextView, absoluteTime: Bool
android.text.format.DateUtils.SECOND_IN_MILLIS,
android.text.format.DateUtils.FORMAT_ABBREV_RELATIVE).toString()
textView.text = if(absoluteTime) context.getString(R.string.posted_on).format(date)
textView.text = if(absoluteTime) textView.context.getString(R.string.posted_on).format(date)
else formattedDate
} catch (e: ParseException) {

View File

@ -139,8 +139,7 @@ class StatusViewHolder(val binding: PostFragmentBinding) : RecyclerView.ViewHold
setTextViewFromISO8601(
status?.created_at!!,
binding.postDate,
isActivity,
binding.root.context
isActivity
)
binding.postDomain.text = status?.getStatusDomain(domain, binding.postDomain.context)

View File

@ -221,8 +221,7 @@ class NotificationsFragment : CachedFeedFragment<Notification>() {
setTextViewFromISO8601(
it,
notificationTime,
false,
itemView.context
false
)
}

View File

@ -0,0 +1,110 @@
package org.pixeldroid.app.stories
import android.graphics.drawable.Drawable
import android.os.Build
import android.os.Bundle
import androidx.activity.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import com.bumptech.glide.Glide
import com.bumptech.glide.load.DataSource
import com.bumptech.glide.load.engine.GlideException
import com.bumptech.glide.request.RequestListener
import com.bumptech.glide.request.RequestOptions
import com.bumptech.glide.request.target.Target
import kotlinx.coroutines.launch
import org.pixeldroid.app.databinding.ActivityStoriesBinding
import org.pixeldroid.app.posts.setTextViewFromISO8601
import org.pixeldroid.app.utils.BaseThemedWithoutBarActivity
class StoriesActivity: BaseThemedWithoutBarActivity() {
private lateinit var binding: ActivityStoriesBinding
private lateinit var model: StoriesViewModel
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityStoriesBinding.inflate(layoutInflater)
setContentView(binding.root)
val _model: StoriesViewModel by viewModels {
StoriesViewModelFactory(application)
}
model = _model
lifecycleScope.launch {
lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
model.uiState.collect { uiState ->
binding.pause.isSelected = uiState.paused
uiState.age?.let { setTextViewFromISO8601(it, binding.storyAge, false) }
uiState.profilePicture?.let {
Glide.with(binding.storyAuthorProfilePicture)
.load(it)
.apply(RequestOptions.circleCropTransform())
.into(binding.storyAuthorProfilePicture)
}
binding.storyAuthor.text = uiState.username
uiState.imageList.getOrNull(uiState.currentImage)?.let {
Glide.with(binding.storyImage)
.load(it)
.listener(object : RequestListener<Drawable> {
override fun onLoadFailed(
e: GlideException?,
model: Any?,
target: Target<Drawable>?,
isFirstResource: Boolean,
): Boolean = false
override fun onResourceReady(
resource: Drawable?,
m: Any?,
target: Target<Drawable>?,
dataSource: DataSource?,
isFirstResource: Boolean,
): Boolean {
model.imageLoaded()
return false
}
})
.into(binding.storyImage)
}
}
}
}
model.count.observe(this) { state ->
// Render state in UI
model.uiState.value.durationList.getOrNull(model.uiState.value.currentImage)?.let {
val percent = 100 - ((state/it.toFloat())*100).toInt()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
binding.progressBarStory.setProgress(percent, true)
} else {
binding.progressBarStory.progress = percent
}
}
}
binding.pause.setOnClickListener {
//Set the button's appearance
it.isSelected = !it.isSelected
if (it.isSelected) {
//Handle selected state change
} else {
//Handle de-select state change
}
}
binding.storyImage.setOnClickListener {
model.pause()
}
}
}

View File

@ -0,0 +1,143 @@
package org.pixeldroid.app.stories
import android.app.Application
import android.os.CountDownTimer
import android.util.Log
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.pixeldroid.app.utils.PixelDroidApplication
import org.pixeldroid.app.utils.api.objects.StoryCarousel
import org.pixeldroid.app.utils.di.PixelfedAPIHolder
import java.time.Instant
import javax.inject.Inject
data class StoriesUiState(
val profilePicture: String? = null,
val username: String? = null,
val age: Instant? = null,
val currentImage: Int = 0,
val imageList: List<String> = emptyList(),
val durationList: List<Int> = emptyList(),
val paused: Boolean = false,
val errorMessage: String? = null,
)
class StoriesViewModel(
application: Application,
) : AndroidViewModel(application) {
@Inject
lateinit var apiHolder: PixelfedAPIHolder
private val _uiState: MutableStateFlow<StoriesUiState> = MutableStateFlow(StoriesUiState())
val uiState: StateFlow<StoriesUiState> = _uiState
var carousel: StoryCarousel? = null
val count = MutableLiveData<Long>()
private var timer: CountDownTimer? = null
init {
(application as PixelDroidApplication).getAppComponent().inject(this)
loadStories()
}
private fun setTimer(timerLength: Long) {
count.value = timerLength
timer = object: CountDownTimer(timerLength * 1000, 500){
override fun onTick(millisUntilFinished: Long) {
count.value = millisUntilFinished / 1000
Log.e("Timer second", "${count.value}")
}
override fun onFinish() {
goToNext()
}
}
}
private fun goToNext(){
_uiState.update { currentUiState ->
currentUiState.copy(
currentImage = currentUiState.currentImage + 1,
//TODO don't just take the first here, choose from activity input somehow?
age = carousel?.nodes?.firstOrNull()?.nodes?.getOrNull(currentUiState.currentImage + 1)?.created_at
)
}
//TODO when done with viewing all stories, close activity and move to profile (?)
timer?.cancel()
startTimerForCurrent()
}
private fun loadStories() {
viewModelScope.launch {
try{
val api = apiHolder.api ?: apiHolder.setToCurrentUser()
carousel = api.carousel()
//TODO don't just take the first here, choose from activity input somehow?
val chosenAccount = carousel?.nodes?.firstOrNull()
_uiState.update { currentUiState ->
currentUiState.copy(
profilePicture = chosenAccount?.user?.avatar,
age = chosenAccount?.nodes?.getOrNull(0)?.created_at,
username = chosenAccount?.user?.username, //TODO check if not username_acct, think about falling back on other option?
errorMessage = null,
currentImage = 0,
imageList = chosenAccount?.nodes?.mapNotNull { it?.src } ?: emptyList(),
durationList = chosenAccount?.nodes?.mapNotNull { it?.duration } ?: emptyList()
)
}
startTimerForCurrent()
} catch (exception: Exception){
_uiState.update { currentUiState ->
currentUiState.copy(errorMessage = "Something went wrong fetching the carousel")
}
}
}
}
private fun startTimerForCurrent(){
uiState.value.let {
it.durationList.getOrNull(it.currentImage)?.toLong()?.let { time ->
setTimer(time)
timer?.start()
}
}
}
fun imageLoaded() {/*
_uiState.update { currentUiState ->
currentUiState.copy(currentImage = currentUiState.currentImage + 1)
}*/
}
fun pause() {
if(_uiState.value.paused){
timer?.start()
} else {
timer?.cancel()
count.value?.let { setTimer(it) }
}
_uiState.update { currentUiState ->
currentUiState.copy(paused = !currentUiState.paused)
}
}
}
class StoriesViewModelFactory(val application: Application) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return modelClass.getConstructor(Application::class.java).newInstance(application)
}
}

View File

@ -231,6 +231,20 @@ interface PixelfedAPI {
@Query("post_id") post_id: String,
)
@GET("/api/v1.1/stories/carousel")
suspend fun carousel(
): StoryCarousel
@POST("/api/v1.1/stories/seen")
suspend fun carouselSeen(
@Query("id") id: String //TODO figure out if this is the id of post or of user?
)
@POST("/api/v1.1/stories/self-expire/{id}")
suspend fun deleteCarousel(
@Path("id") storyId: String
)
//Used in our case to retrieve comments for a given status
@GET("/api/v1/statuses/{id}/context")
suspend fun statusComments(

View File

@ -0,0 +1,35 @@
package org.pixeldroid.app.utils.api.objects
import java.time.Instant
data class StoryCarousel(
val self: CarouselUserContainer?,
val nodes: List<CarouselUserContainer?>?
)
data class CarouselUser(
val id: String?,
val username: String?,
val username_acct: String?,
val avatar: String?, // URL to account avatar
val local: Boolean?, // Is this story from the local instance?
val is_author: Boolean?, // Is this me? (seems redundant with id)
)
/**
* Container with a description of the [user] and a list of stories ([nodes])
*/
data class CarouselUserContainer(
val user: CarouselUser?,
val nodes: List<Story?>?,
)
data class Story(
val id: String?,
val pid: String?, // id of author
val type: String?, //TODO make enum of this? examples: "photo", ???
val src: String?, // URL to photo of story
val duration: Int?, //Time in seconds that the Story should be shown
val seen: Boolean?, //Indication of whether this story has been seen. Set to true using carouselSeen
val created_at: Instant?, //ISO 8601 Datetime
)

View File

@ -9,6 +9,7 @@ import org.pixeldroid.app.utils.BaseFragment
import dagger.Component
import org.pixeldroid.app.postCreation.PostCreationViewModel
import org.pixeldroid.app.profile.EditProfileViewModel
import org.pixeldroid.app.stories.StoriesViewModel
import org.pixeldroid.app.utils.notificationsWorker.NotificationsWorker
import javax.inject.Singleton
@ -22,6 +23,7 @@ interface ApplicationComponent {
fun inject(notificationsWorker: NotificationsWorker)
fun inject(postCreationViewModel: PostCreationViewModel)
fun inject(editProfileViewModel: EditProfileViewModel)
fun inject(storiesViewModel: StoriesViewModel)
val context: Context?
val application: Application?

View File

@ -0,0 +1,5 @@
<vector android:height="24dp" android:tint="#FFFFFF"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M6,19h4L10,5L6,5v14zM14,5v14h4L18,5h-4z"/>
</vector>

View File

@ -0,0 +1,5 @@
<vector android:height="24dp" android:tint="#FFFFFF"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M8,5v14l11,-7z"/>
</vector>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:drawable="@drawable/play"
android:state_selected="true" />
<item
android:drawable="@drawable/pause"/>
</selector>

View File

@ -0,0 +1,79 @@
<?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:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:background="@color/black"
android:layout_height="match_parent">
<ImageView
android:id="@+id/storyImage"
android:layout_width="match_parent"
android:layout_height="0dp"
android:contentDescription="@string/story_image"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintTop_toBottomOf="@id/progressBarStory"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
tools:srcCompat="@tools:sample/backgrounds/scenic[10]" />
<ImageButton
android:id="@+id/pause"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_margin="12dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/play_video"
android:src="@drawable/play_pause"
app:layout_constraintBottom_toBottomOf="@+id/storyAuthorProfilePicture"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@+id/storyAuthorProfilePicture"
tools:visibility="visible" />
<ProgressBar
android:id="@+id/progressBarStory"
style="@style/Widget.AppCompat.ProgressBar.Horizontal"
android:progress="0"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:visibility="visible"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<ImageView
android:id="@+id/storyAuthorProfilePicture"
android:layout_margin="12dp"
android:layout_width="48dp"
android:layout_height="48dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/progressBarStory"
tools:srcCompat="@tools:sample/avatars"
android:contentDescription="@string/profile_picture" />
<TextView
android:id="@+id/storyAuthor"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:textStyle="bold"
app:layout_constraintBottom_toBottomOf="@+id/storyAuthorProfilePicture"
app:layout_constraintStart_toEndOf="@+id/storyAuthorProfilePicture"
app:layout_constraintTop_toTopOf="@+id/storyAuthorProfilePicture"
tools:text="username" />
<TextView
android:id="@+id/storyAge"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
app:layout_constraintBottom_toBottomOf="@+id/storyAuthorProfilePicture"
app:layout_constraintStart_toEndOf="@+id/storyAuthor"
app:layout_constraintTop_toTopOf="@+id/storyAuthorProfilePicture"
app:layout_constraintVertical_bias="0.517"
tools:text="48m" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -330,4 +330,5 @@ For more info about Pixelfed, you can check here: https://pixelfed.org"</string>
<string name="contains_nsfw">Contains NSFW media</string>
<string name="switch_accounts">Switch accounts</string>
<string name="summary_always_show_nsfw">NSFW/CW posts will not be blurred, and will be shown by default.</string>
<string name="story_image">Story image</string>
</resources>

File diff suppressed because it is too large Load Diff