diff --git a/app/build.gradle b/app/build.gradle index c5ae2284..a113a782 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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 = "" diff --git a/app/src/main/kotlin/ac/mdiq/podcini/playback/service/PlaybackService.kt b/app/src/main/kotlin/ac/mdiq/podcini/playback/service/PlaybackService.kt index 9af399a9..d34950c8 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/playback/service/PlaybackService.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/playback/service/PlaybackService.kt @@ -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 diff --git a/app/src/main/kotlin/ac/mdiq/podcini/preferences/fragments/AboutFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/preferences/fragments/AboutFragment.kt index e762153f..07d41d92 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/preferences/fragments/AboutFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/preferences/fragments/AboutFragment.kt @@ -188,7 +188,6 @@ class AboutFragment : PreferenceFragmentCompat() { else -> DevelopersFragment() } } - override fun getItemCount(): Int { return TOTAL_COUNT } diff --git a/app/src/main/kotlin/ac/mdiq/podcini/storage/database/LogsAndStats.kt b/app/src/main/kotlin/ac/mdiq/podcini/storage/database/LogsAndStats.kt index 0eed36b1..bcb9a988 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/storage/database/LogsAndStats.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/storage/database/LogsAndStats.kt @@ -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.. 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 } diff --git a/app/src/main/kotlin/ac/mdiq/podcini/storage/database/RealmDB.kt b/app/src/main/kotlin/ac/mdiq/podcini/storage/database/RealmDB.kt index 9a89cc5f..ee27de50 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/storage/database/RealmDB.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/storage/database/RealmDB.kt @@ -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(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) } diff --git a/app/src/main/kotlin/ac/mdiq/podcini/storage/model/Episode.kt b/app/src/main/kotlin/ac/mdiq/podcini/storage/model/Episode.kt index 49bc9344..faca8942 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/storage/model/Episode.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/storage/model/Episode.kt @@ -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 diff --git a/app/src/main/kotlin/ac/mdiq/podcini/storage/model/EpisodeMedia.kt b/app/src/main/kotlin/ac/mdiq/podcini/storage/model/EpisodeMedia.kt index af28eb17..67ebaaa6 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/storage/model/EpisodeMedia.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/storage/model/EpisodeMedia.kt @@ -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 } diff --git a/app/src/main/kotlin/ac/mdiq/podcini/storage/model/FeedFilter.kt b/app/src/main/kotlin/ac/mdiq/podcini/storage/model/FeedFilter.kt index 56f1d5a4..3646e28b 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/storage/model/FeedFilter.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/storage/model/FeedFilter.kt @@ -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() } diff --git a/app/src/main/kotlin/ac/mdiq/podcini/storage/model/StatisticsItem.kt b/app/src/main/kotlin/ac/mdiq/podcini/storage/model/StatisticsItem.kt index 454b76c7..5e75d096 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/storage/model/StatisticsItem.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/storage/model/StatisticsItem.kt @@ -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 { diff --git a/app/src/main/kotlin/ac/mdiq/podcini/storage/utils/DurationConverter.kt b/app/src/main/kotlin/ac/mdiq/podcini/storage/utils/DurationConverter.kt index 5d649dfa..74ba339c 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/storage/utils/DurationConverter.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/storage/utils/DurationConverter.kt @@ -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 "" } } diff --git a/app/src/main/kotlin/ac/mdiq/podcini/storage/utils/EpisodesPermutors.kt b/app/src/main/kotlin/ac/mdiq/podcini/storage/utils/EpisodesPermutors.kt index ce917551..13885523 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/storage/utils/EpisodesPermutors.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/storage/utils/EpisodesPermutors.kt @@ -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 the type of elements in the list */ interface Permutor { diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/EpisodeActionButton.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/EpisodeActionButton.kt index 7985ab87..3ed89b5e 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/EpisodeActionButton.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/EpisodeActionButton.kt @@ -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") diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/SwipeActions.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/SwipeActions.kt index ff71bd25..7b0da3d1 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/SwipeActions.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/SwipeActions.kt @@ -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)) diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/MainActivity.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/MainActivity.kt index cc821842..3cdeef89 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/MainActivity.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/MainActivity.kt @@ -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 diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/ChaptersDialog.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/ChaptersDialog.kt index 787f1c26..bb298613 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/ChaptersDialog.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/ChaptersDialog.kt @@ -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) } diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/Composables.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/Composables.kt index df33068b..45690e84 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/Composables.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/Composables.kt @@ -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, 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 } } } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/EpisodesVM.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/EpisodesVM.kt index c917f62b..925531e3 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/EpisodesVM.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/EpisodesVM.kt @@ -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, 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, onDismissRequest: () -> Unit) { fun PlayStateDialog(selected: List, 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, onDismissRequest: () -> Unit) { fun PutToQueueDialog(selected: List, 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, onDismissRequest: () -> Unit) { fun ShelveDialog(selected: List, 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, 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, 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()) { diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/Feeds.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/Feeds.kt index 978733b2..a33cec9b 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/Feeds.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/Feeds.kt @@ -50,7 +50,7 @@ import java.util.* @Composable fun ChooseRatingDialog(selected: List, 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, 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)) diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/AudioPlayerFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/AudioPlayerFragment.kt index e43b647a..f9642b09 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/AudioPlayerFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/AudioPlayerFragment.kt @@ -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 -> diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/FeedInfoFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/FeedInfoFragment.kt index 05105775..580b419e 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/FeedInfoFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/FeedInfoFragment.kt @@ -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)) } } } } diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/FeedSettingsFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/FeedSettingsFragment.kt index 043ea5d0..dcf4bffa 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/FeedSettingsFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/FeedSettingsFragment.kt @@ -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 }, diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/LogsFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/LogsFragment.kt index 09229034..8c3d2caf 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/LogsFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/LogsFragment.kt @@ -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)) diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/NavDrawerFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/NavDrawerFragment.kt index bf2abb40..a15c8c01 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/NavDrawerFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/NavDrawerFragment.kt @@ -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 diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/QueuesFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/QueuesFragment.kt index 450d01c1..8d8359a2 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/QueuesFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/QueuesFragment.kt @@ -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)") }) diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/StatisticsFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/StatisticsFragment.kt new file mode 100644 index 00000000..79a384db --- /dev/null +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/StatisticsFragment.kt @@ -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 + 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 { + Logd(TAG, "getMonthlyTimeStatistics called") + val includeMarkedAsPlayed = prefs!!.getBoolean(PREF_INCLUDE_MARKED_PLAYED, false) + + val months: MutableList = 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) { + 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)) } + } + } + } + } + } + } +} diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/SubscriptionsFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/SubscriptionsFragment.kt index 82330705..207ac8a7 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/SubscriptionsFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/SubscriptionsFragment.kt @@ -127,8 +127,8 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener { private val playStateCodeSet = mutableSetOf() private val ratingSort = MutableList(Rating.entries.size) { mutableStateOf(false)} private val ratingCodeSet = mutableSetOf() - 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() 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, 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), diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/statistics/BarChartView.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/statistics/BarChartView.kt deleted file mode 100644 index d368c60d..00000000 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/statistics/BarChartView.kt +++ /dev/null @@ -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) { - 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? = 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?) { - } - } -} diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/statistics/FeedStatisticsFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/statistics/FeedStatisticsFragment.kt deleted file mode 100644 index 36539918..00000000 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/statistics/FeedStatisticsFragment.kt +++ /dev/null @@ -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 - } - } -} diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/statistics/PieChartView.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/statistics/PieChartView.kt deleted file mode 100644 index 259bf368..00000000 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/statistics/PieChartView.kt +++ /dev/null @@ -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 - } - } -} diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/statistics/StatisticsFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/statistics/StatisticsFragment.kt deleted file mode 100644 index 6f3b4f50..00000000 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/statistics/StatisticsFragment.kt +++ /dev/null @@ -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?): 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 = 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 { - Logd(TAG, "getMonthlyTimeStatistics called") - val includeMarkedAsPlayed = prefs!!.getBoolean(PREF_INCLUDE_MARKED_PLAYED, false) - - val months: MutableList = 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() { - private val statisticsData: MutableList = ArrayList() - private val yearlyAggregate: MutableList = 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) { - 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?): 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() { - private var statisticsData: List? = 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?) { - 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?): 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) - } - } -} diff --git a/app/src/main/kotlin/ac/mdiq/podcini/util/MiscFormatter.kt b/app/src/main/kotlin/ac/mdiq/podcini/util/MiscFormatter.kt index c4b0709b..df348290 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/util/MiscFormatter.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/util/MiscFormatter.kt @@ -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) } diff --git a/app/src/main/res/layout/feed_statistics.xml b/app/src/main/res/layout/feed_statistics.xml deleted file mode 100644 index 69446125..00000000 --- a/app/src/main/res/layout/feed_statistics.xml +++ /dev/null @@ -1,100 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/feed_statistics_dialog.xml b/app/src/main/res/layout/feed_statistics_dialog.xml deleted file mode 100644 index fcd36fe7..00000000 --- a/app/src/main/res/layout/feed_statistics_dialog.xml +++ /dev/null @@ -1,7 +0,0 @@ - - diff --git a/app/src/main/res/layout/pager_fragment.xml b/app/src/main/res/layout/pager_fragment.xml index f8a0b83e..ef3fab63 100644 --- a/app/src/main/res/layout/pager_fragment.xml +++ b/app/src/main/res/layout/pager_fragment.xml @@ -19,7 +19,6 @@ android:layout_height="?android:attr/actionBarSize" app:navigationContentDescription="@string/toolbar_back_button_content_description" app:navigationIcon="?homeAsUpIndicator" /> - - - - - - - - diff --git a/app/src/main/res/layout/statistics_listitem.xml b/app/src/main/res/layout/statistics_listitem.xml deleted file mode 100644 index 601618a4..00000000 --- a/app/src/main/res/layout/statistics_listitem.xml +++ /dev/null @@ -1,72 +0,0 @@ - - - - - - - - - - - - diff --git a/app/src/main/res/layout/statistics_listitem_barchart.xml b/app/src/main/res/layout/statistics_listitem_barchart.xml deleted file mode 100644 index be2b0e5c..00000000 --- a/app/src/main/res/layout/statistics_listitem_barchart.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - - - diff --git a/app/src/main/res/layout/statistics_listitem_total.xml b/app/src/main/res/layout/statistics_listitem_total.xml deleted file mode 100644 index 264c8f8a..00000000 --- a/app/src/main/res/layout/statistics_listitem_total.xml +++ /dev/null @@ -1,49 +0,0 @@ - - - - - - - - - - - - diff --git a/app/src/main/res/layout/statistics_year_listitem.xml b/app/src/main/res/layout/statistics_year_listitem.xml deleted file mode 100644 index 9c8b8723..00000000 --- a/app/src/main/res/layout/statistics_year_listitem.xml +++ /dev/null @@ -1,33 +0,0 @@ - - - - - - - - diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0848da6a..7f08152f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -17,13 +17,13 @@ Settings Downloads Logs - - + Subscriptions Cancel download History + Months Years Notifications @@ -386,6 +386,8 @@ Played date Completed date Duration + Spent + hours Episode title Podcast title Show criteria @@ -833,12 +835,15 @@ Auto add new to queue Automatically add new episodes in this podcast to the designated queue when (auto-)refreshing Auto download is disabled in the main Podcini settings + Duration of all started: Time played: + Time spent: Total duration (estimate): Episodes on the device: Space used: Episodes started/total: - View for all podcasts » + View this podcast» + View for all podcasts» Feeds likely related to the author » {faw_spinner} Edit feed URL diff --git a/changelog.md b/changelog.md index de344f27..e06f7538 100644 --- a/changelog.md +++ b/changelog.md @@ -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) diff --git a/fastlane/metadata/android/en-US/changelogs/3020295.txt b/fastlane/metadata/android/en-US/changelogs/3020295.txt new file mode 100644 index 00000000..012f9fa3 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/3020295.txt @@ -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" diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9b1bab9e..6e486e10 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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"