6.13.8 commit

This commit is contained in:
Xilin Jia 2024-11-10 19:56:08 +01:00
parent 50dc8e1330
commit 08822bd8ac
43 changed files with 897 additions and 1545 deletions

View File

@ -26,8 +26,8 @@ android {
vectorDrawables.useSupportLibrary false
vectorDrawables.generatedDensities = []
versionCode 3020294
versionName "6.13.7"
versionCode 3020295
versionName "6.13.8"
applicationId "ac.mdiq.podcini.R"
def commit = ""

View File

@ -1157,8 +1157,10 @@ class PlaybackService : MediaLibraryService() {
media.setPosition(position)
media.setLastPlayedTime(System.currentTimeMillis())
if (it.isNew) it.playState = PlayState.UNPLAYED.code
if (media.startPosition >= 0 && media.getPosition() > media.startPosition)
if (media.startPosition >= 0 && media.getPosition() > media.startPosition) {
media.playedDuration = (media.playedDurationWhenStarted + media.getPosition() - media.startPosition)
media.timeSpent = (System.currentTimeMillis() - media.startTime).toInt()
}
}
}
// This appears not too useful

View File

@ -188,7 +188,6 @@ class AboutFragment : PreferenceFragmentCompat() {
else -> DevelopersFragment()
}
}
override fun getItemCount(): Int {
return TOTAL_COUNT
}

View File

@ -36,10 +36,10 @@ object LogsAndStats {
* Searches the DB for statistics.
* @return The list of statistics objects
*/
fun getStatistics(includeMarkedAsPlayed: Boolean, timeFilterFrom: Long, timeFilterTo: Long): StatisticsResult {
fun getStatistics(includeMarkedAsPlayed: Boolean, timeFilterFrom: Long, timeFilterTo: Long, feedId: Long = 0L): StatisticsResult {
Logd(TAG, "getStatistics called")
val medias = if (feedId == 0L) realm.query(EpisodeMedia::class).find() else realm.query(EpisodeMedia::class).query("episode.feedId == $feedId").find()
val medias = realm.query(EpisodeMedia::class).find()
val groupdMedias = medias.groupBy { it.episodeOrFetch()?.feedId ?: 0L }
val result = StatisticsResult()
result.oldestDate = Long.MAX_VALUE
@ -47,6 +47,8 @@ object LogsAndStats {
val feed = getFeed(fid, false) ?: continue
val numEpisodes = feed.episodes.size.toLong()
var feedPlayedTime = 0L
var timeSpent = 0L
var durationWithSkip = 0L
var feedTotalTime = 0L
var episodesStarted = 0L
var totalDownloadSize = 0L
@ -54,16 +56,20 @@ object LogsAndStats {
for (m in feedMedias) {
if (m.lastPlayedTime > 0 && m.lastPlayedTime < result.oldestDate) result.oldestDate = m.lastPlayedTime
feedTotalTime += m.duration
if (m.lastPlayedTime in timeFilterFrom..<timeFilterTo) {
if (m.lastPlayedTime in (timeFilterFrom + 1)..<timeFilterTo) {
if (includeMarkedAsPlayed) {
if ((m.playbackCompletionTime > 0 && m.playedDuration > 0) || (m.episodeOrFetch()?.playState?:-10) > PlayState.SKIPPED.code || m.position > 0) {
episodesStarted += 1
feedPlayedTime += m.duration
timeSpent += m.timeSpent
}
} else {
feedPlayedTime += m.playedDuration
timeSpent += m.timeSpent
Logd(TAG, "m.playedDuration: ${m.playedDuration} m.timeSpent: ${m.timeSpent}")
if (m.playbackCompletionTime > 0 && m.playedDuration > 0) episodesStarted += 1
}
durationWithSkip += m.duration
}
if (m.downloaded) {
episodesDownloadCount += 1
@ -71,8 +77,10 @@ object LogsAndStats {
}
}
feedPlayedTime /= 1000
durationWithSkip /= 1000
timeSpent /= 1000
feedTotalTime /= 1000
result.statsItems.add(StatisticsItem(feed, feedTotalTime, feedPlayedTime, numEpisodes, episodesStarted, totalDownloadSize, episodesDownloadCount))
result.statsItems.add(StatisticsItem(feed, feedTotalTime, feedPlayedTime, timeSpent, durationWithSkip, numEpisodes, episodesStarted, totalDownloadSize, episodesDownloadCount))
}
return result
}

View File

@ -40,7 +40,7 @@ object RealmDB {
SubscriptionLog::class,
Chapter::class))
.name("Podcini.realm")
.schemaVersion(29)
.schemaVersion(30)
.migration({ mContext ->
val oldRealm = mContext.oldRealm // old realm using the previous schema
val newRealm = mContext.newRealm // new realm using the new schema
@ -105,7 +105,7 @@ object RealmDB {
// }
}
if (oldRealm.schemaVersion() < 28) {
Logd(TAG, "migrating DB from below 27")
Logd(TAG, "migrating DB from below 28")
mContext.enumerate(className = "Episode") { oldObject: DynamicRealmObject, newObject: DynamicMutableRealmObject? ->
newObject?.run {
if (oldObject.getValue<Long>(fieldName = "playState") == 1L) {
@ -113,12 +113,29 @@ object RealmDB {
} else {
val media = oldObject.getObject(propertyName = "media")
var position = 0L
if (media != null) position = media.getValue(propertyName = "position", Long::class) ?: 0
if (media != null) position = media.getValue(propertyName = "position", Long::class)
if (position > 0) set("playState", 5L)
}
}
}
}
if (oldRealm.schemaVersion() < 30) {
Logd(TAG, "migrating DB from below 30")
mContext.enumerate(className = "Episode") { oldObject: DynamicRealmObject, newObject: DynamicMutableRealmObject? ->
newObject?.run {
val media = oldObject.getObject(propertyName = "media")
var playedDuration = 0L
if (media != null) {
playedDuration = media.getValue(propertyName = "playedDuration", Long::class)
Logd(TAG, "position: $playedDuration")
if (playedDuration > 0L) {
val newMedia = newObject.getObject(propertyName = "media")
newMedia?.set("timeSpent", playedDuration)
}
}
}
}
}
}).build()
realm = Realm.open(config)
}

View File

@ -17,9 +17,6 @@ import org.apache.commons.lang3.builder.ToStringBuilder
import org.apache.commons.lang3.builder.ToStringStyle
import java.util.*
/**
* Episode within a feed.
*/
class Episode : RealmObject {
@PrimaryKey
var id: Long = 0L // increments from Date().time * 100 at time of creation

View File

@ -45,8 +45,17 @@ class EpisodeMedia: EmbeddedRealmObject, Playable {
@set:JvmName("setLastPlayedTimeProperty")
var lastPlayedTime: Long = 0 // Last time this media was played (in ms)
var startPosition: Int = -1
var playedDurationWhenStarted: Int = 0
private set
var playedDuration: Int = 0 // How many ms of this file have been played
var startTime: Long = 0 // time in ms when start playing
var timeSpent: Int = 0 // How many ms of this file have been played in actual time
// File size in Byte
var size: Long = 0L
@ -64,11 +73,6 @@ class EpisodeMedia: EmbeddedRealmObject, Playable {
}
var playbackCompletionTime: Long = 0
var startPosition: Int = -1
var playedDurationWhenStarted: Int = 0
private set
@Ignore
var volumeAdaptionSetting: VolumeAdaptionSetting = VolumeAdaptionSetting.OFF
get() = fromInteger(volumeAdaption)
@ -286,6 +290,7 @@ class EpisodeMedia: EmbeddedRealmObject, Playable {
override fun onPlaybackStart() {
startPosition = max(position.toDouble(), 0.0).toInt()
playedDurationWhenStarted = playedDuration
startTime = System.currentTimeMillis()
}
override fun onPlaybackPause(context: Context) {
@ -294,6 +299,7 @@ class EpisodeMedia: EmbeddedRealmObject, Playable {
playedDuration = playedDurationWhenStarted + position - startPosition
playedDurationWhenStarted = playedDuration
}
timeSpent = (System.currentTimeMillis() - startTime).toInt()
startPosition = position
}

View File

@ -75,7 +75,7 @@ class FeedFilter(vararg properties_: String) : Serializable {
query.append(r)
}
query.append(") ")
Logd("FeedFilter", "audoDeleteQueues: ${query}")
Logd("FeedFilter", "audoDeleteQueues: $query")
statements.add(query.toString())
}
when {
@ -90,7 +90,7 @@ class FeedFilter(vararg properties_: String) : Serializable {
query.append(r)
}
query.append(") ")
Logd("queryString", "${query}")
Logd("queryString", "$query")
return query.toString()
}

View File

@ -5,6 +5,8 @@ import java.util.ArrayList
class StatisticsItem(val feed: Feed,
val time: Long, // total time, in seconds
val timePlayed: Long, // in seconds, Respects speed, listening twice, ...
val timeSpent: Long, // in seconds, actual time spent playing
val durationOfStarted: Long, // in seconds, total duration of episodes started playing
val numEpisodes: Long, // Number of episodes.
val episodesStarted: Long, // Episodes that are actually played.
val totalDownloadSize: Long, // Simply sums up the size of download podcasts.
@ -15,6 +17,7 @@ class MonthlyStatisticsItem {
var year: Int = 0
var month: Int = 0
var timePlayed: Long = 0
var timeSpent: Long = 0
}
class StatisticsResult {

View File

@ -101,8 +101,8 @@ object DurationConverter {
* @return "HH:MM hours"
*/
@JvmStatic
fun shortLocalizedDuration(context: Context, time: Long): String {
fun shortLocalizedDuration(context: Context, time: Long, showHoursText: Boolean = true): String {
val hours = time.toFloat() / 3600f
return String.format(Locale.getDefault(), "%.2f ", hours) + context.getString(R.string.time_hours)
return String.format(Locale.getDefault(), "%.2f ", hours) + if (showHoursText) context.getString(R.string.time_hours) else ""
}
}

View File

@ -10,7 +10,6 @@ import java.util.*
object EpisodesPermutors {
/**
* Returns a Permutor that sorts a list appropriate to the given sort order.
*
* @return Permutor that sorts a list appropriate to the given sort order.
*/
@JvmStatic
@ -100,20 +99,16 @@ object EpisodesPermutors {
}
/**
* Implements a reordering by pubdate that avoids consecutive episodes from the same feed in
* the queue.
*
* Implements a reordering by pubdate that avoids consecutive episodes from the same feed in the queue.
* A listener might want to hear episodes from any given feed in pubdate order, but would
* prefer a more balanced ordering that avoids having to listen to clusters of consecutive
* episodes from the same feed. This is what "Smart Shuffle" tries to accomplish.
*
* Assume the queue looks like this: `ABCDDEEEEEEEEEE`.
* This method first starts with a queue of the final size, where each slot is empty (null).
* It takes the podcast with most episodes (`E`) and places the episodes spread out in the queue: `EE_E_EE_E_EE_EE`.
* The podcast with the second-most number of episodes (`D`) is then
* placed spread-out in the *available* slots: `EE_EDEE_EDEE_EE`.
* This continues, until we end up with: `EEBEDEECEDEEAEE`.
*
* Note that episodes aren't strictly ordered in terms of pubdate, but episodes of each feed are.
*
* @param queue A (modifiable) list of FeedItem elements to be reordered.
@ -172,8 +167,7 @@ object EpisodesPermutors {
/**
* Interface for passing around list permutor method. This is used for cases where a simple comparator
* won't work (e.g. Random, Smart Shuffle, etc).
*
* won't work (e.g. Random, Smart Shuffle, etc)
* @param <E> the type of elements in the list
</E> */
interface Permutor<E> {

View File

@ -35,6 +35,7 @@ import android.view.KeyEvent
import android.widget.Toast
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
@ -47,6 +48,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.unit.dp
@ -102,7 +104,7 @@ abstract class EpisodeActionButton internal constructor(@JvmField var item: Epis
fun AltActionsDialog(context: Context, showDialog: Boolean, onDismiss: () -> Unit) {
if (showDialog) {
Dialog(onDismissRequest = onDismiss) {
Card(modifier = Modifier.wrapContentSize(align = Alignment.Center).padding(16.dp), shape = RoundedCornerShape(16.dp)) {
Card(modifier = Modifier.wrapContentSize(align = Alignment.Center).padding(16.dp), shape = RoundedCornerShape(16.dp), border = BorderStroke(1.dp, Color.Yellow)) {
Row(modifier = Modifier.padding(16.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
val label = getLabel()
Logd(TAG, "button label: $label")

View File

@ -27,6 +27,7 @@ import android.content.Context
import android.content.SharedPreferences
import android.util.TypedValue
import android.view.ViewGroup
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.grid.GridCells
@ -151,7 +152,7 @@ open class SwipeActions(private val fragment: Fragment, private val tag: String)
(fragment.view as? ViewGroup)?.removeView(this@apply)
}) {
val context = LocalContext.current
Surface(shape = RoundedCornerShape(16.dp)) {
Surface(shape = RoundedCornerShape(16.dp), border = BorderStroke(1.dp, Color.Yellow)) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
for (action in swipeActions) {
if (action.getId() == NO_ACTION.name || action.getId() == ActionTypes.COMBO.name) continue
@ -614,7 +615,7 @@ open class SwipeActions(private val fragment: Fragment, private val tag: String)
var showPickerDialog by remember { mutableStateOf(false) }
if (showPickerDialog) {
Dialog(onDismissRequest = { showPickerDialog = false }) {
Card(modifier = Modifier.wrapContentSize(align = Alignment.Center).fillMaxWidth().padding(16.dp), shape = RoundedCornerShape(16.dp)) {
Card(modifier = Modifier.wrapContentSize(align = Alignment.Center).fillMaxWidth().padding(16.dp), shape = RoundedCornerShape(16.dp), border = BorderStroke(1.dp, Color.Yellow)) {
LazyVerticalGrid(columns = GridCells.Fixed(2), modifier = Modifier.padding(16.dp)) {
items(keys.size) { index ->
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.padding(16.dp).clickable {
@ -663,7 +664,7 @@ open class SwipeActions(private val fragment: Fragment, private val tag: String)
else -> {}
}
if (tag != QueuesFragment.TAG) keys = keys.filter { a: SwipeAction -> !a.getId().equals(ActionTypes.REMOVE_FROM_QUEUE.name) }
Card(modifier = Modifier.wrapContentSize(align = Alignment.Center).fillMaxWidth().padding(16.dp), shape = RoundedCornerShape(16.dp)) {
Card(modifier = Modifier.wrapContentSize(align = Alignment.Center).fillMaxWidth().padding(16.dp), shape = RoundedCornerShape(16.dp), border = BorderStroke(1.dp, Color.Yellow)) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(20.dp)) {
Text(stringResource(R.string.swipeactions_label) + " - " + forFragment)
Text(stringResource(R.string.swipe_left))

View File

@ -28,7 +28,7 @@ import ac.mdiq.podcini.ui.activity.starter.MainActivityStarter
import ac.mdiq.podcini.ui.dialog.RatingDialog
import ac.mdiq.podcini.ui.fragment.*
import ac.mdiq.podcini.ui.fragment.AudioPlayerFragment.Companion.media3Controller
import ac.mdiq.podcini.ui.statistics.StatisticsFragment
import ac.mdiq.podcini.ui.fragment.StatisticsFragment
import ac.mdiq.podcini.ui.utils.ThemeUtils.getDrawableFromAttr
import ac.mdiq.podcini.ui.utils.TransitionEffect
import ac.mdiq.podcini.util.EventFlow

View File

@ -8,6 +8,7 @@ import ac.mdiq.podcini.playback.service.PlaybackService.Companion.seekTo
import ac.mdiq.podcini.storage.model.Playable
import ac.mdiq.podcini.storage.utils.DurationConverter.getDurationStringLocalized
import ac.mdiq.podcini.storage.utils.DurationConverter.getDurationStringLong
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
@ -20,6 +21,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
@ -35,7 +37,7 @@ fun ChaptersDialog(media: Playable, onDismissRequest: () -> Unit) {
val chapters = media.getChapters()
val textColor = MaterialTheme.colorScheme.onSurface
Dialog(onDismissRequest = onDismissRequest) {
Surface(shape = RoundedCornerShape(16.dp)) {
Surface(shape = RoundedCornerShape(16.dp), border = BorderStroke(1.dp, Color.Yellow)) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
Text(stringResource(R.string.chapters_label))
var currentChapterIndex by remember { mutableIntStateOf(-1) }

View File

@ -1,5 +1,7 @@
package ac.mdiq.podcini.ui.compose
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.*
@ -135,7 +137,7 @@ fun AutoCompleteTextView(suggestions: List<String>, onItemSelected: (String) ->
@Composable
fun LargeTextEditingDialog(textState: TextFieldValue, onTextChange: (TextFieldValue) -> Unit, onDismissRequest: () -> Unit, onSave: (String) -> Unit) {
Dialog(onDismissRequest = { onDismissRequest() }, properties = DialogProperties(usePlatformDefaultWidth = false)) {
Surface(modifier = Modifier.fillMaxWidth().padding(16.dp), shape = MaterialTheme.shapes.medium) {
Surface(modifier = Modifier.fillMaxWidth().padding(16.dp), shape = MaterialTheme.shapes.medium, border = BorderStroke(1.dp, Color.Yellow)) {
val textColor = MaterialTheme.colorScheme.onSurface
Column(modifier = Modifier.padding(16.dp)) {
Text(text = "Add comment", color = textColor, style = MaterialTheme.typography.titleLarge)
@ -183,4 +185,4 @@ fun NonlazyGrid(columns: Int, itemCount: Int, modifier: Modifier = Modifier, con
}
}
}
}
}

View File

@ -1,6 +1,5 @@
package ac.mdiq.podcini.ui.compose
//import ac.mdiq.podcini.ui.actions.EpisodeActionButton.Companion.forItem
import ac.mdiq.podcini.R
import ac.mdiq.podcini.net.download.DownloadStatus
import ac.mdiq.podcini.net.download.service.DownloadServiceInterface
@ -233,7 +232,7 @@ class EpisodeVM(var episode: Episode) {
@Composable
fun ChooseRatingDialog(selected: List<Episode>, onDismissRequest: () -> Unit) {
Dialog(onDismissRequest = onDismissRequest) {
Surface(shape = RoundedCornerShape(16.dp)) {
Surface(shape = RoundedCornerShape(16.dp), border = BorderStroke(1.dp, Color.Yellow)) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
for (rating in Rating.entries.reversed()) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier
@ -255,7 +254,7 @@ fun ChooseRatingDialog(selected: List<Episode>, onDismissRequest: () -> Unit) {
fun PlayStateDialog(selected: List<Episode>, onDismissRequest: () -> Unit) {
val context = LocalContext.current
Dialog(onDismissRequest = onDismissRequest) {
Surface(shape = RoundedCornerShape(16.dp)) {
Surface(shape = RoundedCornerShape(16.dp), border = BorderStroke(1.dp, Color.Yellow)) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
for (state in PlayState.entries) {
if (state.userSet) {
@ -313,7 +312,7 @@ fun PlayStateDialog(selected: List<Episode>, onDismissRequest: () -> Unit) {
fun PutToQueueDialog(selected: List<Episode>, onDismissRequest: () -> Unit) {
val queues = realm.query(PlayQueue::class).find()
Dialog(onDismissRequest = onDismissRequest) {
Surface(shape = RoundedCornerShape(16.dp)) {
Surface(shape = RoundedCornerShape(16.dp), border = BorderStroke(1.dp, Color.Yellow)) {
val scrollState = rememberScrollState()
Column(modifier = Modifier.verticalScroll(scrollState).padding(16.dp), verticalArrangement = Arrangement.spacedBy(1.dp)) {
var removeChecked by remember { mutableStateOf(false) }
@ -365,7 +364,7 @@ fun PutToQueueDialog(selected: List<Episode>, onDismissRequest: () -> Unit) {
fun ShelveDialog(selected: List<Episode>, onDismissRequest: () -> Unit) {
val synthetics = realm.query(Feed::class).query("id >= 100 && id <= 1000").find()
Dialog(onDismissRequest = onDismissRequest) {
Surface(shape = RoundedCornerShape(16.dp)) {
Surface(shape = RoundedCornerShape(16.dp), border = BorderStroke(1.dp, Color.Yellow)) {
val scrollState = rememberScrollState()
Column(modifier = Modifier
.verticalScroll(scrollState)
@ -429,7 +428,7 @@ fun EraseEpisodesDialog(selected: List<Episode>, feed: Feed?, onDismissRequest:
val context = LocalContext.current
Dialog(onDismissRequest = onDismissRequest) {
Surface(shape = RoundedCornerShape(16.dp)) {
Surface(shape = RoundedCornerShape(16.dp), border = BorderStroke(1.dp, Color.Yellow)) {
if (feed == null || feed.id > MAX_SYNTHETIC_ID) Text(stringResource(R.string.not_erase_message), modifier = Modifier.padding(10.dp))
else Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
Text(message + ": ${selected.size}")
@ -955,7 +954,7 @@ fun ConfirmAddYoutubeEpisode(sharedUrls: List<String>, showDialog: Boolean, onDi
if (showDialog) {
Dialog(onDismissRequest = { onDismissRequest() }) {
Card(modifier = Modifier.wrapContentSize(align = Alignment.Center).padding(16.dp), shape = RoundedCornerShape(16.dp)) {
Card(modifier = Modifier.wrapContentSize(align = Alignment.Center).padding(16.dp), shape = RoundedCornerShape(16.dp), border = BorderStroke(1.dp, Color.Yellow)) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.Center) {
var audioOnly by remember { mutableStateOf(false) }
Row(Modifier.fillMaxWidth()) {

View File

@ -50,7 +50,7 @@ import java.util.*
@Composable
fun ChooseRatingDialog(selected: List<Feed>, onDismissRequest: () -> Unit) {
Dialog(onDismissRequest = onDismissRequest) {
Surface(shape = RoundedCornerShape(16.dp)) {
Surface(shape = RoundedCornerShape(16.dp), border = BorderStroke(1.dp, Color.Yellow)) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
for (rating in Rating.entries.reversed()) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(4.dp).clickable {
@ -77,7 +77,7 @@ fun RemoveFeedDialog(feeds: List<Feed>, onDismissRequest: () -> Unit, callback:
val context = LocalContext.current
Dialog(onDismissRequest = onDismissRequest) {
Surface(shape = RoundedCornerShape(16.dp)) {
Surface(shape = RoundedCornerShape(16.dp), border = BorderStroke(1.dp, Color.Yellow)) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
Text(message)
Text(stringResource(R.string.feed_delete_reason_msg))
@ -131,7 +131,7 @@ fun OnlineFeedItem(activity: MainActivity, feed: PodcastSearchResult, log: Subsc
fun confirmSubscribe(feed: PodcastSearchResult, showDialog: Boolean, onDismissRequest: () -> Unit) {
if (showDialog) {
Dialog(onDismissRequest = { onDismissRequest() }) {
Card(modifier = Modifier.wrapContentSize(align = Alignment.Center).padding(16.dp), shape = RoundedCornerShape(16.dp)) {
Card(modifier = Modifier.wrapContentSize(align = Alignment.Center).padding(16.dp), shape = RoundedCornerShape(16.dp), border = BorderStroke(1.dp, Color.Yellow)) {
val textColor = MaterialTheme.colorScheme.onSurface
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.Center) {
Text("Subscribe: \"${feed.title}\" ?", color = textColor, modifier = Modifier.padding(bottom = 10.dp))

View File

@ -63,6 +63,7 @@ import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.LocalContext
@ -340,7 +341,7 @@ class AudioPlayerFragment : Fragment() {
if (showDialog) {
val (selectedOption, onOptionSelected) = remember { mutableStateOf((currentMedia as? EpisodeMedia)?.volumeAdaptionSetting ?: VolumeAdaptionSetting.OFF) }
Dialog(onDismissRequest = { onDismissRequest() }) {
Card(modifier = Modifier.wrapContentSize(align = Alignment.Center).padding(16.dp), shape = RoundedCornerShape(16.dp)) {
Card(modifier = Modifier.wrapContentSize(align = Alignment.Center).padding(16.dp), shape = RoundedCornerShape(16.dp), border = BorderStroke(1.dp, Color.Yellow)) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Column {
VolumeAdaptionSetting.entries.forEach { item ->

View File

@ -21,10 +21,7 @@ import ac.mdiq.podcini.ui.compose.ChooseRatingDialog
import ac.mdiq.podcini.ui.compose.CustomTheme
import ac.mdiq.podcini.ui.compose.LargeTextEditingDialog
import ac.mdiq.podcini.ui.compose.RemoveFeedDialog
import ac.mdiq.podcini.ui.statistics.FeedStatisticsFragment
import ac.mdiq.podcini.ui.statistics.FeedStatisticsFragment.Companion.EXTRA_DETAILED
import ac.mdiq.podcini.ui.statistics.FeedStatisticsFragment.Companion.EXTRA_FEED_ID
import ac.mdiq.podcini.ui.statistics.StatisticsFragment
import ac.mdiq.podcini.ui.fragment.StatisticsFragment.Companion.FeedStatisticsDialog
import ac.mdiq.podcini.ui.utils.TransitionEffect
import ac.mdiq.podcini.util.*
import android.R.string
@ -68,9 +65,7 @@ import androidx.constraintlayout.compose.ConstraintLayout
import androidx.constraintlayout.compose.Dimension
import androidx.documentfile.provider.DocumentFile
import androidx.fragment.app.Fragment
import androidx.fragment.compose.AndroidFragment
import androidx.lifecycle.lifecycleScope
import coil.compose.AsyncImage
import com.google.android.material.appbar.MaterialToolbar
import com.google.android.material.dialog.MaterialAlertDialogBuilder
@ -194,16 +189,6 @@ class FeedInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener {
}
Spacer(modifier = Modifier.width(15.dp))
}
// Icon(imageVector = ImageVector.vectorResource(R.drawable.ic_rounded_corner_left), contentDescription = "left_corner",
// Modifier.width(12.dp).height(12.dp).constrainAs(image1) {
// bottom.linkTo(parent.bottom)
// start.linkTo(parent.start)
// })
// Icon(imageVector = ImageVector.vectorResource(R.drawable.ic_rounded_corner_right), contentDescription = "right_corner",
// Modifier.width(12.dp).height(12.dp).constrainAs(image2) {
// bottom.linkTo(parent.bottom)
// end.linkTo(parent.end)
// })
Row(verticalAlignment = Alignment.Top, modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp).constrainAs(imgvCover) {
top.linkTo(parent.top)
start.linkTo(parent.start)
@ -237,6 +222,8 @@ class FeedInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener {
rating = feed.rating
}
})
var showFeedStats by remember { mutableStateOf(false) }
if (showFeedStats) FeedStatisticsDialog(feed.title?: "No title", feed.id) { showFeedStats = false }
Column(modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 16.dp).verticalScroll(scrollState)) {
val textColor = MaterialTheme.colorScheme.onSurface
@ -292,19 +279,13 @@ class FeedInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener {
Button(modifier = Modifier.padding(top = 10.dp), onClick = {
val fragment = SearchResultsFragment.newInstance(CombinedSearcher::class.java, "$txtvAuthor podcasts")
(activity as MainActivity).loadChildFragment(fragment, TransitionEffect.SLIDE)
}) {
Text(stringResource(R.string.feeds_related_to_author))
}
}) { Text(stringResource(R.string.feeds_related_to_author)) }
}
Text(stringResource(R.string.statistics_label), color = textColor, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.padding(top = 16.dp, bottom = 4.dp))
val arguments = Bundle()
arguments.putLong(EXTRA_FEED_ID, feed.id)
arguments.putBoolean(EXTRA_DETAILED, false)
AndroidFragment(clazz = FeedStatisticsFragment::class.java, arguments = arguments)
Button({
(activity as MainActivity).loadChildFragment(StatisticsFragment(), TransitionEffect.SLIDE)
}) {
Text(stringResource(R.string.statistics_view_all))
Row {
Button({ showFeedStats = true }) { Text(stringResource(R.string.statistics_view_this)) }
Spacer(Modifier.weight(1f))
Button({ (activity as MainActivity).loadChildFragment(StatisticsFragment(), TransitionEffect.SLIDE) }) { Text(stringResource(R.string.statistics_view_all)) }
}
}
}

View File

@ -36,6 +36,7 @@ import android.widget.CompoundButton
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AlertDialog
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
@ -44,6 +45,7 @@ import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
@ -421,7 +423,7 @@ class FeedSettingsFragment : Fragment() {
if (showDialog) {
val (selectedOption, onOptionSelected) = remember { mutableStateOf(videoMode) }
Dialog(onDismissRequest = { onDismissRequest() }) {
Card(modifier = Modifier.wrapContentSize(align = Alignment.Center).padding(16.dp), shape = RoundedCornerShape(16.dp)) {
Card(modifier = Modifier.wrapContentSize(align = Alignment.Center).padding(16.dp), shape = RoundedCornerShape(16.dp), border = BorderStroke(1.dp, Color.Yellow)) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Column {
videoModeTags.forEach { text ->
@ -475,7 +477,7 @@ class FeedSettingsFragment : Fragment() {
if (showDialog) {
val (selectedOption, onOptionSelected) = remember { mutableStateOf(autoDeletePolicy) }
Dialog(onDismissRequest = { onDismissRequest() }) {
Card(modifier = Modifier.wrapContentSize(align = Alignment.Center).padding(16.dp), shape = RoundedCornerShape(16.dp)) {
Card(modifier = Modifier.wrapContentSize(align = Alignment.Center).padding(16.dp), shape = RoundedCornerShape(16.dp), border = BorderStroke(1.dp, Color.Yellow)) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Column {
FeedAutoDeleteOptions.forEach { text ->
@ -512,7 +514,7 @@ class FeedSettingsFragment : Fragment() {
if (showDialog) {
val (selectedOption, onOptionSelected) = remember { mutableStateOf(feed?.preferences?.volumeAdaptionSetting ?: VolumeAdaptionSetting.OFF) }
Dialog(onDismissRequest = { onDismissRequest() }) {
Card(modifier = Modifier.wrapContentSize(align = Alignment.Center).padding(16.dp), shape = RoundedCornerShape(16.dp)) {
Card(modifier = Modifier.wrapContentSize(align = Alignment.Center).padding(16.dp), shape = RoundedCornerShape(16.dp), border = BorderStroke(1.dp, Color.Yellow)) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Column {
VolumeAdaptionSetting.entries.forEach { item ->
@ -542,7 +544,7 @@ class FeedSettingsFragment : Fragment() {
if (showDialog) {
val (selectedOption, onOptionSelected) = remember { mutableStateOf(feed?.preferences?.autoDLPolicy ?: AutoDownloadPolicy.ONLY_NEW) }
Dialog(onDismissRequest = { onDismissRequest() }) {
Card(modifier = Modifier.wrapContentSize(align = Alignment.Center).padding(16.dp), shape = RoundedCornerShape(16.dp)) {
Card(modifier = Modifier.wrapContentSize(align = Alignment.Center).padding(16.dp), shape = RoundedCornerShape(16.dp), border = BorderStroke(1.dp, Color.Yellow)) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Column {
AutoDownloadPolicy.entries.forEach { item ->
@ -572,7 +574,7 @@ class FeedSettingsFragment : Fragment() {
fun SetEpisodesCacheDialog(showDialog: Boolean, onDismiss: () -> Unit) {
if (showDialog) {
Dialog(onDismissRequest = onDismiss) {
Card(modifier = Modifier.wrapContentSize(align = Alignment.Center).padding(16.dp), shape = RoundedCornerShape(16.dp)) {
Card(modifier = Modifier.wrapContentSize(align = Alignment.Center).padding(16.dp), shape = RoundedCornerShape(16.dp), border = BorderStroke(1.dp, Color.Yellow)) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
var newCache by remember { mutableStateOf((feed?.preferences?.autoDLMaxEpisodes ?: 1).toString()) }
TextField(value = newCache, onValueChange = { if (it.isEmpty() || it.toIntOrNull() != null) newCache = it },
@ -594,7 +596,7 @@ class FeedSettingsFragment : Fragment() {
var selected by remember {mutableStateOf(selectedOption)}
if (showDialog) {
Dialog(onDismissRequest = { onDismissRequest() }) {
Card(modifier = Modifier.wrapContentSize(align = Alignment.Center).padding(16.dp), shape = RoundedCornerShape(16.dp)) {
Card(modifier = Modifier.wrapContentSize(align = Alignment.Center).padding(16.dp), shape = RoundedCornerShape(16.dp), border = BorderStroke(1.dp, Color.Yellow)) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
queueSettingOptions.forEach { option ->
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
@ -647,7 +649,7 @@ class FeedSettingsFragment : Fragment() {
var selected by remember {mutableStateOf(selectedOption)}
if (showDialog) {
Dialog(onDismissRequest = { onDismissRequest() }) {
Card(modifier = Modifier.wrapContentSize(align = Alignment.Center).padding(16.dp), shape = RoundedCornerShape(16.dp)) {
Card(modifier = Modifier.wrapContentSize(align = Alignment.Center).padding(16.dp), shape = RoundedCornerShape(16.dp), border = BorderStroke(1.dp, Color.Yellow)) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
FeedPreferences.AVQuality.entries.forEach { option ->
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
@ -689,7 +691,7 @@ class FeedSettingsFragment : Fragment() {
var selected by remember {mutableStateOf(selectedOption)}
if (showDialog) {
Dialog(onDismissRequest = { onDismissRequest() }) {
Card(modifier = Modifier.wrapContentSize(align = Alignment.Center).padding(16.dp), shape = RoundedCornerShape(16.dp)) {
Card(modifier = Modifier.wrapContentSize(align = Alignment.Center).padding(16.dp), shape = RoundedCornerShape(16.dp), border = BorderStroke(1.dp, Color.Yellow)) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
FeedPreferences.AVQuality.entries.forEach { option ->
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
@ -730,7 +732,7 @@ class FeedSettingsFragment : Fragment() {
fun AuthenticationDialog(showDialog: Boolean, onDismiss: () -> Unit) {
if (showDialog) {
Dialog(onDismissRequest = onDismiss) {
Card(modifier = Modifier.wrapContentSize(align = Alignment.Center).padding(16.dp), shape = RoundedCornerShape(16.dp)) {
Card(modifier = Modifier.wrapContentSize(align = Alignment.Center).padding(16.dp), shape = RoundedCornerShape(16.dp), border = BorderStroke(1.dp, Color.Yellow)) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
val oldName = feed?.preferences?.username?:""
var newName by remember { mutableStateOf(oldName) }
@ -758,7 +760,7 @@ class FeedSettingsFragment : Fragment() {
fun AutoSkipDialog(showDialog: Boolean, onDismiss: () -> Unit) {
if (showDialog) {
Dialog(onDismissRequest = onDismiss) {
Card(modifier = Modifier.wrapContentSize(align = Alignment.Center).padding(16.dp), shape = RoundedCornerShape(16.dp)) {
Card(modifier = Modifier.wrapContentSize(align = Alignment.Center).padding(16.dp), shape = RoundedCornerShape(16.dp), border = BorderStroke(1.dp, Color.Yellow)) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
var intro by remember { mutableStateOf((feed?.preferences?.introSkip ?: 0).toString()) }
TextField(value = intro, onValueChange = { if (it.isEmpty() || it.toIntOrNull() != null) intro = it },

View File

@ -31,6 +31,7 @@ import android.view.*
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.Toolbar
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
@ -402,7 +403,7 @@ class LogsFragment : Fragment(), Toolbar.OnMenuItemClickListener {
else -> ""
}
Dialog(onDismissRequest = { onDismissRequest() }) {
Card(modifier = Modifier.wrapContentSize(align = Alignment.Center).padding(10.dp), shape = RoundedCornerShape(16.dp)) {
Card(modifier = Modifier.wrapContentSize(align = Alignment.Center).padding(10.dp), shape = RoundedCornerShape(16.dp), border = BorderStroke(1.dp, Color.Yellow)) {
Column(modifier = Modifier.padding(10.dp)) {
val textColor = MaterialTheme.colorScheme.onSurface
Text(stringResource(R.string.download_error_details), color = textColor, modifier = Modifier.padding(bottom = 3.dp))
@ -430,7 +431,7 @@ class LogsFragment : Fragment(), Toolbar.OnMenuItemClickListener {
fun SubscriptionDetailDialog(log: SubscriptionLog, showDialog: Boolean, onDismissRequest: () -> Unit) {
if (showDialog) {
Dialog(onDismissRequest = { onDismissRequest() }) {
Card(modifier = Modifier.wrapContentSize(align = Alignment.Center).padding(10.dp), shape = RoundedCornerShape(16.dp)) {
Card(modifier = Modifier.wrapContentSize(align = Alignment.Center).padding(10.dp), shape = RoundedCornerShape(16.dp), border = BorderStroke(1.dp, Color.Yellow)) {
Column(modifier = Modifier.padding(10.dp)) {
val textColor = MaterialTheme.colorScheme.onSurface
Text(stringResource(R.string.download_error_details), color = textColor, modifier = Modifier.padding(bottom = 3.dp))
@ -468,7 +469,7 @@ class LogsFragment : Fragment(), Toolbar.OnMenuItemClickListener {
val messageFull = requireContext().getString(R.string.download_log_details_message, requireContext().getString(from(status.reason)), message, url)
Dialog(onDismissRequest = { onDismissRequest() }) {
Card(modifier = Modifier.wrapContentSize(align = Alignment.Center).padding(10.dp), shape = RoundedCornerShape(16.dp), ) {
Card(modifier = Modifier.wrapContentSize(align = Alignment.Center).padding(10.dp), shape = RoundedCornerShape(16.dp), border = BorderStroke(1.dp, Color.Yellow)) {
Column(modifier = Modifier.padding(10.dp)) {
val textColor = MaterialTheme.colorScheme.onSurface
Text(stringResource(R.string.download_error_details), color = textColor, modifier = Modifier.padding(bottom = 3.dp))

View File

@ -12,7 +12,6 @@ import ac.mdiq.podcini.ui.activity.PreferenceActivity
import ac.mdiq.podcini.ui.compose.CustomTheme
import ac.mdiq.podcini.ui.fragment.FeedEpisodesFragment.Companion.ARGUMENT_FEED_ID
import ac.mdiq.podcini.ui.fragment.HistoryFragment.Companion.getNumberOfPlayed
import ac.mdiq.podcini.ui.statistics.StatisticsFragment
import ac.mdiq.podcini.ui.utils.ThemeUtils
import ac.mdiq.podcini.util.Logd
import android.R.attr

View File

@ -44,6 +44,7 @@ import android.util.Log
import android.view.*
import android.widget.CheckBox
import androidx.appcompat.widget.Toolbar
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
@ -541,7 +542,7 @@ class QueuesFragment : Fragment(), Toolbar.OnMenuItemClickListener {
fun RenameQueueDialog(showDialog: Boolean, onDismiss: () -> Unit) {
if (showDialog) {
Dialog(onDismissRequest = onDismiss) {
Card(modifier = Modifier.wrapContentSize(align = Alignment.Center).padding(16.dp), shape = RoundedCornerShape(16.dp)) {
Card(modifier = Modifier.wrapContentSize(align = Alignment.Center).padding(16.dp), shape = RoundedCornerShape(16.dp), border = BorderStroke(1.dp, Color.Yellow)) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
var newName by remember { mutableStateOf(curQueue.name) }
TextField(value = newName, onValueChange = { newName = it }, label = { Text("Rename (Unique name only)") })
@ -565,7 +566,7 @@ class QueuesFragment : Fragment(), Toolbar.OnMenuItemClickListener {
fun AddQueueDialog(showDialog: Boolean, onDismiss: () -> Unit) {
if (showDialog) {
Dialog(onDismissRequest = onDismiss) {
Card(modifier = Modifier.wrapContentSize(align = Alignment.Center).padding(16.dp), shape = RoundedCornerShape(16.dp)) {
Card(modifier = Modifier.wrapContentSize(align = Alignment.Center).padding(16.dp), shape = RoundedCornerShape(16.dp), border = BorderStroke(1.dp, Color.Yellow)) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
var newName by remember { mutableStateOf("") }
TextField(value = newName, onValueChange = { newName = it }, label = { Text("Add queue (Unique name only)") })

View File

@ -0,0 +1,664 @@
package ac.mdiq.podcini.ui.fragment
import ac.mdiq.podcini.R
import ac.mdiq.podcini.databinding.PagerFragmentBinding
import ac.mdiq.podcini.storage.database.LogsAndStats.getStatistics
import ac.mdiq.podcini.storage.database.RealmDB.realm
import ac.mdiq.podcini.storage.database.RealmDB.runOnIOScope
import ac.mdiq.podcini.storage.database.RealmDB.update
import ac.mdiq.podcini.storage.model.*
import ac.mdiq.podcini.storage.utils.DurationConverter.shortLocalizedDuration
import ac.mdiq.podcini.ui.activity.MainActivity
import ac.mdiq.podcini.ui.activity.starter.MainActivityStarter
import ac.mdiq.podcini.ui.compose.CustomTheme
import ac.mdiq.podcini.ui.dialog.ConfirmationDialog
import ac.mdiq.podcini.ui.dialog.DatesFilterDialog
import ac.mdiq.podcini.util.EventFlow
import ac.mdiq.podcini.util.FlowEvent
import ac.mdiq.podcini.util.Logd
import android.content.Context
import android.content.DialogInterface
import android.content.SharedPreferences
import android.os.Bundle
import android.text.format.DateFormat
import android.text.format.Formatter
import android.util.Log
import android.view.*
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.viewpager2.adapter.FragmentStateAdapter
import androidx.viewpager2.widget.ViewPager2
import androidx.viewpager2.widget.ViewPager2.OnPageChangeCallback
import coil.compose.AsyncImage
import coil.request.CachePolicy
import coil.request.ImageRequest
import com.google.android.material.appbar.MaterialToolbar
import com.google.android.material.tabs.TabLayout
import com.google.android.material.tabs.TabLayoutMediator
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.text.SimpleDateFormat
import java.time.LocalDate
import java.time.ZoneId
import java.util.*
import kotlin.math.max
import kotlin.math.min
class StatisticsFragment : Fragment() {
private lateinit var tabLayout: TabLayout
private lateinit var viewPager: ViewPager2
private lateinit var toolbar: MaterialToolbar
private var _binding: PagerFragmentBinding? = null
private val binding get() = _binding!!
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
super.onCreateView(inflater, container, savedInstanceState)
setHasOptionsMenu(true)
_binding = PagerFragmentBinding.inflate(inflater)
viewPager = binding.viewpager
toolbar = binding.toolbar
toolbar.title = getString(R.string.statistics_label)
toolbar.inflateMenu(R.menu.statistics)
toolbar.setNavigationOnClickListener { parentFragmentManager.popBackStack() }
(activity as MainActivity).setupToolbarToggle(toolbar, false)
viewPager.adapter = PagerAdapter(this)
// Give the TabLayout the ViewPager
tabLayout = binding.slidingTabs
setupPagedToolbar(toolbar, viewPager)
TabLayoutMediator(tabLayout, viewPager) { tab: TabLayout.Tab, position: Int ->
when (position) {
POS_SUBSCRIPTIONS -> tab.setText(R.string.subscriptions_label)
POS_YEARS -> tab.setText(R.string.months_statistics_label)
POS_SPACE_TAKEN -> tab.setText(R.string.downloads_label)
else -> {}
}
}.attach()
return binding.root
}
override fun onDestroyView() {
Logd(TAG, "onDestroyView")
_binding = null
super.onDestroyView()
}
@Deprecated("Deprecated in Java")
override fun onOptionsItemSelected(item: MenuItem): Boolean {
if (item.itemId == R.id.statistics_reset) {
confirmResetStatistics()
return true
}
return super.onOptionsItemSelected(item)
}
private fun setupPagedToolbar(toolbar: MaterialToolbar, viewPager: ViewPager2) {
this.toolbar = toolbar
this.viewPager = viewPager
toolbar.setOnMenuItemClickListener { item: MenuItem? ->
if (this.onOptionsItemSelected(item!!)) return@setOnMenuItemClickListener true
val child = childFragmentManager.findFragmentByTag("f" + viewPager.currentItem)
if (child != null) return@setOnMenuItemClickListener child.onOptionsItemSelected(item)
false
}
viewPager.registerOnPageChangeCallback(object : OnPageChangeCallback() {
override fun onPageSelected(position: Int) {
val child = childFragmentManager.findFragmentByTag("f$position")
child?.onPrepareOptionsMenu(toolbar.menu)
}
})
}
private fun confirmResetStatistics() {
val conDialog: ConfirmationDialog = object : ConfirmationDialog(requireContext(),
R.string.statistics_reset_data, R.string.statistics_reset_data_msg) {
override fun onConfirmButtonPressed(dialog: DialogInterface) {
dialog.dismiss()
doResetStatistics()
}
}
conDialog.createNewDialog().show()
}
private fun doResetStatistics() {
prefs!!.edit()
.putBoolean(PREF_INCLUDE_MARKED_PLAYED, false)
.putLong(PREF_FILTER_FROM, 0)
.putLong(PREF_FILTER_TO, Long.MAX_VALUE)
.apply()
lifecycleScope.launch {
try {
withContext(Dispatchers.IO) { resetStatistics() }
EventFlow.postEvent(FlowEvent.StatisticsEvent())
} catch (error: Throwable) { Log.e(TAG, Log.getStackTraceString(error)) }
}
}
private fun resetStatistics(): Job {
return runOnIOScope {
val mediaAll = realm.query(EpisodeMedia::class).find()
for (m in mediaAll) update(m) { m.playedDuration = 0 }
}
}
private class PagerAdapter(fragment: Fragment) : FragmentStateAdapter(fragment) {
override fun createFragment(position: Int): Fragment {
return when (position) {
POS_SUBSCRIPTIONS -> SubscriptionStatisticsFragment()
POS_YEARS -> MonthlyStatisticsFragment()
POS_SPACE_TAKEN -> DownloadStatisticsFragment()
else -> DownloadStatisticsFragment()
}
}
override fun getItemCount(): Int {
return TOTAL_COUNT
}
}
class SubscriptionStatisticsFragment : Fragment() {
lateinit var statisticsData: StatisticsResult
private lateinit var lineChartData: LineChartData
private var timeSpentSum = 0L
private var timeFilterFrom: Long = 0
private var timeFilterTo = Long.MAX_VALUE
private var includeMarkedAsPlayed = false
private var timePlayedToday: Long = 0
private var timeSpentToday: Long = 0
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setHasOptionsMenu(true)
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
loadStatistics()
val composeView = ComposeView(requireContext()).apply {
setContent {
CustomTheme(requireContext()) {
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxWidth()) {
Text(stringResource(R.string.statistics_today), color = MaterialTheme.colorScheme.onSurface)
Row {
Text(stringResource(R.string.duration) + ": " + shortLocalizedDuration(context, timePlayedToday), color = MaterialTheme.colorScheme.onSurface)
Spacer(Modifier.width(20.dp))
Text( stringResource(R.string.spent) + ": " + shortLocalizedDuration(context, timeSpentToday), color = MaterialTheme.colorScheme.onSurface)
}
val headerCaption = if (includeMarkedAsPlayed) stringResource(R.string.statistics_counting_total)
else {
if (timeFilterFrom != 0L || timeFilterTo != Long.MAX_VALUE) {
val skeleton = DateFormat.getBestDateTimePattern(Locale.getDefault(), "MMM yyyy")
val dateFormat = SimpleDateFormat(skeleton, Locale.getDefault())
val dateFrom = dateFormat.format(Date(timeFilterFrom))
// FilterTo is first day of next month => Subtract one day
val dateTo = dateFormat.format(Date(timeFilterTo - 24L * 3600000L))
stringResource(R.string.statistics_counting_range, dateFrom, dateTo)
} else stringResource(R.string.statistics_counting_total)
}
Text(headerCaption, color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.padding(top = 20.dp))
Row {
Text(stringResource(R.string.duration) + ": " + shortLocalizedDuration(context, lineChartData.sum.toLong()), color = MaterialTheme.colorScheme.onSurface)
Spacer(Modifier.width(20.dp))
Text( stringResource(R.string.spent) + ": " + shortLocalizedDuration(context, timeSpentSum), color = MaterialTheme.colorScheme.onSurface)
}
HorizontalLineChart(lineChartData)
StatsList(statisticsData, lineChartData) { item ->
context.getString(R.string.duration) + ": " + shortLocalizedDuration(context, item!!.timePlayed) +
"\t" + context.getString(R.string.spent) + ": " + shortLocalizedDuration(context, item.timeSpent)
}
}
}
}
}
return composeView
}
override fun onStart() {
super.onStart()
procFlowEvents()
}
override fun onStop() {
super.onStop()
cancelFlowEvents()
}
private var eventSink: Job? = null
private fun cancelFlowEvents() {
eventSink?.cancel()
eventSink = null
}
private fun procFlowEvents() {
if (eventSink != null) return
eventSink = lifecycleScope.launch {
EventFlow.events.collectLatest { event ->
Logd(TAG, "Received event: ${event.TAG}")
when (event) {
is FlowEvent.StatisticsEvent -> loadStatistics()
else -> {}
}
}
}
}
@Deprecated("Deprecated in Java")
override fun onPrepareOptionsMenu(menu: Menu) {
super.onPrepareOptionsMenu(menu)
menu.findItem(R.id.statistics_reset).setVisible(true)
menu.findItem(R.id.statistics_filter).setVisible(true)
}
@Deprecated("Deprecated in Java")
override fun onOptionsItemSelected(item: MenuItem): Boolean {
if (item.itemId == R.id.statistics_filter) {
val dialog = object: DatesFilterDialog(requireContext(), statisticsData.oldestDate) {
override fun initParams() {
prefs = Companion.prefs
includeMarkedAsPlayed = prefs!!.getBoolean(PREF_INCLUDE_MARKED_PLAYED, false)
timeFilterFrom = prefs!!.getLong(PREF_FILTER_FROM, 0)
timeFilterTo = prefs!!.getLong(PREF_FILTER_TO, Long.MAX_VALUE)
}
override fun callback(timeFilterFrom: Long, timeFilterTo: Long, includeMarkedAsPlayed: Boolean) {
prefs!!.edit()
.putBoolean(PREF_INCLUDE_MARKED_PLAYED, includeMarkedAsPlayed)
.putLong(PREF_FILTER_FROM, timeFilterFrom)
.putLong(PREF_FILTER_TO, timeFilterTo)
.apply()
EventFlow.postEvent(FlowEvent.StatisticsEvent())
}
}
dialog.show()
return true
}
return super.onOptionsItemSelected(item)
}
private fun setTimeFilter(includeMarkedAsPlayed: Boolean, timeFilterFrom: Long, timeFilterTo: Long) {
this.includeMarkedAsPlayed = includeMarkedAsPlayed
this.timeFilterFrom = timeFilterFrom
this.timeFilterTo = timeFilterTo
}
private fun loadStatistics() {
val statsToday = getStatistics(true, LocalDate.now().atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli(), Long.MAX_VALUE)
for (item in statsToday.statsItems) {
timePlayedToday += item.timePlayed
timeSpentToday += item.timeSpent
}
val includeMarkedAsPlayed = prefs!!.getBoolean(PREF_INCLUDE_MARKED_PLAYED, false)
val timeFilterFrom = prefs!!.getLong(PREF_FILTER_FROM, 0)
val timeFilterTo = prefs!!.getLong(PREF_FILTER_TO, Long.MAX_VALUE)
try {
statisticsData = getStatistics(includeMarkedAsPlayed, timeFilterFrom, timeFilterTo)
statisticsData.statsItems.sortWith { item1: StatisticsItem, item2: StatisticsItem -> item2.timePlayed.compareTo(item1.timePlayed) }
val dataValues = MutableList(statisticsData.statsItems.size){0f}
for (i in statisticsData.statsItems.indices) {
val item = statisticsData.statsItems[i]
dataValues[i] = item.timePlayed.toFloat()
timeSpentSum += item.timeSpent
}
lineChartData = LineChartData(dataValues)
// When "from" is "today", set it to today
setTimeFilter(includeMarkedAsPlayed,
max(min(timeFilterFrom.toDouble(), System.currentTimeMillis().toDouble()), statisticsData.oldestDate.toDouble()).toLong(),
min(timeFilterTo.toDouble(), System.currentTimeMillis().toDouble()).toLong())
} catch (error: Throwable) { Log.e(TAG, Log.getStackTraceString(error)) }
}
}
class MonthlyStatisticsFragment : Fragment() {
private lateinit var monthlyStats: List<MonthlyStatisticsItem>
private var maxDataValue = 1f
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
loadStatistics()
val composeView = ComposeView(requireContext()).apply {
setContent {
CustomTheme(requireContext()) {
Column {
Row(modifier = Modifier.horizontalScroll(rememberScrollState()).padding(start = 20.dp, end = 20.dp)) { BarChart() }
Spacer(Modifier.height(20.dp))
MonthList()
}
}
}
}
return composeView
}
override fun onStart() {
super.onStart()
procFlowEvents()
}
override fun onStop() {
super.onStop()
cancelFlowEvents()
}
private var eventSink: Job? = null
private fun cancelFlowEvents() {
eventSink?.cancel()
eventSink = null
}
private fun procFlowEvents() {
if (eventSink != null) return
eventSink = lifecycleScope.launch {
EventFlow.events.collectLatest { event ->
Logd(TAG, "Received event: ${event.TAG}")
when (event) {
is FlowEvent.StatisticsEvent -> loadStatistics()
else -> {}
}
}
}
}
@Composable
fun BarChart() {
val barWidth = 40f
val spaceBetweenBars = 16f
Canvas(modifier = Modifier.width((monthlyStats.size * (barWidth + spaceBetweenBars)).dp).height(150.dp)) {
// val canvasWidth = size.width
val canvasHeight = size.height
for (index in monthlyStats.indices) {
val barHeight = (monthlyStats[index].timePlayed / maxDataValue) * canvasHeight // Normalize height
Logd(TAG, "index: $index barHeight: $barHeight")
val xOffset = spaceBetweenBars + index * (barWidth + spaceBetweenBars) // Calculate x position
drawRect(color = Color.Cyan,
topLeft = androidx.compose.ui.geometry.Offset(xOffset, canvasHeight - barHeight),
size = androidx.compose.ui.geometry.Size(barWidth, barHeight)
)
}
}
}
@Composable
fun MonthList() {
val lazyListState = rememberLazyListState()
val textColor = MaterialTheme.colorScheme.onSurface
LazyColumn(state = lazyListState, modifier = Modifier.fillMaxWidth().padding(start = 10.dp, end = 10.dp, top = 10.dp, bottom = 10.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)) {
itemsIndexed(monthlyStats) { index, item ->
Row(Modifier.background(MaterialTheme.colorScheme.surface)) {
Column {
val monthString = String.format(Locale.getDefault(), "%d-%d", monthlyStats[index].year, monthlyStats[index].month)
Text(monthString, color = textColor, style = MaterialTheme.typography.bodyLarge.merge())
val hoursString = stringResource(R.string.duration) + ": " + String.format(Locale.getDefault(), "%.1f ", monthlyStats[index].timePlayed / 3600000.0f) + stringResource(R.string.time_hours) +
"\t" + stringResource(R.string.spent) + ": " + String.format(Locale.getDefault(), "%.1f ", monthlyStats[index].timeSpent / 3600000.0f) + stringResource(R.string.time_hours)
Text(hoursString, color = textColor, style = MaterialTheme.typography.bodyMedium)
}
}
}
}
}
@Deprecated("Deprecated in Java")
override fun onPrepareOptionsMenu(menu: Menu) {
super.onPrepareOptionsMenu(menu)
menu.findItem(R.id.statistics_reset).setVisible(true)
menu.findItem(R.id.statistics_filter).setVisible(false)
}
private fun loadStatistics() {
try {
monthlyStats = getMonthlyTimeStatistics()
for (item in monthlyStats) maxDataValue = max(maxDataValue.toDouble(), item.timePlayed.toDouble()).toFloat()
Logd(TAG, "maxDataValue: $maxDataValue")
} catch (error: Throwable) { Log.e(TAG, Log.getStackTraceString(error)) }
}
private fun getMonthlyTimeStatistics(): List<MonthlyStatisticsItem> {
Logd(TAG, "getMonthlyTimeStatistics called")
val includeMarkedAsPlayed = prefs!!.getBoolean(PREF_INCLUDE_MARKED_PLAYED, false)
val months: MutableList<MonthlyStatisticsItem> = ArrayList()
val medias = realm.query(EpisodeMedia::class).query("lastPlayedTime > 0").find()
val groupdMedias = medias.groupBy {
val calendar = Calendar.getInstance()
calendar.timeInMillis = it.lastPlayedTime
"${calendar.get(Calendar.YEAR)}-${calendar.get(Calendar.MONTH) + 1}"
}
val orderedGroupedItems = groupdMedias.toList().sortedBy {
val (key, _) = it
val year = key.substringBefore("-").toInt()
val month = key.substringAfter("-").toInt()
year * 12 + month
}.toMap()
for (key in orderedGroupedItems.keys) {
val medias_ = orderedGroupedItems[key] ?: continue
val mItem = MonthlyStatisticsItem()
mItem.year = key.substringBefore("-").toInt()
mItem.month = key.substringAfter("-").toInt()
var dur = 0L
var spent = 0L
for (m in medias_) {
dur += if (m.playedDuration > 0) m.playedDuration
else {
if (includeMarkedAsPlayed) {
if (m.playbackCompletionTime > 0 || (m.episodeOrFetch()?.playState ?: -10) >= PlayState.SKIPPED.code) m.duration
else if (m.position > 0) m.position else 0
} else m.position
}
spent += m.timeSpent
}
mItem.timePlayed = dur
mItem.timeSpent = spent
months.add(mItem)
}
return months
}
}
class DownloadStatisticsFragment : Fragment() {
private lateinit var statisticsData: StatisticsResult
private lateinit var lineChartData: LineChartData
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
loadStatistics()
val composeView = ComposeView(requireContext()).apply {
setContent {
CustomTheme(requireContext()) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(stringResource(R.string.total_size_downloaded_podcasts), color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.padding(top = 20.dp, bottom = 10.dp))
Text(Formatter.formatShortFileSize(context, lineChartData.sum.toLong()), color = MaterialTheme.colorScheme.onSurface)
HorizontalLineChart(lineChartData)
StatsList(statisticsData, lineChartData) { item ->
("${Formatter.formatShortFileSize(context, item!!.totalDownloadSize)}"
+ String.format(Locale.getDefault(), "%d%s", item.episodesDownloadCount, context.getString(R.string.episodes_suffix)))
}
}
}
}
}
return composeView
}
@Deprecated("Deprecated in Java")
override fun onPrepareOptionsMenu(menu: Menu) {
super.onPrepareOptionsMenu(menu)
menu.findItem(R.id.statistics_reset).setVisible(false)
menu.findItem(R.id.statistics_filter).setVisible(false)
}
private fun loadStatistics() {
statisticsData = getStatistics(false, 0, Long.MAX_VALUE)
statisticsData.statsItems.sortWith { item1: StatisticsItem, item2: StatisticsItem -> item2.totalDownloadSize.compareTo(item1.totalDownloadSize) }
val dataValues = MutableList(statisticsData.statsItems.size) { 0f }
for (i in statisticsData.statsItems.indices) {
val item = statisticsData.statsItems[i]
dataValues[i] = item.totalDownloadSize.toFloat()
}
lineChartData = LineChartData(dataValues)
}
}
class LineChartData(val values: MutableList<Float>) {
val sum: Float
init {
var valueSum = 0f
for (datum in values) valueSum += datum
this.sum = valueSum
}
private fun getPercentageOfItem(index: Int): Float {
if (sum == 0f) return 0f
return values[index] / sum
}
private fun isLargeEnoughToDisplay(index: Int): Boolean {
return getPercentageOfItem(index) > 0.04
}
fun getComposeColorOfItem(index: Int): Color {
if (!isLargeEnoughToDisplay(index)) return Color.Gray
return Color(COLOR_VALUES[index % COLOR_VALUES.size])
}
companion object {
private val COLOR_VALUES = mutableListOf(-0xc88a1a, -0x1ae3dd, -0x6800, -0xda64dc, -0x63d850,
-0xff663a, -0x22bb89, -0x995600, -0x47d1d2, -0xce9c6b,
-0x66bb67, -0xdd5567, -0x5555ef, -0x99cc34, -0xff8c1a)
}
}
companion object {
val TAG = StatisticsFragment::class.simpleName ?: "Anonymous"
private const val PREF_NAME: String = "StatisticsActivityPrefs"
const val PREF_INCLUDE_MARKED_PLAYED: String = "countAll"
const val PREF_FILTER_FROM: String = "filterFrom"
const val PREF_FILTER_TO: String = "filterTo"
private const val POS_SUBSCRIPTIONS = 0
private const val POS_YEARS = 1
private const val POS_SPACE_TAKEN = 2
private const val TOTAL_COUNT = 3
var prefs: SharedPreferences? = null
fun getSharedPrefs(context: Context) {
if (prefs == null) prefs = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
}
@Composable
fun HorizontalLineChart(lineChartData: LineChartData) {
val data = lineChartData.values
val total = data.sum()
Canvas(modifier = Modifier.fillMaxWidth().height(50.dp).padding(start = 20.dp, end = 20.dp)) {
val canvasWidth = size.width
val canvasHeight = size.height
val lineY = canvasHeight / 2
var startX = 0f
for (index in data.indices) {
val segmentWidth = (data[index] / total) * canvasWidth
Logd(TAG, "index: $index segmentWidth: $segmentWidth")
drawRect(color = lineChartData.getComposeColorOfItem(index),
topLeft = androidx.compose.ui.geometry.Offset(startX, lineY - 10),
size = androidx.compose.ui.geometry.Size(segmentWidth, 20f))
startX += segmentWidth
}
}
}
@Composable
fun StatsList(statisticsData: StatisticsResult, lineChartData: LineChartData, infoCB: (StatisticsItem?)->String) {
val lazyListState = rememberLazyListState()
val context = LocalContext.current
var showFeedStats by remember { mutableStateOf(false) }
var feedId by remember { mutableLongStateOf(0L) }
var feedTitle by remember { mutableStateOf("") }
if (showFeedStats) FeedStatisticsDialog(feedTitle, feedId) { showFeedStats = false }
LazyColumn(state = lazyListState, modifier = Modifier.fillMaxWidth().padding(start = 10.dp, end = 10.dp, top = 10.dp, bottom = 10.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)) {
itemsIndexed(statisticsData.statsItems, key = { _, item -> item.feed.id }) { index, item ->
Row(Modifier.background(MaterialTheme.colorScheme.surface).fillMaxWidth().clickable(onClick = {
Logd(SubscriptionsFragment.TAG, "icon clicked!")
feedId = item.feed.id
feedTitle = item.feed.title ?: "No title"
showFeedStats = true
})) {
val imgLoc = remember(item) { item.feed.imageUrl }
AsyncImage(model = ImageRequest.Builder(context).data(imgLoc).memoryCachePolicy(CachePolicy.ENABLED).placeholder(R.mipmap.ic_launcher).error(R.mipmap.ic_launcher).build(),
contentDescription = "imgvCover", placeholder = painterResource(R.mipmap.ic_launcher), error = painterResource(R.mipmap.ic_launcher),
modifier = Modifier.width(40.dp).height(40.dp).padding(end = 5.dp)
)
val textColor = MaterialTheme.colorScheme.onSurface
Column {
Text(item.feed.title?:"No title", color = textColor, style = MaterialTheme.typography.bodyLarge.merge())
Row {
val chipColor = lineChartData.getComposeColorOfItem(index)
Text("", style = MaterialTheme.typography.bodyMedium.merge(), color = chipColor)
Text(infoCB(item), color = textColor, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(start = 2.dp))
}
}
}
}
}
}
@Composable
fun FeedStatisticsDialog(title: String, feedId: Long, onDismissRequest: () -> Unit) {
var statisticsData: StatisticsItem? = null
fun loadStatistics() {
try {
val data = getStatistics(false, 0, Long.MAX_VALUE, feedId)
data.statsItems.sortWith { item1: StatisticsItem, item2: StatisticsItem -> item2.timePlayed.compareTo(item1.timePlayed) }
if (data.statsItems.isNotEmpty()) statisticsData = data.statsItems[0]
} catch (error: Throwable) { error.printStackTrace() }
}
loadStatistics()
Dialog(onDismissRequest = { onDismissRequest() }) {
Card(modifier = Modifier.wrapContentSize(align = Alignment.Center).padding(16.dp), shape = RoundedCornerShape(16.dp), border = BorderStroke(1.dp, Color.Yellow)) {
val context = LocalContext.current
val textColor = MaterialTheme.colorScheme.onSurface
Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
Text(title)
Row {
Text(stringResource(R.string.statistics_episodes_started_total), color = textColor, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f))
Text(String.format(Locale.getDefault(), "%d / %d", statisticsData?.episodesStarted ?: 0, statisticsData?.numEpisodes ?: 0), color = textColor, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(0.4f))
}
Row {
Text(stringResource(R.string.statistics_length_played), color = textColor, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f))
Text(shortLocalizedDuration(context, statisticsData?.durationOfStarted ?: 0), color = textColor, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(0.4f))
}
Row {
Text(stringResource(R.string.statistics_time_played), color = textColor, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f))
Text(shortLocalizedDuration(context, statisticsData?.timePlayed ?: 0), color = textColor, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(0.4f))
}
Row {
Text(stringResource(R.string.statistics_time_spent), color = textColor, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f))
Text(shortLocalizedDuration(context, statisticsData?.timeSpent ?: 0), color = textColor, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(0.4f))
}
Row {
Text(stringResource(R.string.statistics_total_duration), color = textColor, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f))
Text(shortLocalizedDuration(context, statisticsData?.time ?: 0), color = textColor, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(0.4f))
}
Row {
Text(stringResource(R.string.statistics_episodes_on_device), color = textColor, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f))
Text(String.format(Locale.getDefault(), "%d", statisticsData?.episodesDownloadCount ?: 0), color = textColor, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(0.4f))
}
Row {
Text(stringResource(R.string.statistics_space_used), color = textColor, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f))
Text(Formatter.formatShortFileSize(context, statisticsData?.totalDownloadSize ?: 0), color = textColor, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(0.4f))
}
Row {
Button(onClick = { onDismissRequest() }) {Text(stringResource(android.R.string.ok)) }
Spacer(Modifier.weight(1f))
Button(onClick = {
MainActivityStarter(context).withOpenFeed(feedId).withAddToBackStack().start()
onDismissRequest()
}) {Text(stringResource(R.string.open_podcast)) }
}
}
}
}
}
}
}

View File

@ -127,8 +127,8 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener {
private val playStateCodeSet = mutableSetOf<String>()
private val ratingSort = MutableList(Rating.entries.size) { mutableStateOf(false)}
private val ratingCodeSet = mutableSetOf<String>()
private var downlaodedSort by mutableStateOf(false)
private var commentedSort by mutableStateOf(false)
private var downlaodedSortIndex by mutableStateOf(-1)
private var commentedSortIndex by mutableStateOf(-1)
private var feedListFiltered = mutableStateListOf<Feed>()
private var showFilterDialog by mutableStateOf(false)
@ -402,7 +402,7 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener {
fun AutoDeleteHandlerDialog(onDismissRequest: () -> Unit) {
val (selectedOption, _) = remember { mutableStateOf("") }
Dialog(onDismissRequest = { onDismissRequest() }) {
Card(modifier = Modifier.wrapContentSize(align = Alignment.Center).padding(16.dp), shape = RoundedCornerShape(16.dp)) {
Card(modifier = Modifier.wrapContentSize(align = Alignment.Center).padding(16.dp), shape = RoundedCornerShape(16.dp), border = BorderStroke(1.dp, Color.Yellow)) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Column {
FeedAutoDeleteOptions.forEach { text ->
@ -427,7 +427,7 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener {
fun SetAssociateQueueDialog(onDismissRequest: () -> Unit) {
var selectedOption by remember {mutableStateOf("")}
Dialog(onDismissRequest = { onDismissRequest() }) {
Card(modifier = Modifier.wrapContentSize(align = Alignment.Center).padding(16.dp), shape = RoundedCornerShape(16.dp)) {
Card(modifier = Modifier.wrapContentSize(align = Alignment.Center).padding(16.dp), shape = RoundedCornerShape(16.dp), border = BorderStroke(1.dp, Color.Yellow)) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
queueSettingOptions.forEach { option ->
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
@ -471,7 +471,7 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener {
@Composable
fun SetKeepUpdateDialog(onDismissRequest: () -> Unit) {
Dialog(onDismissRequest = { onDismissRequest() }) {
Card(modifier = Modifier.wrapContentSize(align = Alignment.Center).padding(16.dp), shape = RoundedCornerShape(16.dp)) {
Card(modifier = Modifier.wrapContentSize(align = Alignment.Center).padding(16.dp), shape = RoundedCornerShape(16.dp), border = BorderStroke(1.dp, Color.Yellow)) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.Center) {
Row(Modifier.fillMaxWidth()) {
Icon(ImageVector.vectorResource(id = R.drawable.ic_refresh), "")
@ -493,7 +493,7 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener {
@Composable
fun ChooseRatingDialog(selected: List<Feed>, onDismissRequest: () -> Unit) {
Dialog(onDismissRequest = onDismissRequest) {
Surface(shape = RoundedCornerShape(16.dp)) {
Surface(shape = RoundedCornerShape(16.dp), border = BorderStroke(1.dp, Color.Yellow)) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
for (rating in Rating.entries.reversed()) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(4.dp).clickable {
@ -893,8 +893,8 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener {
appPrefs.edit().putBoolean("dateAscending", dateAscending).apply()
appPrefs.edit().putBoolean("countAscending", countAscending).apply()
appPrefs.edit().putInt("dateSortIndex", dateSortIndex).apply()
appPrefs.edit().putBoolean("downlaodedSort", downlaodedSort).apply()
appPrefs.edit().putBoolean("commentedSort", commentedSort).apply()
appPrefs.edit().putInt("downlaodedSortIndex", downlaodedSortIndex).apply()
appPrefs.edit().putInt("commentedSortIndex", commentedSortIndex).apply()
sortArrays2CodeSet()
appPrefs.edit().putStringSet("playStateCodeSet", playStateCodeSet).apply()
appPrefs.edit().putStringSet("ratingCodeSet", ratingCodeSet).apply()
@ -906,8 +906,8 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener {
dateAscending = appPrefs.getBoolean("dateAscending", true)
countAscending = appPrefs.getBoolean("countAscending", true)
dateSortIndex = appPrefs.getInt("dateSortIndex", 0)
downlaodedSort = appPrefs.getBoolean("downlaodedSort", true)
commentedSort = appPrefs.getBoolean("commentedSort", true)
downlaodedSortIndex = appPrefs.getInt("downlaodedSortIndex", -1)
commentedSortIndex = appPrefs.getInt("commentedSortIndex", -1)
playStateCodeSet.clear()
playStateCodeSet.addAll(appPrefs.getStringSet("playStateCodeSet", setOf())!!)
ratingCodeSet.clear()
@ -996,8 +996,8 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener {
ratingQueries += " rating == ${Rating.entries[i].code} "
}
}
val downloadedQuery = if (downlaodedSort) " media.downloaded == true " else ""
val commentedQuery = if (commentedSort) " comment != '' " else ""
val downloadedQuery = if (downlaodedSortIndex == 0) " media.downloaded == true " else if (downlaodedSortIndex == 1) " media.downloaded == false " else ""
val commentedQuery = if (commentedSortIndex == 0) " comment != '' " else if (commentedSortIndex == 1) " comment == '' " else ""
var queryString = "feedId == $0"
if (playStateQueries.isNotEmpty()) queryString += " AND ($playStateQueries)"
@ -1104,29 +1104,78 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener {
HorizontalDivider(color = Color.Yellow, thickness = 1.dp)
Column(modifier = Modifier.padding(start = 5.dp, bottom = 2.dp).fillMaxWidth()) {
if (sortIndex == 2) {
OutlinedButton(modifier = Modifier.padding(5.dp), elevation = null, border = BorderStroke(2.dp, if (!downlaodedSort) textColor else Color.Green),
onClick = {
downlaodedSort = !downlaodedSort
doSort()
saveSortingPrefs()
}
) { Text(stringResource(R.string.downloaded_label)) }
OutlinedButton(modifier = Modifier.padding(5.dp), elevation = null, border = BorderStroke(2.dp, if (!commentedSort) textColor else Color.Green),
onClick = {
commentedSort = !commentedSort
doSort()
saveSortingPrefs()
}
) { Text(stringResource(R.string.commented)) }
Row(modifier = Modifier.padding(2.dp).fillMaxWidth(), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically) {
val item = EpisodeFilter.EpisodesFilterGroup.DOWNLOADED
var selectNone by remember { mutableStateOf(false) }
if (selectNone) downlaodedSortIndex = -1
Text(stringResource(item.nameRes) + " :", fontWeight = FontWeight.Bold, style = MaterialTheme.typography.headlineSmall, color = textColor, modifier = Modifier.padding(end = 10.dp))
Spacer(Modifier.weight(0.3f))
OutlinedButton(
modifier = Modifier.padding(0.dp), border = BorderStroke(2.dp, if (downlaodedSortIndex != 0) textColor else Color.Green),
onClick = {
if (downlaodedSortIndex != 0) {
selectNone = false
downlaodedSortIndex = 0
} else downlaodedSortIndex = -1
doSort()
saveSortingPrefs()
},
) { Text(text = stringResource(item.values[0].displayName), color = textColor) }
Spacer(Modifier.weight(0.1f))
OutlinedButton(
modifier = Modifier.padding(0.dp), border = BorderStroke(2.dp, if (downlaodedSortIndex != 1) textColor else Color.Green),
onClick = {
if (downlaodedSortIndex != 1) {
selectNone = false
downlaodedSortIndex = 1
} else downlaodedSortIndex = -1
doSort()
saveSortingPrefs()
},
) { Text(text = stringResource(item.values[1].displayName), color = textColor) }
Spacer(Modifier.weight(0.5f))
}
Row(modifier = Modifier.padding(2.dp).fillMaxWidth(), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically) {
val item = EpisodeFilter.EpisodesFilterGroup.OPINION
var selectNone by remember { mutableStateOf(false) }
if (selectNone) commentedSortIndex = -1
Text(stringResource(item.nameRes) + " :", fontWeight = FontWeight.Bold, style = MaterialTheme.typography.headlineSmall, color = textColor, modifier = Modifier.padding(end = 10.dp))
Spacer(Modifier.weight(0.3f))
OutlinedButton(
modifier = Modifier.padding(0.dp), border = BorderStroke(2.dp, if (commentedSortIndex != 0) textColor else Color.Green),
onClick = {
if (commentedSortIndex != 0) {
selectNone = false
commentedSortIndex = 0
} else commentedSortIndex = -1
doSort()
saveSortingPrefs()
},
) { Text(text = stringResource(item.values[0].displayName), color = textColor) }
Spacer(Modifier.weight(0.1f))
OutlinedButton(
modifier = Modifier.padding(0.dp), border = BorderStroke(2.dp, if (commentedSortIndex != 1) textColor else Color.Green),
onClick = {
if (commentedSortIndex != 1) {
selectNone = false
commentedSortIndex = 1
} else commentedSortIndex = -1
doSort()
saveSortingPrefs()
},
) { Text(text = stringResource(item.values[1].displayName), color = textColor) }
Spacer(Modifier.weight(0.5f))
}
}
if ((sortIndex == 1 && dateSortIndex == 0) || sortIndex == 2) {
val item = EpisodeFilter.EpisodesFilterGroup.PLAY_STATE
var selectNone by remember { mutableStateOf(false) }
var expandRow by remember { mutableStateOf(false) }
Row {
Text(stringResource(item.nameRes) + ".. :", fontWeight = FontWeight.Bold, style = MaterialTheme.typography.headlineSmall, color = textColor, modifier = Modifier.clickable {
expandRow = !expandRow
})
Text(stringResource(item.nameRes) + ".. :", fontWeight = FontWeight.Bold, style = MaterialTheme.typography.headlineSmall, color = textColor,
modifier = Modifier.clickable {
expandRow = !expandRow
})
var lowerSelected by remember { mutableStateOf(false) }
var higherSelected by remember { mutableStateOf(false) }
Spacer(Modifier.weight(1f))
@ -1149,9 +1198,7 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener {
modifier = Modifier.clickable {
lowerSelected = false
higherSelected = false
for (i in item.values.indices) {
playStateSort[i].value = false
}
for (i in item.values.indices) playStateSort[i].value = false
doSort()
saveSortingPrefs()
})
@ -1186,9 +1233,7 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener {
doSort()
saveSortingPrefs()
},
) {
Text(text = stringResource(item.values[index].displayName), maxLines = 1, color = textColor)
}
) { Text(text = stringResource(item.values[index].displayName), maxLines = 1, color = textColor) }
}
}
if (sortIndex == 2) {
@ -1196,9 +1241,10 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener {
var selectNone by remember { mutableStateOf(false) }
var expandRow by remember { mutableStateOf(false) }
Row {
Text(stringResource(item.nameRes) + ".. :", fontWeight = FontWeight.Bold, style = MaterialTheme.typography.headlineSmall, color = textColor, modifier = Modifier.clickable {
expandRow = !expandRow
})
Text(stringResource(item.nameRes) + ".. :", fontWeight = FontWeight.Bold, style = MaterialTheme.typography.headlineSmall, color = textColor,
modifier = Modifier.clickable {
expandRow = !expandRow
})
var lowerSelected by remember { mutableStateOf(false) }
var higherSelected by remember { mutableStateOf(false) }
Spacer(Modifier.weight(1f))
@ -1221,9 +1267,7 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener {
modifier = Modifier.clickable {
lowerSelected = false
higherSelected = false
for (i in item.values.indices) {
ratingSort[i].value = false
}
for (i in item.values.indices) ratingSort[i].value = false
doSort()
saveSortingPrefs()
})
@ -1246,9 +1290,6 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener {
}
if (expandRow) NonlazyGrid(columns = 3, itemCount = item.values.size) { index ->
if (selectNone) ratingSort[index].value = false
LaunchedEffect(Unit) {
// if (filter != null && item.values[index].filterId in filter.properties) selectedList[index].value = true
}
OutlinedButton(
modifier = Modifier.padding(0.dp).heightIn(min = 20.dp).widthIn(min = 20.dp).wrapContentWidth(),
border = BorderStroke(2.dp, if (ratingSort[index].value) Color.Green else textColor),

View File

@ -1,127 +0,0 @@
package ac.mdiq.podcini.ui.statistics
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.*
import android.graphics.drawable.Drawable
import android.util.AttributeSet
import androidx.appcompat.widget.AppCompatImageView
import ac.mdiq.podcini.storage.model.MonthlyStatisticsItem
import ac.mdiq.podcini.ui.utils.ThemeUtils.getColorFromAttr
import kotlin.math.floor
import kotlin.math.max
class BarChartView : AppCompatImageView {
private var drawable: BarChartDrawable? = null
constructor(context: Context) : super(context!!) {
setup()
}
constructor(context: Context, attrs: AttributeSet?) : super(context!!, attrs) {
setup()
}
constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context!!, attrs, defStyleAttr) {
setup()
}
@SuppressLint("ClickableViewAccessibility")
private fun setup() {
drawable = BarChartDrawable()
setImageDrawable(drawable)
}
/**
* Set of data values to display.
*/
fun setData(data: List<MonthlyStatisticsItem>) {
drawable!!.data = data
drawable!!.maxValue = 1
for (item in data) {
drawable!!.maxValue = max(drawable!!.maxValue.toDouble(), item.timePlayed.toDouble()).toLong()
}
}
private inner class BarChartDrawable : Drawable() {
private val ONE_HOUR = 3600000L
var data: List<MonthlyStatisticsItem>? = null
var maxValue: Long = 1
private val paintBars: Paint
private val paintGridLines: Paint
private val paintGridText: Paint
private val colors = intArrayOf(0, -0x63d850)
init {
colors[0] = getColorFromAttr(context, androidx.appcompat.R.attr.colorAccent)
paintBars = Paint()
paintBars.style = Paint.Style.FILL
paintBars.isAntiAlias = true
paintGridLines = Paint()
paintGridLines.style = Paint.Style.STROKE
paintGridLines.setPathEffect(DashPathEffect(floatArrayOf(10f, 10f), 0f))
paintGridLines.color =
getColorFromAttr(context, android.R.attr.textColorSecondary)
paintGridText = Paint()
paintGridText.isAntiAlias = true
paintGridText.color =
getColorFromAttr(context, android.R.attr.textColorSecondary)
}
override fun draw(canvas: Canvas) {
val width = bounds.width().toFloat()
val height = bounds.height().toFloat()
val barHeight = height * 0.9f
val textPadding = width * 0.05f
val stepSize = (width - textPadding) / (data!!.size + 2)
val textSize = height * 0.06f
paintGridText.textSize = textSize
paintBars.strokeWidth = height * 0.015f
paintBars.color = colors[0]
var colorIndex = 0
var lastYear = if (data!!.size > 0) data!![0].year else 0
for (i in data!!.indices) {
val x = textPadding + (i + 1) * stepSize
if (lastYear != data!![i].year) {
lastYear = data!![i].year
colorIndex++
paintBars.color = colors[colorIndex % 2]
if (i < data!!.size - 2) {
canvas.drawText(data!![i].year.toString(), x + stepSize,
barHeight + (height - barHeight + textSize) / 2, paintGridText)
}
canvas.drawLine(x, height, x, barHeight, paintGridText)
}
val valuePercentage = max(0.005, (data!![i].timePlayed.toFloat() / maxValue).toDouble())
.toFloat()
val y = (1 - valuePercentage) * barHeight
canvas.drawRect(x, y, x + stepSize * 0.95f, barHeight, paintBars)
}
val maxLine = (floor(maxValue / (10.0 * ONE_HOUR)) * 10 * ONE_HOUR).toFloat()
var y = (1 - (maxLine / maxValue)) * barHeight
canvas.drawLine(0f, y, width, y, paintGridLines)
canvas.drawText((maxLine.toLong() / ONE_HOUR).toString(), 0f, y + 1.2f * textSize, paintGridText)
val midLine = maxLine / 2
y = (1 - (midLine / maxValue)) * barHeight
canvas.drawLine(0f, y, width, y, paintGridLines)
canvas.drawText((midLine.toLong() / ONE_HOUR).toString(), 0f, y + 1.2f * textSize, paintGridText)
}
@Deprecated("Deprecated in Java")
override fun getOpacity(): Int {
return PixelFormat.TRANSLUCENT
}
override fun setAlpha(alpha: Int) {
}
override fun setColorFilter(cf: ColorFilter?) {
}
}
}

View File

@ -1,83 +0,0 @@
package ac.mdiq.podcini.ui.statistics
import ac.mdiq.podcini.databinding.FeedStatisticsBinding
import ac.mdiq.podcini.storage.database.LogsAndStats.getStatistics
import ac.mdiq.podcini.storage.model.StatisticsItem
import ac.mdiq.podcini.storage.utils.DurationConverter.shortLocalizedDuration
import android.os.Bundle
import android.text.format.Formatter
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.*
class FeedStatisticsFragment : Fragment() {
private var _binding: FeedStatisticsBinding? = null
private val binding get() = _binding!!
private var feedId: Long = 0
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
feedId = requireArguments().getLong(EXTRA_FEED_ID)
_binding = FeedStatisticsBinding.inflate(inflater)
if (!requireArguments().getBoolean(EXTRA_DETAILED)) {
for (i in 0 until binding.root.childCount) {
val child = binding.root.getChildAt(i)
if ("detailed" == child.tag) child.visibility = View.GONE
}
}
loadStatistics()
return binding.root
}
private fun loadStatistics() {
lifecycleScope.launch {
try {
val statisticsData = withContext(Dispatchers.IO) {
val data = getStatistics(true, 0, Long.MAX_VALUE)
data.statsItems.sortWith { item1: StatisticsItem, item2: StatisticsItem ->
item2.timePlayed.compareTo(item1.timePlayed)
}
for (statisticsItem in data.statsItems) {
if (statisticsItem.feed.id == feedId) return@withContext statisticsItem
}
null
}
showStats(statisticsData)
} catch (error: Throwable) { error.printStackTrace() }
}
}
private fun showStats(s: StatisticsItem?) {
if (s == null) return
binding.startedTotalLabel.text = String.format(Locale.getDefault(), "%d / %d", s.episodesStarted, s.numEpisodes)
binding.timePlayedLabel.text = shortLocalizedDuration(requireContext(), s.timePlayed)
binding.totalDurationLabel.text = shortLocalizedDuration(requireContext(), s.time)
binding.onDeviceLabel.text = String.format(Locale.getDefault(), "%d", s.episodesDownloadCount)
binding.spaceUsedLabel.text = Formatter.formatShortFileSize(context, s.totalDownloadSize)
}
override fun onDestroy() {
_binding = null
super.onDestroy()
}
companion object {
const val EXTRA_FEED_ID = "ac.mdiq.podcini.extra.feedId"
const val EXTRA_DETAILED = "ac.mdiq.podcini.extra.detailed"
fun newInstance(feedId: Long, detailed: Boolean): FeedStatisticsFragment {
val fragment = FeedStatisticsFragment()
val arguments = Bundle()
arguments.putLong(EXTRA_FEED_ID, feedId)
arguments.putBoolean(EXTRA_DETAILED, detailed)
fragment.arguments = arguments
return fragment
}
}
}

View File

@ -1,133 +0,0 @@
package ac.mdiq.podcini.ui.statistics
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.*
import android.graphics.drawable.Drawable
import android.util.AttributeSet
import androidx.appcompat.widget.AppCompatImageView
class PieChartView : AppCompatImageView {
private var drawable: PieChartDrawable? = null
constructor(context: Context) : super(context!!) {
setup()
}
constructor(context: Context, attrs: AttributeSet?) : super(context!!, attrs) {
setup()
}
constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context!!, attrs, defStyleAttr) {
setup()
}
@SuppressLint("ClickableViewAccessibility")
private fun setup() {
drawable = PieChartDrawable()
setImageDrawable(drawable)
}
/**
* Set of data values to display.
*/
fun setData(data: PieChartData?) {
drawable!!.data = data
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
val width = measuredWidth
setMeasuredDimension(width, width / 2)
}
class PieChartData(val values: FloatArray) {
val sum: Float
init {
var valueSum = 0f
for (datum in values) {
valueSum += datum
}
this.sum = valueSum
}
fun getPercentageOfItem(index: Int): Float {
if (sum == 0f) {
return 0f
}
return values[index] / sum
}
fun isLargeEnoughToDisplay(index: Int): Boolean {
return getPercentageOfItem(index) > 0.04
}
fun getColorOfItem(index: Int): Int {
if (!isLargeEnoughToDisplay(index)) {
return Color.GRAY
}
return COLOR_VALUES[index % COLOR_VALUES.size]
}
companion object {
private val COLOR_VALUES = intArrayOf(-0xc88a1a, -0x1ae3dd, -0x6800, -0xda64dc, -0x63d850,
-0xff663a, -0x22bb89, -0x995600, -0x47d1d2, -0xce9c6b,
-0x66bb67, -0xdd5567, -0x5555ef, -0x99cc34, -0xff8c1a)
}
}
private class PieChartDrawable : Drawable() {
var data: PieChartData? = null
private val paint = Paint()
init {
paint.flags = Paint.ANTI_ALIAS_FLAG
paint.style = Paint.Style.STROKE
paint.strokeJoin = Paint.Join.ROUND
paint.strokeCap = Paint.Cap.ROUND
}
override fun draw(canvas: Canvas) {
val strokeSize = bounds.height() / 30f
paint.strokeWidth = strokeSize
val radius = bounds.height() - strokeSize
val center = bounds.width() / 2f
val arcBounds = RectF(center - radius, strokeSize, center + radius, strokeSize + radius * 2)
var startAngle = 180f
for (i in data!!.values.indices) {
if (!data!!.isLargeEnoughToDisplay(i)) {
break
}
paint.color = data!!.getColorOfItem(i)
val padding = if (i == 0) PADDING_DEGREES / 2 else PADDING_DEGREES
val sweepAngle = (180f - PADDING_DEGREES) * data!!.getPercentageOfItem(i)
canvas.drawArc(arcBounds, startAngle + padding, sweepAngle - padding, false, paint)
startAngle += sweepAngle
}
paint.color = Color.GRAY
val sweepAngle = 360 - startAngle - PADDING_DEGREES / 2
if (sweepAngle > PADDING_DEGREES) {
canvas.drawArc(arcBounds, startAngle + PADDING_DEGREES, sweepAngle - PADDING_DEGREES, false, paint)
}
}
@Deprecated("Deprecated in Java")
override fun getOpacity(): Int {
return PixelFormat.TRANSLUCENT
}
override fun setAlpha(alpha: Int) {
}
override fun setColorFilter(cf: ColorFilter?) {
}
companion object {
private const val PADDING_DEGREES = 3f
}
}
}

View File

@ -1,751 +0,0 @@
package ac.mdiq.podcini.ui.statistics
import ac.mdiq.podcini.R
import ac.mdiq.podcini.databinding.*
import ac.mdiq.podcini.storage.database.LogsAndStats.getStatistics
import ac.mdiq.podcini.storage.database.RealmDB.realm
import ac.mdiq.podcini.storage.database.RealmDB.runOnIOScope
import ac.mdiq.podcini.storage.database.RealmDB.update
import ac.mdiq.podcini.storage.model.*
import ac.mdiq.podcini.storage.utils.DurationConverter.shortLocalizedDuration
import ac.mdiq.podcini.ui.activity.MainActivity
import ac.mdiq.podcini.ui.activity.starter.MainActivityStarter
import ac.mdiq.podcini.ui.dialog.ConfirmationDialog
import ac.mdiq.podcini.ui.dialog.DatesFilterDialog
import ac.mdiq.podcini.ui.statistics.PieChartView.PieChartData
import ac.mdiq.podcini.util.EventFlow
import ac.mdiq.podcini.util.FlowEvent
import ac.mdiq.podcini.util.Logd
import android.annotation.SuppressLint
import android.app.Dialog
import android.content.Context
import android.content.DialogInterface
import android.content.SharedPreferences
import android.os.Bundle
import android.text.format.DateFormat
import android.text.format.Formatter
import android.util.Log
import android.view.*
import android.widget.ImageView
import android.widget.ProgressBar
import android.widget.TextView
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.viewpager2.adapter.FragmentStateAdapter
import androidx.viewpager2.widget.ViewPager2
import androidx.viewpager2.widget.ViewPager2.OnPageChangeCallback
import coil.load
import com.google.android.material.appbar.MaterialToolbar
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.tabs.TabLayout
import com.google.android.material.tabs.TabLayoutMediator
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.text.SimpleDateFormat
import java.util.*
import kotlin.math.max
import kotlin.math.min
class StatisticsFragment : Fragment() {
private lateinit var tabLayout: TabLayout
private lateinit var viewPager: ViewPager2
private lateinit var toolbar: MaterialToolbar
private var _binding: PagerFragmentBinding? = null
private val binding get() = _binding!!
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
super.onCreateView(inflater, container, savedInstanceState)
setHasOptionsMenu(true)
_binding = PagerFragmentBinding.inflate(inflater)
viewPager = binding.viewpager
toolbar = binding.toolbar
toolbar.title = getString(R.string.statistics_label)
toolbar.inflateMenu(R.menu.statistics)
toolbar.setNavigationOnClickListener { parentFragmentManager.popBackStack() }
(activity as MainActivity).setupToolbarToggle(toolbar, false)
viewPager.adapter = PagerAdapter(this)
// Give the TabLayout the ViewPager
tabLayout = binding.slidingTabs
setupPagedToolbar(toolbar, viewPager)
TabLayoutMediator(tabLayout, viewPager) { tab: TabLayout.Tab, position: Int ->
when (position) {
POS_SUBSCRIPTIONS -> tab.setText(R.string.subscriptions_label)
POS_YEARS -> tab.setText(R.string.years_statistics_label)
POS_SPACE_TAKEN -> tab.setText(R.string.downloads_label)
else -> {}
}
}.attach()
return binding.root
}
override fun onDestroyView() {
Logd(TAG, "onDestroyView")
_binding = null
super.onDestroyView()
}
@Deprecated("Deprecated in Java")
override fun onOptionsItemSelected(item: MenuItem): Boolean {
if (item.itemId == R.id.statistics_reset) {
confirmResetStatistics()
return true
}
return super.onOptionsItemSelected(item)
}
/**
* Invalidate the toolbar menu if the current child fragment is visible.
* @param child The fragment to invalidate
*/
fun invalidateOptionsMenuIfActive(child: Fragment) {
val visibleChild = childFragmentManager.findFragmentByTag("f" + viewPager.currentItem)
if (visibleChild === child) visibleChild.onPrepareOptionsMenu(toolbar.menu)
}
private fun setupPagedToolbar(toolbar: MaterialToolbar, viewPager: ViewPager2) {
this.toolbar = toolbar
this.viewPager = viewPager
toolbar.setOnMenuItemClickListener { item: MenuItem? ->
if (this.onOptionsItemSelected(item!!)) return@setOnMenuItemClickListener true
val child = childFragmentManager.findFragmentByTag("f" + viewPager.currentItem)
if (child != null) return@setOnMenuItemClickListener child.onOptionsItemSelected(item)
false
}
viewPager.registerOnPageChangeCallback(object : OnPageChangeCallback() {
override fun onPageSelected(position: Int) {
val child = childFragmentManager.findFragmentByTag("f$position")
child?.onPrepareOptionsMenu(toolbar.menu)
}
})
}
private fun confirmResetStatistics() {
val conDialog: ConfirmationDialog = object : ConfirmationDialog(requireContext(),
R.string.statistics_reset_data, R.string.statistics_reset_data_msg) {
override fun onConfirmButtonPressed(dialog: DialogInterface) {
dialog.dismiss()
doResetStatistics()
}
}
conDialog.createNewDialog().show()
}
private fun doResetStatistics() {
prefs!!.edit()
.putBoolean(PREF_INCLUDE_MARKED_PLAYED, false)
.putLong(PREF_FILTER_FROM, 0)
.putLong(PREF_FILTER_TO, Long.MAX_VALUE)
.apply()
lifecycleScope.launch {
try {
withContext(Dispatchers.IO) {
resetStatistics()
}
// This runs on the Main thread
EventFlow.postEvent(FlowEvent.StatisticsEvent())
} catch (error: Throwable) {
// This also runs on the Main thread
Log.e(TAG, Log.getStackTraceString(error))
}
}
}
private fun resetStatistics(): Job {
return runOnIOScope {
val mediaAll = realm.query(EpisodeMedia::class).find()
for (m in mediaAll) {
update(m) { m.playedDuration = 0 }
}
}
}
private class PagerAdapter(fragment: Fragment) : FragmentStateAdapter(fragment) {
override fun createFragment(position: Int): Fragment {
return when (position) {
POS_SUBSCRIPTIONS -> SubscriptionStatisticsFragment()
POS_YEARS -> YearsStatisticsFragment()
POS_SPACE_TAKEN -> DownloadStatisticsFragment()
else -> DownloadStatisticsFragment()
}
}
override fun getItemCount(): Int {
return TOTAL_COUNT
}
}
class SubscriptionStatisticsFragment : Fragment() {
private var _binding: StatisticsFragmentBinding? = null
private val binding get() = _binding!!
private var statisticsResult: StatisticsResult? = null
private lateinit var feedStatisticsList: RecyclerView
private lateinit var progressBar: ProgressBar
private lateinit var listAdapter: ListAdapter
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setHasOptionsMenu(true)
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = StatisticsFragmentBinding.inflate(inflater)
feedStatisticsList = binding.statisticsList
progressBar = binding.progressBar
listAdapter = ListAdapter(this)
feedStatisticsList.layoutManager = LinearLayoutManager(context)
feedStatisticsList.adapter = listAdapter
refreshStatistics()
return binding.root
}
override fun onStart() {
super.onStart()
procFlowEvents()
}
override fun onStop() {
super.onStop()
cancelFlowEvents()
}
override fun onDestroyView() {
Logd(TAG, "onDestroyView")
_binding = null
super.onDestroyView()
}
private var eventSink: Job? = null
private fun cancelFlowEvents() {
eventSink?.cancel()
eventSink = null
}
private fun procFlowEvents() {
if (eventSink != null) return
eventSink = lifecycleScope.launch {
EventFlow.events.collectLatest { event ->
Logd(TAG, "Received event: ${event.TAG}")
when (event) {
is FlowEvent.StatisticsEvent -> refreshStatistics()
else -> {}
}
}
}
}
@Deprecated("Deprecated in Java")
override fun onPrepareOptionsMenu(menu: Menu) {
super.onPrepareOptionsMenu(menu)
menu.findItem(R.id.statistics_reset).setVisible(true)
menu.findItem(R.id.statistics_filter).setVisible(true)
}
@Deprecated("Deprecated in Java")
override fun onOptionsItemSelected(item: MenuItem): Boolean {
if (item.itemId == R.id.statistics_filter) {
if (statisticsResult != null) {
val dialog = object: DatesFilterDialog(requireContext(), statisticsResult!!.oldestDate) {
override fun initParams() {
prefs = StatisticsFragment.prefs
includeMarkedAsPlayed = prefs!!.getBoolean(PREF_INCLUDE_MARKED_PLAYED, false)
timeFilterFrom = prefs!!.getLong(PREF_FILTER_FROM, 0)
timeFilterTo = prefs!!.getLong(PREF_FILTER_TO, Long.MAX_VALUE)
}
override fun callback(timeFilterFrom: Long, timeFilterTo: Long, includeMarkedAsPlayed: Boolean) {
prefs!!.edit()
.putBoolean(PREF_INCLUDE_MARKED_PLAYED, includeMarkedAsPlayed)
.putLong(PREF_FILTER_FROM, timeFilterFrom)
.putLong(PREF_FILTER_TO, timeFilterTo)
.apply()
EventFlow.postEvent(FlowEvent.StatisticsEvent())
}
}
dialog.show()
}
return true
}
return super.onOptionsItemSelected(item)
}
private fun refreshStatistics() {
progressBar.visibility = View.VISIBLE
feedStatisticsList.visibility = View.GONE
loadStatistics()
}
private fun loadStatistics() {
val includeMarkedAsPlayed = prefs!!.getBoolean(PREF_INCLUDE_MARKED_PLAYED, false)
val timeFilterFrom = prefs!!.getLong(PREF_FILTER_FROM, 0)
val timeFilterTo = prefs!!.getLong(PREF_FILTER_TO, Long.MAX_VALUE)
lifecycleScope.launch {
try {
val statisticsData = withContext(Dispatchers.IO) {
val data = getStatistics(includeMarkedAsPlayed, timeFilterFrom, timeFilterTo)
data.statsItems.sortWith { item1: StatisticsItem, item2: StatisticsItem ->
item2.timePlayed.compareTo(item1.timePlayed)
}
data
}
statisticsResult = statisticsData
// When "from" is "today", set it to today
listAdapter.setTimeFilter(includeMarkedAsPlayed,
max(min(timeFilterFrom.toDouble(), System.currentTimeMillis().toDouble()), statisticsData.oldestDate.toDouble()).toLong(),
min(timeFilterTo.toDouble(), System.currentTimeMillis().toDouble()).toLong())
listAdapter.update(statisticsData.statsItems)
progressBar.visibility = View.GONE
feedStatisticsList.visibility = View.VISIBLE
} catch (error: Throwable) {
Log.e(TAG, Log.getStackTraceString(error))
}
}
}
private class ListAdapter(private val fragment: Fragment) : StatisticsListAdapter(fragment.requireContext()) {
private var timeFilterFrom: Long = 0
private var timeFilterTo = Long.MAX_VALUE
private var includeMarkedAsPlayed = false
override val headerCaption: String
get() {
if (includeMarkedAsPlayed) return context.getString(R.string.statistics_counting_total)
val skeleton = DateFormat.getBestDateTimePattern(Locale.getDefault(), "MMM yyyy")
val dateFormat = SimpleDateFormat(skeleton, Locale.getDefault())
val dateFrom = dateFormat.format(Date(timeFilterFrom))
// FilterTo is first day of next month => Subtract one day
val dateTo = dateFormat.format(Date(timeFilterTo - 24L * 3600000L))
return context.getString(R.string.statistics_counting_range, dateFrom, dateTo)
}
override val headerValue: String
get() = shortLocalizedDuration(context, pieChartData!!.sum.toLong())
fun setTimeFilter(includeMarkedAsPlayed: Boolean, timeFilterFrom: Long, timeFilterTo: Long) {
this.includeMarkedAsPlayed = includeMarkedAsPlayed
this.timeFilterFrom = timeFilterFrom
this.timeFilterTo = timeFilterTo
}
override fun generateChartData(statisticsData: List<StatisticsItem>?): PieChartData {
val dataValues = FloatArray(statisticsData!!.size)
for (i in statisticsData.indices) {
val item = statisticsData[i]
dataValues[i] = item.timePlayed.toFloat()
}
return PieChartData(dataValues)
}
override fun onBindFeedViewHolder(holder: StatisticsHolder?, item: StatisticsItem?) {
val time = item!!.timePlayed
holder!!.value.text = shortLocalizedDuration(context, time)
holder.itemView.setOnClickListener {
val yourDialogFragment = StatisticsDialogFragment.newInstance(item.feed.id, item.feed.title)
yourDialogFragment.show(fragment.childFragmentManager.beginTransaction(), "DialogFragment")
}
}
}
}
class YearsStatisticsFragment : Fragment() {
private var _binding: StatisticsFragmentBinding? = null
private val binding get() = _binding!!
private lateinit var yearStatisticsList: RecyclerView
private lateinit var progressBar: ProgressBar
private lateinit var listAdapter: ListAdapter
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = StatisticsFragmentBinding.inflate(inflater)
yearStatisticsList = binding.statisticsList
progressBar = binding.progressBar
listAdapter = ListAdapter(requireContext())
yearStatisticsList.layoutManager = LinearLayoutManager(context)
yearStatisticsList.adapter = listAdapter
refreshStatistics()
return binding.root
}
override fun onStart() {
super.onStart()
procFlowEvents()
}
override fun onStop() {
super.onStop()
cancelFlowEvents()
}
override fun onDestroyView() {
Logd(TAG, "onDestroyView")
_binding = null
super.onDestroyView()
}
private var eventSink: Job? = null
private fun cancelFlowEvents() {
eventSink?.cancel()
eventSink = null
}
private fun procFlowEvents() {
if (eventSink != null) return
eventSink = lifecycleScope.launch {
EventFlow.events.collectLatest { event ->
Logd(TAG, "Received event: ${event.TAG}")
when (event) {
is FlowEvent.StatisticsEvent -> refreshStatistics()
else -> {}
}
}
}
}
@Deprecated("Deprecated in Java")
override fun onPrepareOptionsMenu(menu: Menu) {
super.onPrepareOptionsMenu(menu)
menu.findItem(R.id.statistics_reset).setVisible(true)
menu.findItem(R.id.statistics_filter).setVisible(false)
}
private fun refreshStatistics() {
progressBar.visibility = View.VISIBLE
yearStatisticsList.visibility = View.GONE
loadStatistics()
}
private fun loadStatistics() {
lifecycleScope.launch {
try {
val result: List<MonthlyStatisticsItem> = withContext(Dispatchers.IO) {
getMonthlyTimeStatistics()
}
listAdapter.update(result)
progressBar.visibility = View.GONE
yearStatisticsList.visibility = View.VISIBLE
} catch (error: Throwable) {
// This also runs on the Main thread
Log.e(TAG, Log.getStackTraceString(error))
}
}
}
private fun getMonthlyTimeStatistics(): List<MonthlyStatisticsItem> {
Logd(TAG, "getMonthlyTimeStatistics called")
val includeMarkedAsPlayed = prefs!!.getBoolean(PREF_INCLUDE_MARKED_PLAYED, false)
val months: MutableList<MonthlyStatisticsItem> = ArrayList()
val medias = realm.query(EpisodeMedia::class).query("lastPlayedTime > 0").find()
val groupdMedias = medias.groupBy {
val calendar = Calendar.getInstance()
calendar.timeInMillis = it.lastPlayedTime
"${calendar.get(Calendar.YEAR)}-${calendar.get(Calendar.MONTH) + 1}"
}
val orderedGroupedItems = groupdMedias.toList().sortedBy {
val (key, _) = it
val year = key.substringBefore("-").toInt()
val month = key.substringAfter("-").toInt()
year * 12 + month
}.toMap()
for (key in orderedGroupedItems.keys) {
val medias_ = orderedGroupedItems[key] ?: continue
val mItem = MonthlyStatisticsItem()
mItem.year = key.substringBefore("-").toInt()
mItem.month = key.substringAfter("-").toInt()
var dur = 0L
for (m in medias_) {
if (m.playedDuration > 0) dur += m.playedDuration
else {
// progress import does not include playedDuration
if (includeMarkedAsPlayed) {
if (m.playbackCompletionTime > 0 || (m.episodeOrFetch()?.playState?:-10) >= PlayState.SKIPPED.code)
dur += m.duration
else if (m.position > 0) dur += m.position
} else dur += m.position
}
}
mItem.timePlayed = dur
months.add(mItem)
}
return months
}
/**
* Adapter for the yearly playback statistics list.
*/
private class ListAdapter(val context: Context) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
private val statisticsData: MutableList<MonthlyStatisticsItem> = ArrayList()
private val yearlyAggregate: MutableList<MonthlyStatisticsItem?> = ArrayList()
override fun getItemCount(): Int {
return yearlyAggregate.size + 1
}
override fun getItemViewType(position: Int): Int {
return if (position == 0) TYPE_HEADER else TYPE_FEED
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
val inflater = LayoutInflater.from(context)
if (viewType == TYPE_HEADER) return HeaderHolder(inflater.inflate(R.layout.statistics_listitem_barchart, parent, false))
return StatisticsHolder(inflater.inflate(R.layout.statistics_year_listitem, parent, false))
}
@SuppressLint("SetTextI18n")
override fun onBindViewHolder(h: RecyclerView.ViewHolder, position: Int) {
if (getItemViewType(position) == TYPE_HEADER) {
val holder = h as HeaderHolder
holder.barChart.setData(statisticsData)
} else {
val holder = h as StatisticsHolder
val statsItem = yearlyAggregate[position - 1]
holder.year.text = String.format(Locale.getDefault(), "%d ", statsItem!!.year)
holder.hours.text = String.format(Locale.getDefault(),
"%.1f ",
statsItem.timePlayed / 3600000.0f) + context.getString(R.string.time_hours)
}
}
@SuppressLint("NotifyDataSetChanged")
fun update(statistics: List<MonthlyStatisticsItem>) {
var lastYear = if (statistics.isNotEmpty()) statistics[0].year else 0
var lastDataPoint = if (statistics.isNotEmpty()) (statistics[0].month - 1) + lastYear * 12 else 0
var yearSum: Long = 0
yearlyAggregate.clear()
statisticsData.clear()
for (statistic in statistics) {
if (statistic.year != lastYear) {
val yearAggregate = MonthlyStatisticsItem()
yearAggregate.year = lastYear
yearAggregate.timePlayed = yearSum
yearlyAggregate.add(yearAggregate)
yearSum = 0
lastYear = statistic.year
}
yearSum += statistic.timePlayed
while (lastDataPoint + 1 < (statistic.month - 1) + statistic.year * 12) {
lastDataPoint++
val item = MonthlyStatisticsItem()
item.year = lastDataPoint / 12
item.month = lastDataPoint % 12 + 1
statisticsData.add(item) // Compensate for months without playback
}
statisticsData.add(statistic)
lastDataPoint = (statistic.month - 1) + statistic.year * 12
}
val yearAggregate = MonthlyStatisticsItem()
yearAggregate.year = lastYear
yearAggregate.timePlayed = yearSum
yearlyAggregate.add(yearAggregate)
yearlyAggregate.reverse()
notifyDataSetChanged()
}
private class HeaderHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
val binding = StatisticsListitemBarchartBinding.bind(itemView)
var barChart: BarChartView = binding.barChart
}
private class StatisticsHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
val binding = StatisticsYearListitemBinding.bind(itemView)
var year: TextView = binding.yearLabel
var hours: TextView = binding.hoursLabel
}
companion object {
private const val TYPE_HEADER = 0
private const val TYPE_FEED = 1
}
}
}
class DownloadStatisticsFragment : Fragment() {
private var _binding: StatisticsFragmentBinding? = null
private val binding get() = _binding!!
private lateinit var downloadStatisticsList: RecyclerView
private lateinit var progressBar: ProgressBar
private lateinit var listAdapter: ListAdapter
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = StatisticsFragmentBinding.inflate(inflater)
downloadStatisticsList = binding.statisticsList
progressBar = binding.progressBar
listAdapter = ListAdapter(requireContext(), this)
downloadStatisticsList.layoutManager = LinearLayoutManager(context)
downloadStatisticsList.adapter = listAdapter
refreshDownloadStatistics()
return binding.root
}
override fun onDestroyView() {
Logd(TAG, "onDestroyView")
_binding = null
super.onDestroyView()
}
@Deprecated("Deprecated in Java")
override fun onPrepareOptionsMenu(menu: Menu) {
super.onPrepareOptionsMenu(menu)
menu.findItem(R.id.statistics_reset).setVisible(false)
menu.findItem(R.id.statistics_filter).setVisible(false)
}
private fun refreshDownloadStatistics() {
progressBar.visibility = View.VISIBLE
downloadStatisticsList.visibility = View.GONE
loadStatistics()
}
private fun loadStatistics() {
lifecycleScope.launch {
try {
val statisticsData = withContext(Dispatchers.IO) {
val data = getStatistics(false, 0, Long.MAX_VALUE)
data.statsItems.sortWith { item1: StatisticsItem, item2: StatisticsItem ->
item2.totalDownloadSize.compareTo(item1.totalDownloadSize)
}
data
}
listAdapter.update(statisticsData.statsItems)
progressBar.visibility = View.GONE
downloadStatisticsList.visibility = View.VISIBLE
} catch (error: Throwable) {
Log.e(TAG, Log.getStackTraceString(error))
}
}
}
private class ListAdapter(context: Context, private val fragment: Fragment) : StatisticsListAdapter(context) {
override val headerCaption: String
get() = context.getString(R.string.total_size_downloaded_podcasts)
override val headerValue: String
get() = Formatter.formatShortFileSize(context, pieChartData!!.sum.toLong())
override fun generateChartData(statisticsData: List<StatisticsItem>?): PieChartData {
val dataValues = FloatArray(statisticsData!!.size)
for (i in statisticsData.indices) {
val item = statisticsData[i]
dataValues[i] = item.totalDownloadSize.toFloat()
}
return PieChartData(dataValues)
}
@SuppressLint("SetTextI18n")
override fun onBindFeedViewHolder(holder: StatisticsHolder?, item: StatisticsItem?) {
holder!!.value.text = ("${Formatter.formatShortFileSize(context, item!!.totalDownloadSize)}"
+ String.format(Locale.getDefault(), "%d%s", item.episodesDownloadCount, context.getString(R.string.episodes_suffix)))
holder.itemView.setOnClickListener {
val yourDialogFragment = StatisticsDialogFragment.newInstance(item.feed.id, item.feed.title)
yourDialogFragment.show(fragment.childFragmentManager.beginTransaction(), "DialogFragment")
}
}
}
}
class StatisticsDialogFragment : DialogFragment() {
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val dialog = MaterialAlertDialogBuilder(requireContext())
dialog.setPositiveButton(android.R.string.ok, null)
dialog.setNeutralButton(R.string.open_podcast) { _: DialogInterface?, _: Int ->
val feedId = requireArguments().getLong(EXTRA_FEED_ID)
MainActivityStarter(requireContext()).withOpenFeed(feedId).withAddToBackStack().start()
}
dialog.setTitle(requireArguments().getString(EXTRA_FEED_TITLE))
dialog.setView(R.layout.feed_statistics_dialog)
return dialog.create()
}
override fun onStart() {
super.onStart()
val feedId = requireArguments().getLong(EXTRA_FEED_ID)
childFragmentManager.beginTransaction().replace(R.id.statisticsContainer,
FeedStatisticsFragment.newInstance(feedId, true), "feed_statistics_fragment")
.commitAllowingStateLoss()
}
companion object {
private const val EXTRA_FEED_ID = "ac.mdiq.podcini.extra.feedId"
private const val EXTRA_FEED_TITLE = "ac.mdiq.podcini.extra.feedTitle"
fun newInstance(feedId: Long, feedTitle: String?): StatisticsDialogFragment {
val fragment = StatisticsDialogFragment()
val arguments = Bundle()
arguments.putLong(EXTRA_FEED_ID, feedId)
arguments.putString(EXTRA_FEED_TITLE, feedTitle)
fragment.arguments = arguments
return fragment
}
}
}
/**
* Parent Adapter for the playback and download statistics list.
*/
private abstract class StatisticsListAdapter protected constructor(@JvmField protected val context: Context) :
RecyclerView.Adapter<RecyclerView.ViewHolder>() {
private var statisticsData: List<StatisticsItem>? = null
@JvmField
protected var pieChartData: PieChartData? = null
protected abstract val headerCaption: String?
protected abstract val headerValue: String?
override fun getItemCount(): Int {
return statisticsData!!.size + 1
}
override fun getItemViewType(position: Int): Int {
return if (position == 0) TYPE_HEADER else TYPE_FEED
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
val inflater = LayoutInflater.from(context)
if (viewType == TYPE_HEADER) return HeaderHolder(inflater.inflate(R.layout.statistics_listitem_total, parent, false))
return StatisticsHolder(inflater.inflate(R.layout.statistics_listitem, parent, false))
}
override fun onBindViewHolder(h: RecyclerView.ViewHolder, position: Int) {
if (getItemViewType(position) == TYPE_HEADER) {
val holder = h as HeaderHolder
holder.pieChart.setData(pieChartData)
holder.totalTime.text = headerValue
holder.totalText.text = headerCaption
} else {
val holder = h as StatisticsHolder
val statsItem = statisticsData!![position - 1]
holder.image.load(statsItem.feed.imageUrl) {
placeholder(R.color.light_gray)
error(R.mipmap.ic_launcher)
}
holder.title.text = statsItem.feed.title
holder.chip.setTextColor(pieChartData!!.getColorOfItem(position - 1))
onBindFeedViewHolder(holder, statsItem)
}
}
@SuppressLint("NotifyDataSetChanged")
fun update(statistics: List<StatisticsItem>?) {
statisticsData = statistics
pieChartData = generateChartData(statistics)
notifyDataSetChanged()
}
class HeaderHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
val binding = StatisticsListitemTotalBinding.bind(itemView)
var totalTime: TextView = binding.totalTime
var pieChart: PieChartView = binding.pieChart
var totalText: TextView = binding.totalDescription
}
class StatisticsHolder internal constructor(itemView: View) : RecyclerView.ViewHolder(itemView) {
val binding = StatisticsListitemBinding.bind(itemView)
var image: ImageView = binding.imgvCover
var title: TextView = binding.txtvTitle
@JvmField
var value: TextView = binding.txtvValue
var chip: TextView = binding.chip
}
protected abstract fun generateChartData(statisticsData: List<StatisticsItem>?): PieChartData?
protected abstract fun onBindFeedViewHolder(holder: StatisticsHolder?, item: StatisticsItem?)
companion object {
private const val TYPE_HEADER = 0
private const val TYPE_FEED = 1
}
}
companion object {
val TAG = StatisticsFragment::class.simpleName ?: "Anonymous"
const val PREF_NAME: String = "StatisticsActivityPrefs"
const val PREF_INCLUDE_MARKED_PLAYED: String = "countAll"
const val PREF_FILTER_FROM: String = "filterFrom"
const val PREF_FILTER_TO: String = "filterTo"
private const val POS_SUBSCRIPTIONS = 0
private const val POS_YEARS = 1
private const val POS_SPACE_TAKEN = 2
private const val TOTAL_COUNT = 3
var prefs: SharedPreferences? = null
fun getSharedPrefs(context: Context) {
if (prefs == null) prefs = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
}
}
}

View File

@ -19,14 +19,12 @@ object MiscFormatter {
@JvmStatic
fun formatAbbrev(context: Context?, date: Date?): String {
if (date == null) return ""
val now = GregorianCalendar()
val cal = GregorianCalendar()
cal.time = date
val withinLastYear = now[Calendar.YEAR] == cal[Calendar.YEAR]
var format = DateUtils.FORMAT_ABBREV_ALL
if (withinLastYear) format = format or DateUtils.FORMAT_NO_YEAR
return DateUtils.formatDateTime(context, date.time, format)
}

View File

@ -1,100 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<TableLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<TableRow
android:tag="detailed">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/statistics_episodes_started_total" />
<com.mikepenz.iconics.view.IconicsTextView
android:id="@+id/startedTotalLabel"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginLeft="8dp"
android:layout_marginStart="8dp"
android:text="@string/wait_icon"
tools:text="0 / 0" />
</TableRow>
<TableRow>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/statistics_time_played" />
<com.mikepenz.iconics.view.IconicsTextView
android:id="@+id/timePlayedLabel"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginLeft="8dp"
android:layout_marginStart="8dp"
android:text="@string/wait_icon"
tools:text="0 min" />
</TableRow>
<TableRow
android:tag="detailed">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/statistics_total_duration" />
<com.mikepenz.iconics.view.IconicsTextView
android:id="@+id/totalDurationLabel"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginLeft="8dp"
android:layout_marginStart="8dp"
android:text="@string/wait_icon"
tools:text="0 min" />
</TableRow>
<TableRow>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/statistics_episodes_on_device" />
<com.mikepenz.iconics.view.IconicsTextView
android:id="@+id/onDeviceLabel"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginLeft="8dp"
android:layout_marginStart="8dp"
android:text="@string/wait_icon"
tools:text="0" />
</TableRow>
<TableRow>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/statistics_space_used" />
<com.mikepenz.iconics.view.IconicsTextView
android:id="@+id/spaceUsedLabel"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginLeft="8dp"
android:layout_marginStart="8dp"
android:text="@string/wait_icon"
tools:text="0 MB" />
</TableRow>
</TableLayout>

View File

@ -1,7 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.fragment.app.FragmentContainerView
xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/statisticsContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="16dp" />

View File

@ -19,7 +19,6 @@
android:layout_height="?android:attr/actionBarSize"
app:navigationContentDescription="@string/toolbar_back_button_content_description"
app:navigationIcon="?homeAsUpIndicator" />
</com.google.android.material.appbar.AppBarLayout>
<com.google.android.material.tabs.TabLayout

View File

@ -1,25 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:id="@+id/statistics_fragment"
android:orientation="vertical">
<ProgressBar
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:id="@+id/progressBar"
android:layout_gravity="center"/>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/statistics_list"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:paddingBottom="@dimen/list_vertical_padding"
android:paddingTop="@dimen/list_vertical_padding"
android:scrollbarStyle="outsideOverlay"
tools:listitem="@layout/statistics_listitem"/>
</FrameLayout>

View File

@ -1,72 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:id="@+id/statistics_listitem"
android:paddingLeft="16dp"
android:paddingRight="16dp"
android:paddingTop="8dp"
android:paddingBottom="8dp"
android:background="?android:attr/selectableItemBackground">
<ImageView
android:id="@+id/imgvCover"
android:importantForAccessibility="no"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_alignParentLeft="true"
android:layout_alignParentStart="true"
android:layout_centerVertical="true"
android:adjustViewBounds="true"
android:cropToPadding="true"
android:scaleType="fitCenter"
tools:src="@tools:sample/avatars"
tools:background="@android:color/holo_green_dark"/>
<TextView
android:id="@+id/txtvTitle"
android:lines="1"
android:ellipsize="end"
android:singleLine="true"
android:textColor="?android:attr/textColorPrimary"
android:textSize="16sp"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginLeft="16dp"
android:layout_marginStart="16dp"
android:layout_toRightOf="@id/imgvCover"
android:layout_toEndOf="@id/imgvCover"
android:layout_alignTop="@id/imgvCover"
android:layout_alignWithParentIfMissing="true"
tools:text="Feed title"/>
<TextView
android:id="@+id/chip"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="13sp"
android:layout_toEndOf="@+id/imgvCover"
android:layout_toRightOf="@+id/imgvCover"
android:layout_marginLeft="16dp"
android:layout_marginStart="16dp"
android:layout_below="@+id/txtvTitle"
android:layout_marginEnd="4dp"
android:layout_marginRight="4dp"
android:text="⬤"
tools:ignore="HardcodedText"/>
<TextView
android:id="@+id/txtvValue"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:lines="1"
android:textColor="?android:attr/textColorTertiary"
android:textSize="14sp"
android:layout_toEndOf="@+id/chip"
android:layout_toRightOf="@+id/chip"
android:layout_below="@+id/txtvTitle"
tools:text="23 hours"/>
</RelativeLayout>

View File

@ -1,21 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:id="@+id/statistics_listitem_barchart"
android:orientation="vertical"
android:padding="16dp">
<ac.mdiq.podcini.ui.statistics.BarChartView
android:id="@+id/barChart"
android:layout_width="match_parent"
android:layout_height="200dp" />
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginTop="16dp"
android:background="?android:attr/dividerVertical" />
</LinearLayout>

View File

@ -1,49 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:id="@+id/statistics_listitem_total"
android:padding="16dp">
<ac.mdiq.podcini.ui.statistics.PieChartView
android:id="@+id/pie_chart"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerInParent="true"
android:layout_marginRight="8dp"
android:maxWidth="800dp"
android:minWidth="460dp"
android:layout_marginLeft="8dp" />
<TextView
android:id="@+id/total_time"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="?android:attr/textColorPrimary"
android:gravity="center_horizontal"
android:textSize="28sp"
android:layout_marginBottom="4dp"
android:layout_above="@id/total_description"
tools:text="10.0 hours" />
<TextView
android:id="@+id/total_description"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerHorizontal="true"
android:textAlignment="center"
android:maxLines="3"
android:textSize="14sp"
android:layout_marginBottom="16dp"
android:layout_alignBottom="@id/pie_chart" />
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginTop="16dp"
android:background="?android:attr/dividerVertical"
android:layout_below="@+id/pie_chart" />
</RelativeLayout>

View File

@ -1,33 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:id="@+id/statistics_year_listitem"
android:orientation="vertical"
android:paddingLeft="16dp"
android:paddingRight="16dp"
android:paddingTop="16dp"
android:paddingBottom="8dp"
android:background="?android:attr/selectableItemBackground">
<TextView
android:id="@+id/yearLabel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:lines="1"
android:textColor="?android:attr/textColorPrimary"
android:textSize="16sp"
tools:text="2020" />
<TextView
android:id="@+id/hoursLabel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:lines="1"
android:textColor="?android:attr/textColorTertiary"
android:textSize="14sp"
tools:text="23 hours" />
</LinearLayout>

View File

@ -17,13 +17,13 @@
<string name="settings_label">Settings</string>
<string name="downloads_label">Downloads</string>
<string name="logs_label">Logs</string>
<string name="subscriptions_label">Subscriptions</string>
<string name="cancel_download_label">Cancel download</string>
<string name="playback_history_label">History</string>
<string name="months_statistics_label">Months</string>
<string name="years_statistics_label">Years</string>
<string name="notification_pref_fragment">Notifications</string>
@ -386,6 +386,8 @@
<string name="last_played_date">Played date</string>
<string name="completed_date">Completed date</string>
<string name="duration">Duration</string>
<string name="spent">Spent</string>
<string name="hours">hours</string>
<string name="episode_title">Episode title</string>
<string name="feed_title">Podcast title</string>
<string name="show_criteria">Show criteria</string>
@ -833,12 +835,15 @@
<string name="audo_add_new_queue">Auto add new to queue</string>
<string name="audo_add_new_queue_summary">Automatically add new episodes in this podcast to the designated queue when (auto-)refreshing</string>
<string name="auto_download_disabled_globally">Auto download is disabled in the main Podcini settings</string>
<string name="statistics_length_played">Duration of all started:</string>
<string name="statistics_time_played">Time played:</string>
<string name="statistics_time_spent">Time spent:</string>
<string name="statistics_total_duration">Total duration (estimate):</string>
<string name="statistics_episodes_on_device">Episodes on the device:</string>
<string name="statistics_space_used">Space used:</string>
<string name="statistics_episodes_started_total">Episodes started/total:</string>
<string name="statistics_view_all">View for all podcasts »</string>
<string name="statistics_view_this">View this podcast»</string>
<string name="statistics_view_all">View for all podcasts»</string>
<string name="feeds_related_to_author">Feeds likely related to the author »</string>
<string name="wait_icon" translatable="false">{faw_spinner}</string>
<string name="edit_url_menu">Edit feed URL</string>

View File

@ -1,3 +1,18 @@
# 6.13.8
* Subscriptions sorting added the negative sides of downloaded and commented
* added border to Compose dialogs
* in EpisodeMedia added timeSpent to measure the actual time spent playing the episode
* upon migration, it's set the same as playedDuration, but it will get its own value when any episode is played
* redid and enhanced Statistics, it's in Compose
* pie chart is replaced with line chart
* in Subscriptions, in the header and every feed, added timeSpent
* in Subscriptions header, added usage of today
* on the popup of a feed statistics, also added "duration of all started"
* the "Years" tab is now "Months", showing played time for every month, and added timeSpent for every month
* enhanced efficiency of getting statistics for single feed
* in FeedInfo details, added "view statistics of this feed" together with "view statistics of all feeds"
# 6.13.7
* likely fixed getting duplicate episodes on updates (youtube feeds)

View File

@ -0,0 +1,14 @@
Version 6.13.8
* Subscriptions sorting added the negative sides of downloaded and commented
* added border to Compose dialogs
* in EpisodeMedia added timeSpent to measure the actual time spent playing the episode
* upon migration, it's set the same as playedDuration, but it will get its own value when any episode is played
* redid and enhanced Statistics, it's in Compose
* pie chart is replaced with line chart
* in Subscriptions, in the header and every feed, added timeSpent
* in Subscriptions header, added usage of today
* on the popup of a feed statistics, also added "duration of all started"
* the "Years" tab is now "Months", showing played time for every month, and added timeSpent for every month
* enhanced efficiency of getting statistics for single feed
* in FeedInfo details, added "view statistics of this feed" together with "view statistics of all feeds"

View File

@ -14,7 +14,7 @@ coordinatorlayout = "1.2.0"
coreKtx = "1.15.0"
coreKtxVersion = "1.8.1"
coreSplashscreen = "1.0.1"
desugar_jdk_libs_nio = "2.1.2"
desugar_jdk_libs_nio = "2.1.3"
documentfile = "1.0.1"
fyydlin = "v0.5.0"
googleMaterialTypeface = "4.0.0.3-kotlin"