summaryrefslogtreecommitdiff
path: root/ui/statistics
diff options
context:
space:
mode:
authorByteHamster <info@bytehamster.com>2022-02-23 00:22:51 +0100
committerByteHamster <info@bytehamster.com>2022-02-26 19:44:17 +0100
commit7451da112145f96ecddc314eea7b90fcb03737dd (patch)
tree437ad8df931d422eb0c5deebf19d6a9e93c0310c /ui/statistics
parent0d7555da8c291457cd8fe7b97036fd05c515bbd2 (diff)
downloadAntennaPod-7451da112145f96ecddc314eea7b90fcb03737dd.zip
Move statistics screens to new module
Diffstat (limited to 'ui/statistics')
-rw-r--r--ui/statistics/README.md3
-rw-r--r--ui/statistics/build.gradle31
-rw-r--r--ui/statistics/src/main/AndroidManifest.xml1
-rw-r--r--ui/statistics/src/main/java/de/danoeh/antennapod/ui/statistics/PieChartView.java149
-rw-r--r--ui/statistics/src/main/java/de/danoeh/antennapod/ui/statistics/StatisticsFragment.java96
-rw-r--r--ui/statistics/src/main/java/de/danoeh/antennapod/ui/statistics/StatisticsListAdapter.java121
-rw-r--r--ui/statistics/src/main/java/de/danoeh/antennapod/ui/statistics/downloads/DownloadStatisticsFragment.java90
-rw-r--r--ui/statistics/src/main/java/de/danoeh/antennapod/ui/statistics/downloads/DownloadStatisticsListAdapter.java50
-rw-r--r--ui/statistics/src/main/java/de/danoeh/antennapod/ui/statistics/feed/FeedStatisticsDialogFragment.java42
-rw-r--r--ui/statistics/src/main/java/de/danoeh/antennapod/ui/statistics/feed/FeedStatisticsFragment.java95
-rw-r--r--ui/statistics/src/main/java/de/danoeh/antennapod/ui/statistics/subscriptions/PlaybackStatisticsListAdapter.java74
-rw-r--r--ui/statistics/src/main/java/de/danoeh/antennapod/ui/statistics/subscriptions/SubscriptionStatisticsFragment.java278
-rw-r--r--ui/statistics/src/main/java/de/danoeh/antennapod/ui/statistics/years/LineChartView.java138
-rw-r--r--ui/statistics/src/main/java/de/danoeh/antennapod/ui/statistics/years/YearStatisticsListAdapter.java120
-rw-r--r--ui/statistics/src/main/java/de/danoeh/antennapod/ui/statistics/years/YearsStatisticsFragment.java86
-rw-r--r--ui/statistics/src/main/res/layout/feed_statistics.xml95
-rw-r--r--ui/statistics/src/main/res/layout/feed_statistics_dialog.xml7
-rw-r--r--ui/statistics/src/main/res/layout/statistics_filter_dialog.xml95
-rw-r--r--ui/statistics/src/main/res/layout/statistics_fragment.xml24
-rw-r--r--ui/statistics/src/main/res/layout/statistics_listitem.xml71
-rw-r--r--ui/statistics/src/main/res/layout/statistics_listitem_linechart.xml22
-rw-r--r--ui/statistics/src/main/res/layout/statistics_listitem_total.xml48
-rw-r--r--ui/statistics/src/main/res/layout/statistics_year_listitem.xml32
-rw-r--r--ui/statistics/src/main/res/menu/statistics.xml17
24 files changed, 1785 insertions, 0 deletions
diff --git a/ui/statistics/README.md b/ui/statistics/README.md
new file mode 100644
index 000000000..91fb4e2af
--- /dev/null
+++ b/ui/statistics/README.md
@@ -0,0 +1,3 @@
+# :ui:statistics
+
+This module provides the statistics screens.
diff --git a/ui/statistics/build.gradle b/ui/statistics/build.gradle
new file mode 100644
index 000000000..49ec1df8b
--- /dev/null
+++ b/ui/statistics/build.gradle
@@ -0,0 +1,31 @@
+plugins {
+ id("com.android.library")
+}
+apply from: "../../common.gradle"
+apply from: "../../playFlavor.gradle"
+
+android {
+ lintOptions {
+ disable "InvalidPeriodicWorkRequestInterval", "MissingPermission", "GradleCompatible",
+ "QueryPermissionsNeeded", "Overdraw", "SetTextI18n", "RtlHardcoded"
+ }
+}
+
+dependencies {
+ implementation project(":core")
+ implementation project(":model")
+ implementation project(":ui:common")
+
+ annotationProcessor "androidx.annotation:annotation:$annotationVersion"
+ implementation "androidx.appcompat:appcompat:$appcompatVersion"
+ implementation "androidx.core:core:$coreVersion"
+ implementation "androidx.fragment:fragment:$fragmentVersion"
+ implementation "androidx.recyclerview:recyclerview:$recyclerViewVersion"
+ implementation "androidx.viewpager2:viewpager2:$viewPager2Version"
+ implementation "com.google.android.material:material:$googleMaterialVersion"
+
+ implementation "com.github.bumptech.glide:glide:$glideVersion"
+ annotationProcessor "com.github.bumptech.glide:compiler:$glideVersion"
+ implementation "io.reactivex.rxjava2:rxandroid:$rxAndroidVersion"
+ implementation "io.reactivex.rxjava2:rxjava:$rxJavaVersion"
+}
diff --git a/ui/statistics/src/main/AndroidManifest.xml b/ui/statistics/src/main/AndroidManifest.xml
new file mode 100644
index 000000000..59c5bd42c
--- /dev/null
+++ b/ui/statistics/src/main/AndroidManifest.xml
@@ -0,0 +1 @@
+<manifest package="de.danoeh.antennapod.ui.statistics" />
diff --git a/ui/statistics/src/main/java/de/danoeh/antennapod/ui/statistics/PieChartView.java b/ui/statistics/src/main/java/de/danoeh/antennapod/ui/statistics/PieChartView.java
new file mode 100644
index 000000000..f3e6f0259
--- /dev/null
+++ b/ui/statistics/src/main/java/de/danoeh/antennapod/ui/statistics/PieChartView.java
@@ -0,0 +1,149 @@
+package de.danoeh.antennapod.ui.statistics;
+
+import android.annotation.SuppressLint;
+import android.content.Context;
+import android.graphics.Canvas;
+import android.graphics.Color;
+import android.graphics.ColorFilter;
+import android.graphics.Paint;
+import android.graphics.PixelFormat;
+import android.graphics.RectF;
+import android.graphics.drawable.Drawable;
+import android.util.AttributeSet;
+import androidx.annotation.NonNull;
+import androidx.appcompat.widget.AppCompatImageView;
+import io.reactivex.annotations.Nullable;
+
+public class PieChartView extends AppCompatImageView {
+ private PieChartDrawable drawable;
+
+ public PieChartView(Context context) {
+ super(context);
+ setup();
+ }
+
+ public PieChartView(Context context, @Nullable AttributeSet attrs) {
+ super(context, attrs);
+ setup();
+ }
+
+ public PieChartView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ setup();
+ }
+
+ @SuppressLint("ClickableViewAccessibility")
+ private void setup() {
+ drawable = new PieChartDrawable();
+ setImageDrawable(drawable);
+ }
+
+ /**
+ * Set of data values to display.
+ */
+ public void setData(PieChartData data) {
+ drawable.data = data;
+ }
+
+ @Override
+ protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
+ super.onMeasure(widthMeasureSpec, heightMeasureSpec);
+ int width = getMeasuredWidth();
+ setMeasuredDimension(width, width / 2);
+ }
+
+ public static class PieChartData {
+ private static final int[] COLOR_VALUES = new int[]{0xFF3775E6, 0xffe51c23, 0xffff9800, 0xff259b24, 0xff9c27b0,
+ 0xff0099c6, 0xffdd4477, 0xff66aa00, 0xffb82e2e, 0xff316395,
+ 0xff994499, 0xff22aa99, 0xffaaaa11, 0xff6633cc, 0xff0073e6};
+
+ private final float valueSum;
+ private final float[] values;
+
+ public PieChartData(float[] values) {
+ this.values = values;
+ float valueSum = 0;
+ for (float datum : values) {
+ valueSum += datum;
+ }
+ this.valueSum = valueSum;
+ }
+
+ public float getSum() {
+ return valueSum;
+ }
+
+ public float getPercentageOfItem(int index) {
+ if (valueSum == 0) {
+ return 0;
+ }
+ return values[index] / valueSum;
+ }
+
+ public boolean isLargeEnoughToDisplay(int index) {
+ return getPercentageOfItem(index) > 0.04;
+ }
+
+ public int getColorOfItem(int index) {
+ if (!isLargeEnoughToDisplay(index)) {
+ return Color.GRAY;
+ }
+ return COLOR_VALUES[index % COLOR_VALUES.length];
+ }
+ }
+
+ private static class PieChartDrawable extends Drawable {
+ private static final float PADDING_DEGREES = 3f;
+ private PieChartData data;
+ private final Paint paint;
+
+ private PieChartDrawable() {
+ paint = new Paint();
+ paint.setFlags(Paint.ANTI_ALIAS_FLAG);
+ paint.setStyle(Paint.Style.STROKE);
+ paint.setStrokeJoin(Paint.Join.ROUND);
+ paint.setStrokeCap(Paint.Cap.ROUND);
+ }
+
+ @Override
+ public void draw(@NonNull Canvas canvas) {
+ final float strokeSize = getBounds().height() / 30f;
+ paint.setStrokeWidth(strokeSize);
+
+ float radius = getBounds().height() - strokeSize;
+ float center = getBounds().width() / 2.f;
+ RectF arcBounds = new RectF(center - radius, strokeSize, center + radius, strokeSize + radius * 2);
+
+ float startAngle = 180;
+ for (int i = 0; i < data.values.length; i++) {
+ if (!data.isLargeEnoughToDisplay(i)) {
+ break;
+ }
+ paint.setColor(data.getColorOfItem(i));
+ float padding = i == 0 ? PADDING_DEGREES / 2 : PADDING_DEGREES;
+ float sweepAngle = (180f - PADDING_DEGREES) * data.getPercentageOfItem(i);
+ canvas.drawArc(arcBounds, startAngle + padding, sweepAngle - padding, false, paint);
+ startAngle = startAngle + sweepAngle;
+ }
+
+ paint.setColor(Color.GRAY);
+ float sweepAngle = 360 - startAngle - PADDING_DEGREES / 2;
+ if (sweepAngle > PADDING_DEGREES) {
+ canvas.drawArc(arcBounds, startAngle + PADDING_DEGREES, sweepAngle - PADDING_DEGREES, false, paint);
+ }
+ }
+
+ @Override
+ public int getOpacity() {
+ return PixelFormat.TRANSLUCENT;
+ }
+
+ @Override
+ public void setAlpha(int alpha) {
+ }
+
+ @Override
+ public void setColorFilter(ColorFilter cf) {
+ }
+ }
+}
diff --git a/ui/statistics/src/main/java/de/danoeh/antennapod/ui/statistics/StatisticsFragment.java b/ui/statistics/src/main/java/de/danoeh/antennapod/ui/statistics/StatisticsFragment.java
new file mode 100644
index 000000000..fc0f590cd
--- /dev/null
+++ b/ui/statistics/src/main/java/de/danoeh/antennapod/ui/statistics/StatisticsFragment.java
@@ -0,0 +1,96 @@
+package de.danoeh.antennapod.ui.statistics;
+
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+
+import androidx.annotation.NonNull;
+import androidx.appcompat.widget.Toolbar;
+import androidx.fragment.app.Fragment;
+import androidx.viewpager2.adapter.FragmentStateAdapter;
+import androidx.viewpager2.widget.ViewPager2;
+
+import com.google.android.material.tabs.TabLayout;
+import com.google.android.material.tabs.TabLayoutMediator;
+import de.danoeh.antennapod.ui.common.PagedToolbarFragment;
+import de.danoeh.antennapod.ui.statistics.downloads.DownloadStatisticsFragment;
+import de.danoeh.antennapod.ui.statistics.subscriptions.SubscriptionStatisticsFragment;
+import de.danoeh.antennapod.ui.statistics.years.YearsStatisticsFragment;
+
+/**
+ * Displays the 'statistics' screen
+ */
+public class StatisticsFragment extends PagedToolbarFragment {
+
+ public static final String TAG = "StatisticsFragment";
+
+ private static final int POS_SUBSCRIPTIONS = 0;
+ private static final int POS_YEARS = 1;
+ private static final int POS_SPACE_TAKEN = 2;
+ private static final int TOTAL_COUNT = 3;
+
+ private TabLayout tabLayout;
+ private ViewPager2 viewPager;
+ private Toolbar toolbar;
+
+ @Override
+ public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container,
+ Bundle savedInstanceState) {
+ super.onCreateView(inflater, container, savedInstanceState);
+ setHasOptionsMenu(true);
+
+ View rootView = inflater.inflate(R.layout.pager_fragment, container, false);
+ viewPager = rootView.findViewById(R.id.viewpager);
+ toolbar = rootView.findViewById(R.id.toolbar);
+ toolbar.setTitle(getString(R.string.statistics_label));
+ toolbar.inflateMenu(R.menu.statistics);
+ toolbar.setNavigationOnClickListener(v -> getParentFragmentManager().popBackStack());
+ viewPager.setAdapter(new StatisticsPagerAdapter(this));
+ // Give the TabLayout the ViewPager
+ tabLayout = rootView.findViewById(R.id.sliding_tabs);
+ super.setupPagedToolbar(toolbar, viewPager);
+ new TabLayoutMediator(tabLayout, viewPager, (tab, position) -> {
+ switch (position) {
+ case POS_SUBSCRIPTIONS:
+ tab.setText(R.string.subscriptions_label);
+ break;
+ case POS_YEARS:
+ tab.setText(R.string.years_statistics_label);
+ break;
+ case POS_SPACE_TAKEN:
+ tab.setText(R.string.downloads_label);
+ break;
+ default:
+ break;
+ }
+ }).attach();
+ return rootView;
+ }
+
+ public static class StatisticsPagerAdapter extends FragmentStateAdapter {
+
+ StatisticsPagerAdapter(@NonNull Fragment fragment) {
+ super(fragment);
+ }
+
+ @NonNull
+ @Override
+ public Fragment createFragment(int position) {
+ switch (position) {
+ case POS_SUBSCRIPTIONS:
+ return new SubscriptionStatisticsFragment();
+ case POS_YEARS:
+ return new YearsStatisticsFragment();
+ default:
+ case POS_SPACE_TAKEN:
+ return new DownloadStatisticsFragment();
+ }
+ }
+
+ @Override
+ public int getItemCount() {
+ return TOTAL_COUNT;
+ }
+ }
+}
diff --git a/ui/statistics/src/main/java/de/danoeh/antennapod/ui/statistics/StatisticsListAdapter.java b/ui/statistics/src/main/java/de/danoeh/antennapod/ui/statistics/StatisticsListAdapter.java
new file mode 100644
index 000000000..d961659d7
--- /dev/null
+++ b/ui/statistics/src/main/java/de/danoeh/antennapod/ui/statistics/StatisticsListAdapter.java
@@ -0,0 +1,121 @@
+package de.danoeh.antennapod.ui.statistics;
+
+import android.content.Context;
+import androidx.annotation.NonNull;
+import androidx.recyclerview.widget.RecyclerView;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ImageView;
+import android.widget.TextView;
+
+import com.bumptech.glide.Glide;
+import com.bumptech.glide.request.RequestOptions;
+
+import de.danoeh.antennapod.core.glide.ApGlideSettings;
+import de.danoeh.antennapod.core.storage.StatisticsItem;
+
+import java.util.List;
+
+/**
+ * Parent Adapter for the playback and download statistics list.
+ */
+public abstract class StatisticsListAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder> {
+ private static final int TYPE_HEADER = 0;
+ private static final int TYPE_FEED = 1;
+ protected final Context context;
+ private List<StatisticsItem> statisticsData;
+ protected PieChartView.PieChartData pieChartData;
+
+ protected StatisticsListAdapter(Context context) {
+ this.context = context;
+ }
+
+ @Override
+ public int getItemCount() {
+ return statisticsData.size() + 1;
+ }
+
+ @Override
+ public int getItemViewType(int position) {
+ return position == 0 ? TYPE_HEADER : TYPE_FEED;
+ }
+
+ @NonNull
+ @Override
+ public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
+ LayoutInflater inflater = LayoutInflater.from(context);
+ if (viewType == TYPE_HEADER) {
+ return new HeaderHolder(inflater.inflate(R.layout.statistics_listitem_total, parent, false));
+ }
+ return new StatisticsHolder(inflater.inflate(R.layout.statistics_listitem, parent, false));
+ }
+
+ @Override
+ public void onBindViewHolder(@NonNull RecyclerView.ViewHolder h, int position) {
+ if (getItemViewType(position) == TYPE_HEADER) {
+ HeaderHolder holder = (HeaderHolder) h;
+ holder.pieChart.setData(pieChartData);
+ holder.totalTime.setText(getHeaderValue());
+ holder.totalText.setText(getHeaderCaption());
+ } else {
+ StatisticsHolder holder = (StatisticsHolder) h;
+ StatisticsItem statsItem = statisticsData.get(position - 1);
+ Glide.with(context)
+ .load(statsItem.feed.getImageUrl())
+ .apply(new RequestOptions()
+ .placeholder(R.color.light_gray)
+ .error(R.color.light_gray)
+ .diskCacheStrategy(ApGlideSettings.AP_DISK_CACHE_STRATEGY)
+ .fitCenter()
+ .dontAnimate())
+ .into(holder.image);
+
+ holder.title.setText(statsItem.feed.getTitle());
+ holder.chip.setTextColor(pieChartData.getColorOfItem(position - 1));
+ onBindFeedViewHolder(holder, statsItem);
+ }
+ }
+
+ public void update(List<StatisticsItem> statistics) {
+ statisticsData = statistics;
+ pieChartData = generateChartData(statistics);
+ notifyDataSetChanged();
+ }
+
+ static class HeaderHolder extends RecyclerView.ViewHolder {
+ TextView totalTime;
+ PieChartView pieChart;
+ TextView totalText;
+
+ HeaderHolder(View itemView) {
+ super(itemView);
+ totalTime = itemView.findViewById(R.id.total_time);
+ pieChart = itemView.findViewById(R.id.pie_chart);
+ totalText = itemView.findViewById(R.id.total_description);
+ }
+ }
+
+ public static class StatisticsHolder extends RecyclerView.ViewHolder {
+ public ImageView image;
+ public TextView title;
+ public TextView value;
+ public TextView chip;
+
+ StatisticsHolder(View itemView) {
+ super(itemView);
+ image = itemView.findViewById(R.id.imgvCover);
+ title = itemView.findViewById(R.id.txtvTitle);
+ value = itemView.findViewById(R.id.txtvValue);
+ chip = itemView.findViewById(R.id.chip);
+ }
+ }
+
+ protected abstract String getHeaderCaption();
+
+ protected abstract String getHeaderValue();
+
+ protected abstract PieChartView.PieChartData generateChartData(List<StatisticsItem> statisticsData);
+
+ protected abstract void onBindFeedViewHolder(StatisticsHolder holder, StatisticsItem item);
+}
diff --git a/ui/statistics/src/main/java/de/danoeh/antennapod/ui/statistics/downloads/DownloadStatisticsFragment.java b/ui/statistics/src/main/java/de/danoeh/antennapod/ui/statistics/downloads/DownloadStatisticsFragment.java
new file mode 100644
index 000000000..295094beb
--- /dev/null
+++ b/ui/statistics/src/main/java/de/danoeh/antennapod/ui/statistics/downloads/DownloadStatisticsFragment.java
@@ -0,0 +1,90 @@
+package de.danoeh.antennapod.ui.statistics.downloads;
+
+import android.os.Bundle;
+import android.util.Log;
+import android.view.LayoutInflater;
+import android.view.Menu;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ProgressBar;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.fragment.app.Fragment;
+import androidx.recyclerview.widget.LinearLayoutManager;
+import androidx.recyclerview.widget.RecyclerView;
+
+import de.danoeh.antennapod.core.storage.DBReader;
+import de.danoeh.antennapod.ui.statistics.R;
+import io.reactivex.Observable;
+import io.reactivex.android.schedulers.AndroidSchedulers;
+import io.reactivex.disposables.Disposable;
+import io.reactivex.schedulers.Schedulers;
+
+import java.util.Collections;
+
+/**
+ * Displays the 'download statistics' screen
+ */
+public class DownloadStatisticsFragment extends Fragment {
+ private static final String TAG = DownloadStatisticsFragment.class.getSimpleName();
+
+ private Disposable disposable;
+ private RecyclerView downloadStatisticsList;
+ private ProgressBar progressBar;
+ private DownloadStatisticsListAdapter listAdapter;
+
+ @Nullable
+ @Override
+ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container,
+ @Nullable Bundle savedInstanceState) {
+ View root = inflater.inflate(R.layout.statistics_fragment, container, false);
+ downloadStatisticsList = root.findViewById(R.id.statistics_list);
+ progressBar = root.findViewById(R.id.progressBar);
+ listAdapter = new DownloadStatisticsListAdapter(getContext());
+ downloadStatisticsList.setLayoutManager(new LinearLayoutManager(getContext()));
+ downloadStatisticsList.setAdapter(listAdapter);
+ return root;
+ }
+
+ @Override
+ public void onStart() {
+ super.onStart();
+ refreshDownloadStatistics();
+ }
+
+ @Override
+ public void onPrepareOptionsMenu(@NonNull Menu menu) {
+ super.onPrepareOptionsMenu(menu);
+ menu.findItem(R.id.statistics_reset).setVisible(false);
+ menu.findItem(R.id.statistics_filter).setVisible(false);
+ }
+
+ private void refreshDownloadStatistics() {
+ progressBar.setVisibility(View.VISIBLE);
+ downloadStatisticsList.setVisibility(View.GONE);
+ loadStatistics();
+ }
+
+ private void loadStatistics() {
+ if (disposable != null) {
+ disposable.dispose();
+ }
+
+ disposable =
+ Observable.fromCallable(() -> {
+ // Filters do not matter here
+ DBReader.StatisticsResult statisticsData = DBReader.getStatistics(false, 0, Long.MAX_VALUE);
+ Collections.sort(statisticsData.feedTime, (item1, item2) ->
+ Long.compare(item2.totalDownloadSize, item1.totalDownloadSize));
+ return statisticsData;
+ })
+ .subscribeOn(Schedulers.io())
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribe(result -> {
+ listAdapter.update(result.feedTime);
+ progressBar.setVisibility(View.GONE);
+ downloadStatisticsList.setVisibility(View.VISIBLE);
+ }, error -> Log.e(TAG, Log.getStackTraceString(error)));
+ }
+}
diff --git a/ui/statistics/src/main/java/de/danoeh/antennapod/ui/statistics/downloads/DownloadStatisticsListAdapter.java b/ui/statistics/src/main/java/de/danoeh/antennapod/ui/statistics/downloads/DownloadStatisticsListAdapter.java
new file mode 100644
index 000000000..edd3e322f
--- /dev/null
+++ b/ui/statistics/src/main/java/de/danoeh/antennapod/ui/statistics/downloads/DownloadStatisticsListAdapter.java
@@ -0,0 +1,50 @@
+package de.danoeh.antennapod.ui.statistics.downloads;
+
+import android.content.Context;
+import android.text.format.Formatter;
+import de.danoeh.antennapod.core.storage.StatisticsItem;
+import de.danoeh.antennapod.ui.statistics.PieChartView;
+import de.danoeh.antennapod.ui.statistics.R;
+import de.danoeh.antennapod.ui.statistics.StatisticsListAdapter;
+
+import java.util.List;
+import java.util.Locale;
+
+/**
+ * Adapter for the download statistics list.
+ */
+public class DownloadStatisticsListAdapter extends StatisticsListAdapter {
+
+ public DownloadStatisticsListAdapter(Context context) {
+ super(context);
+ }
+
+ @Override
+ protected String getHeaderCaption() {
+ return context.getString(R.string.total_size_downloaded_podcasts);
+ }
+
+ @Override
+ protected String getHeaderValue() {
+ return Formatter.formatShortFileSize(context, (long) pieChartData.getSum());
+ }
+
+ @Override
+ protected PieChartView.PieChartData generateChartData(List<StatisticsItem> statisticsData) {
+ float[] dataValues = new float[statisticsData.size()];
+ for (int i = 0; i < statisticsData.size(); i++) {
+ StatisticsItem item = statisticsData.get(i);
+ dataValues[i] = item.totalDownloadSize;
+ }
+ return new PieChartView.PieChartData(dataValues);
+ }
+
+ @Override
+ protected void onBindFeedViewHolder(StatisticsHolder holder, StatisticsItem item) {
+ holder.value.setText(Formatter.formatShortFileSize(context, item.totalDownloadSize)
+ + " • "
+ + String.format(Locale.getDefault(), "%d%s",
+ item.episodesDownloadCount, context.getString(R.string.episodes_suffix)));
+ }
+
+}
diff --git a/ui/statistics/src/main/java/de/danoeh/antennapod/ui/statistics/feed/FeedStatisticsDialogFragment.java b/ui/statistics/src/main/java/de/danoeh/antennapod/ui/statistics/feed/FeedStatisticsDialogFragment.java
new file mode 100644
index 000000000..7109bd6a1
--- /dev/null
+++ b/ui/statistics/src/main/java/de/danoeh/antennapod/ui/statistics/feed/FeedStatisticsDialogFragment.java
@@ -0,0 +1,42 @@
+package de.danoeh.antennapod.ui.statistics.feed;
+
+import android.app.Dialog;
+import android.os.Bundle;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.appcompat.app.AlertDialog;
+import androidx.fragment.app.DialogFragment;
+import de.danoeh.antennapod.ui.statistics.R;
+
+public class FeedStatisticsDialogFragment extends DialogFragment {
+ private static final String EXTRA_FEED_ID = "de.danoeh.antennapod.extra.feedId";
+ private static final String EXTRA_FEED_TITLE = "de.danoeh.antennapod.extra.feedTitle";
+
+ public static FeedStatisticsDialogFragment newInstance(long feedId, String feedTitle) {
+ FeedStatisticsDialogFragment fragment = new FeedStatisticsDialogFragment();
+ Bundle arguments = new Bundle();
+ arguments.putLong(EXTRA_FEED_ID, feedId);
+ arguments.putString(EXTRA_FEED_TITLE, feedTitle);
+ fragment.setArguments(arguments);
+ return fragment;
+ }
+
+ @NonNull
+ @Override
+ public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) {
+ AlertDialog.Builder dialog = new AlertDialog.Builder(getContext());
+ dialog.setPositiveButton(android.R.string.ok, null);
+ dialog.setTitle(getArguments().getString(EXTRA_FEED_TITLE));
+ dialog.setView(R.layout.feed_statistics_dialog);
+ return dialog.create();
+ }
+
+ @Override
+ public void onStart() {
+ super.onStart();
+ long feedId = getArguments().getLong(EXTRA_FEED_ID);
+ getChildFragmentManager().beginTransaction().replace(R.id.statisticsContainer,
+ FeedStatisticsFragment.newInstance(feedId, true), "feed_statistics_fragment")
+ .commitAllowingStateLoss();
+ }
+}
diff --git a/ui/statistics/src/main/java/de/danoeh/antennapod/ui/statistics/feed/FeedStatisticsFragment.java b/ui/statistics/src/main/java/de/danoeh/antennapod/ui/statistics/feed/FeedStatisticsFragment.java
new file mode 100644
index 000000000..1aeeb8fa9
--- /dev/null
+++ b/ui/statistics/src/main/java/de/danoeh/antennapod/ui/statistics/feed/FeedStatisticsFragment.java
@@ -0,0 +1,95 @@
+package de.danoeh.antennapod.ui.statistics.feed;
+
+import android.os.Bundle;
+import android.text.format.Formatter;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.fragment.app.Fragment;
+import de.danoeh.antennapod.core.storage.DBReader;
+import de.danoeh.antennapod.core.storage.StatisticsItem;
+import de.danoeh.antennapod.core.util.Converter;
+import de.danoeh.antennapod.ui.statistics.databinding.FeedStatisticsBinding;
+import io.reactivex.Observable;
+import io.reactivex.android.schedulers.AndroidSchedulers;
+import io.reactivex.disposables.Disposable;
+import io.reactivex.schedulers.Schedulers;
+
+import java.util.Collections;
+import java.util.Locale;
+
+public class FeedStatisticsFragment extends Fragment {
+ private static final String EXTRA_FEED_ID = "de.danoeh.antennapod.extra.feedId";
+ private static final String EXTRA_DETAILED = "de.danoeh.antennapod.extra.detailed";
+
+ private long feedId;
+ private Disposable disposable;
+ private FeedStatisticsBinding viewBinding;
+
+ public static FeedStatisticsFragment newInstance(long feedId, boolean detailed) {
+ FeedStatisticsFragment fragment = new FeedStatisticsFragment();
+ Bundle arguments = new Bundle();
+ arguments.putLong(EXTRA_FEED_ID, feedId);
+ arguments.putBoolean(EXTRA_DETAILED, detailed);
+ fragment.setArguments(arguments);
+ return fragment;
+ }
+
+ @Nullable
+ @Override
+ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container,
+ @Nullable Bundle savedInstanceState) {
+ feedId = getArguments().getLong(EXTRA_FEED_ID);
+ viewBinding = FeedStatisticsBinding.inflate(inflater);
+
+ if (!getArguments().getBoolean(EXTRA_DETAILED)) {
+ for (int i = 0; i < viewBinding.getRoot().getChildCount(); i++) {
+ View child = viewBinding.getRoot().getChildAt(i);
+ if ("detailed".equals(child.getTag())) {
+ child.setVisibility(View.GONE);
+ }
+ }
+ }
+
+ loadStatistics();
+ return viewBinding.getRoot();
+ }
+
+ private void loadStatistics() {
+ disposable =
+ Observable.fromCallable(() -> {
+ DBReader.StatisticsResult statisticsData = DBReader.getStatistics(true, 0, Long.MAX_VALUE);
+ Collections.sort(statisticsData.feedTime, (item1, item2) ->
+ Long.compare(item2.timePlayed, item1.timePlayed));
+
+ for (StatisticsItem statisticsItem : statisticsData.feedTime) {
+ if (statisticsItem.feed.getId() == feedId) {
+ return statisticsItem;
+ }
+ }
+ return null;
+ })
+ .subscribeOn(Schedulers.io())
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribe(this::showStats, Throwable::printStackTrace);
+ }
+
+ private void showStats(StatisticsItem s) {
+ viewBinding.startedTotalLabel.setText(String.format(Locale.getDefault(), "%d / %d",
+ s.episodesStarted, s.episodes));
+ viewBinding.timePlayedLabel.setText(Converter.shortLocalizedDuration(getContext(), s.timePlayed));
+ viewBinding.totalDurationLabel.setText(Converter.shortLocalizedDuration(getContext(), s.time));
+ viewBinding.onDeviceLabel.setText(String.format(Locale.getDefault(), "%d", s.episodesDownloadCount));
+ viewBinding.spaceUsedLabel.setText(Formatter.formatShortFileSize(getContext(), s.totalDownloadSize));
+ }
+
+ @Override
+ public void onDestroy() {
+ super.onDestroy();
+ if (disposable != null) {
+ disposable.dispose();
+ }
+ }
+}
diff --git a/ui/statistics/src/main/java/de/danoeh/antennapod/ui/statistics/subscriptions/PlaybackStatisticsListAdapter.java b/ui/statistics/src/main/java/de/danoeh/antennapod/ui/statistics/subscriptions/PlaybackStatisticsListAdapter.java
new file mode 100644
index 000000000..662f96775
--- /dev/null
+++ b/ui/statistics/src/main/java/de/danoeh/antennapod/ui/statistics/subscriptions/PlaybackStatisticsListAdapter.java
@@ -0,0 +1,74 @@
+package de.danoeh.antennapod.ui.statistics.subscriptions;
+
+import androidx.fragment.app.Fragment;
+import de.danoeh.antennapod.core.storage.StatisticsItem;
+import de.danoeh.antennapod.core.util.Converter;
+import de.danoeh.antennapod.ui.statistics.PieChartView;
+import de.danoeh.antennapod.ui.statistics.R;
+import de.danoeh.antennapod.ui.statistics.StatisticsListAdapter;
+import de.danoeh.antennapod.ui.statistics.feed.FeedStatisticsDialogFragment;
+
+import java.text.SimpleDateFormat;
+import java.util.Date;
+import java.util.List;
+import java.util.Locale;
+
+/**
+ * Adapter for the playback statistics list.
+ */
+public class PlaybackStatisticsListAdapter extends StatisticsListAdapter {
+
+ private final Fragment fragment;
+ private long timeFilterFrom = 0;
+ private long timeFilterTo = Long.MAX_VALUE;
+ private boolean includeMarkedAsPlayed = false;
+
+ public PlaybackStatisticsListAdapter(Fragment fragment) {
+ super(fragment.getContext());
+ this.fragment = fragment;
+ }
+
+ public void setTimeFilter(boolean includeMarkedAsPlayed, long timeFilterFrom, long timeFilterTo) {
+ this.includeMarkedAsPlayed = includeMarkedAsPlayed;
+ this.timeFilterFrom = timeFilterFrom;
+ this.timeFilterTo = timeFilterTo;
+ }
+
+ @Override
+ protected String getHeaderCaption() {
+ if (includeMarkedAsPlayed) {
+ return context.getString(R.string.statistics_counting_total);
+ }
+ SimpleDateFormat dateFormat = new SimpleDateFormat("MMM yyyy", Locale.getDefault());
+ String dateFrom = dateFormat.format(new Date(timeFilterFrom));
+ String dateTo = dateFormat.format(new Date(timeFilterTo));
+ return context.getString(R.string.statistics_counting_range, dateFrom, dateTo);
+ }
+
+ @Override
+ protected String getHeaderValue() {
+ return Converter.shortLocalizedDuration(context, (long) pieChartData.getSum());
+ }
+
+ @Override
+ protected PieChartView.PieChartData generateChartData(List<StatisticsItem> statisticsData) {
+ float[] dataValues = new float[statisticsData.size()];
+ for (int i = 0; i < statisticsData.size(); i++) {
+ StatisticsItem item = statisticsData.get(i);
+ dataValues[i] = item.timePlayed;
+ }
+ return new PieChartView.PieChartData(dataValues);
+ }
+
+ @Override
+ protected void onBindFeedViewHolder(StatisticsHolder holder, StatisticsItem statsItem) {
+ long time = statsItem.timePlayed;
+ holder.value.setText(Converter.shortLocalizedDuration(context, time));
+
+ holder.itemView.setOnClickListener(v -> {
+ FeedStatisticsDialogFragment yourDialogFragment = FeedStatisticsDialogFragment.newInstance(
+ statsItem.feed.getId(), statsItem.feed.getTitle());
+ yourDialogFragment.show(fragment.getChildFragmentManager().beginTransaction(), "DialogFragment");
+ });
+ }
+}
diff --git a/ui/statistics/src/main/java/de/danoeh/antennapod/ui/statistics/subscriptions/SubscriptionStatisticsFragment.java b/ui/statistics/src/main/java/de/danoeh/antennapod/ui/statistics/subscriptions/SubscriptionStatisticsFragment.java
new file mode 100644
index 000000000..79cacfa82
--- /dev/null
+++ b/ui/statistics/src/main/java/de/danoeh/antennapod/ui/statistics/subscriptions/SubscriptionStatisticsFragment.java
@@ -0,0 +1,278 @@
+package de.danoeh.antennapod.ui.statistics.subscriptions;
+
+import android.content.Context;
+import android.content.DialogInterface;
+import android.content.SharedPreferences;
+import android.os.Bundle;
+import android.util.Log;
+import android.view.LayoutInflater;
+import android.view.Menu;
+import android.view.MenuItem;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ArrayAdapter;
+import android.widget.ProgressBar;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.appcompat.app.AlertDialog;
+import androidx.core.util.Pair;
+import androidx.fragment.app.Fragment;
+import androidx.recyclerview.widget.LinearLayoutManager;
+import androidx.recyclerview.widget.RecyclerView;
+
+import de.danoeh.antennapod.core.dialog.ConfirmationDialog;
+import de.danoeh.antennapod.core.storage.DBReader;
+import de.danoeh.antennapod.core.storage.DBWriter;
+import de.danoeh.antennapod.ui.statistics.R;
+import de.danoeh.antennapod.ui.statistics.databinding.StatisticsFilterDialogBinding;
+import io.reactivex.Completable;
+import io.reactivex.Observable;
+import io.reactivex.android.schedulers.AndroidSchedulers;
+import io.reactivex.disposables.Disposable;
+import io.reactivex.schedulers.Schedulers;
+
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Calendar;
+import java.util.Collections;
+import java.util.Date;
+import java.util.Locale;
+
+/**
+ * Displays the 'playback statistics' screen
+ */
+public class SubscriptionStatisticsFragment extends Fragment {
+ private static final String TAG = SubscriptionStatisticsFragment.class.getSimpleName();
+ private static final String PREF_NAME = "StatisticsActivityPrefs";
+ private static final String PREF_INCLUDE_MARKED_PLAYED = "countAll";
+ private static final String PREF_FILTER_FROM = "filterFrom";
+ private static final String PREF_FILTER_TO = "filterTo";
+
+ private Disposable disposable;
+ private RecyclerView feedStatisticsList;
+ private ProgressBar progressBar;
+ private PlaybackStatisticsListAdapter listAdapter;
+ private boolean includeMarkedAsPlayed = false;
+ private long timeFilterFrom = 0;
+ private long timeFilterTo = Long.MAX_VALUE;
+ private SharedPreferences prefs;
+ private DBReader.StatisticsResult statisticsResult;
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ prefs = getContext().getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE);
+ includeMarkedAsPlayed = prefs.getBoolean(PREF_INCLUDE_MARKED_PLAYED, false);
+ timeFilterFrom = prefs.getLong(PREF_FILTER_FROM, 0);
+ timeFilterTo = prefs.getLong(PREF_FILTER_TO, Long.MAX_VALUE);
+ setHasOptionsMenu(true);
+ }
+
+ @Nullable
+ @Override
+ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container,
+ @Nullable Bundle savedInstanceState) {
+ View root = inflater.inflate(R.layout.statistics_fragment, container, false);
+ feedStatisticsList = root.findViewById(R.id.statistics_list);
+ progressBar = root.findViewById(R.id.progressBar);
+ listAdapter = new PlaybackStatisticsListAdapter(this);
+ feedStatisticsList.setLayoutManager(new LinearLayoutManager(getContext()));
+ feedStatisticsList.setAdapter(listAdapter);
+ return root;
+ }
+
+ @Override
+ public void onStart() {
+ super.onStart();
+ refreshStatistics();
+ }
+
+ @Override
+ public void onDestroyView() {
+ super.onDestroyView();
+ if (disposable != null) {
+ disposable.dispose();
+ }
+ }
+
+ @Override
+ public void onPrepareOptionsMenu(@NonNull Menu menu) {
+ super.onPrepareOptionsMenu(menu);
+ menu.findItem(R.id.statistics_reset).setVisible(true);
+ menu.findItem(R.id.statistics_filter).setVisible(true);
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(MenuItem item) {
+ if (item.getItemId() == R.id.statistics_filter) {
+ selectStatisticsFilter();
+ return true;
+ } else if (item.getItemId() == R.id.statistics_reset) {
+ confirmResetStatistics();
+ return true;
+ }
+ return super.onOptionsItemSelected(item);
+ }
+
+ private void selectStatisticsFilter() {
+ if (statisticsResult == null) {
+ return;
+ }
+ StatisticsFilterDialogBinding dialogBinding = StatisticsFilterDialogBinding.inflate(getLayoutInflater());
+ AlertDialog.Builder builder = new AlertDialog.Builder(getContext());
+ builder.setView(dialogBinding.getRoot());
+ builder.setTitle(R.string.filter);
+ dialogBinding.includeMarkedCheckbox.setOnCheckedChangeListener((compoundButton, checked) -> {
+ dialogBinding.timeToSpinner.setEnabled(!checked);
+ dialogBinding.timeFromSpinner.setEnabled(!checked);
+ dialogBinding.lastYearButton.setEnabled(!checked);
+ dialogBinding.allTimeButton.setEnabled(!checked);
+ dialogBinding.dateSelectionContainer.setAlpha(checked ? 0.5f : 1f);
+ });
+ dialogBinding.includeMarkedCheckbox.setChecked(includeMarkedAsPlayed);
+
+ Pair<String[], Long[]> filterDates = makeMonthlyList(statisticsResult.oldestDate);
+
+ ArrayAdapter<String> adapterFrom = new ArrayAdapter<>(getContext(),
+ android.R.layout.simple_spinner_item, filterDates.first);
+ adapterFrom.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
+ dialogBinding.timeFromSpinner.setAdapter(adapterFrom);
+ for (int i = 0; i < filterDates.second.length; i++) {
+ if (filterDates.second[i] >= timeFilterFrom) {
+ dialogBinding.timeFromSpinner.setSelection(i);
+ break;
+ }
+ }
+
+ ArrayAdapter<String> adapterTo = new ArrayAdapter<>(getContext(),
+ android.R.layout.simple_spinner_item, filterDates.first);
+ adapterTo.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
+ dialogBinding.timeToSpinner.setAdapter(adapterTo);
+ for (int i = 0; i < filterDates.second.length; i++) {
+ if (filterDates.second[i] >= timeFilterTo) {
+ dialogBinding.timeToSpinner.setSelection(i);
+ break;
+ }
+ }
+
+ dialogBinding.allTimeButton.setOnClickListener(v -> {
+ dialogBinding.timeFromSpinner.setSelection(0);
+ dialogBinding.timeToSpinner.setSelection(filterDates.first.length - 1);
+ });
+ dialogBinding.lastYearButton.setOnClickListener(v -> {
+ dialogBinding.timeFromSpinner.setSelection(Math.max(0, filterDates.first.length - 14));
+ dialogBinding.timeToSpinner.setSelection(filterDates.first.length - 2);
+ });
+
+ builder.setPositiveButton(android.R.string.ok, (dialog, which) -> {
+ includeMarkedAsPlayed = dialogBinding.includeMarkedCheckbox.isChecked();
+ if (includeMarkedAsPlayed) {
+ // We do not know the date at which something was marked as played, so filtering does not make sense
+ timeFilterFrom = 0;
+ timeFilterTo = Long.MAX_VALUE;
+ } else {
+ timeFilterFrom = filterDates.second[dialogBinding.timeFromSpinner.getSelectedItemPosition()];
+ timeFilterTo = filterDates.second[dialogBinding.timeToSpinner.getSelectedItemPosition()];
+ }
+ prefs.edit()
+ .putBoolean(PREF_INCLUDE_MARKED_PLAYED, includeMarkedAsPlayed)
+ .putLong(PREF_FILTER_FROM, timeFilterFrom)
+ .putLong(PREF_FILTER_TO, timeFilterTo)
+ .apply();
+ refreshStatistics();
+ });
+ builder.show();
+ }
+
+ private Pair<String[], Long[]> makeMonthlyList(long oldestDate) {
+ Calendar date = Calendar.getInstance();
+ date.setTimeInMillis(oldestDate);
+ date.set(Calendar.DAY_OF_MONTH, 1);
+ ArrayList<String> names = new ArrayList<>();
+ ArrayList<Long> timestamps = new ArrayList<>();
+ SimpleDateFormat dateFormat = new SimpleDateFormat("MMM yyyy", Locale.getDefault());
+ while (date.getTimeInMillis() < System.currentTimeMillis()) {
+ names.add(dateFormat.format(new Date(date.getTimeInMillis())));
+ timestamps.add(date.getTimeInMillis());
+ if (date.get(Calendar.MONTH) == Calendar.DECEMBER) {
+ date.set(Calendar.MONTH, Calendar.JANUARY);
+ date.set(Calendar.YEAR, date.get(Calendar.YEAR) + 1);
+ } else {
+ date.set(Calendar.MONTH, date.get(Calendar.MONTH) + 1);
+ }
+ }
+ names.add(getString(R.string.statistics_today));
+ timestamps.add(Long.MAX_VALUE);
+ return new Pair<>(names.toArray(new String[0]), timestamps.toArray(new Long[0]));
+ }
+
+ private void confirmResetStatistics() {
+ ConfirmationDialog conDialog = new ConfirmationDialog(
+ getActivity(),
+ R.string.statistics_reset_data,
+ R.string.statistics_reset_data_msg) {
+
+ @Override
+ public void onConfirmButtonPressed(DialogInterface dialog) {
+ dialog.dismiss();
+ doResetStatistics();
+ }
+ };
+ conDialog.createNewDialog().show();
+ }
+
+ private void doResetStatistics() {
+ progressBar.setVisibility(View.VISIBLE);
+ feedStatisticsList.setVisibility(View.GONE);
+ if (disposable != null) {
+ disposable.dispose();
+ }
+
+ includeMarkedAsPlayed = false;
+ timeFilterFrom = 0;
+ timeFilterTo = Long.MAX_VALUE;
+ prefs.edit()
+ .putBoolean(PREF_INCLUDE_MARKED_PLAYED, includeMarkedAsPlayed)
+ .putLong(PREF_FILTER_FROM, timeFilterFrom)
+ .putLong(PREF_FILTER_TO, timeFilterTo)
+ .apply();
+
+ disposable = Completable.fromFuture(DBWriter.resetStatistics())
+ .subscribeOn(Schedulers.io())
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribe(this::refreshStatistics, error -> Log.e(TAG, Log.getStackTraceString(error)));
+ }
+
+ private void refreshStatistics() {
+ progressBar.setVisibility(View.VISIBLE);
+ feedStatisticsList.setVisibility(View.GONE);
+ loadStatistics();
+ }
+
+ private void loadStatistics() {
+ if (disposable != null) {
+ disposable.dispose();
+ }
+ disposable = Observable.fromCallable(
+ () -> {
+ DBReader.StatisticsResult statisticsData = DBReader.getStatistics(
+ includeMarkedAsPlayed, timeFilterFrom, timeFilterTo);
+ Collections.sort(statisticsData.feedTime, (item1, item2) ->
+ Long.compare(item2.timePlayed, item1.timePlayed));
+ return statisticsData;
+ })
+ .subscribeOn(Schedulers.io())
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribe(result -> {
+ statisticsResult = result;
+ // When "from" is "today", set it to today
+ listAdapter.setTimeFilter(includeMarkedAsPlayed, Math.max(
+ Math.min(timeFilterFrom, System.currentTimeMillis()), result.oldestDate),
+ Math.min(timeFilterTo, System.currentTimeMillis()));
+ listAdapter.update(result.feedTime);
+ progressBar.setVisibility(View.GONE);
+ feedStatisticsList.setVisibility(View.VISIBLE);
+ }, error -> Log.e(TAG, Log.getStackTraceString(error)));
+ }
+}
diff --git a/ui/statistics/src/main/java/de/danoeh/antennapod/ui/statistics/years/LineChartView.java b/ui/statistics/src/main/java/de/danoeh/antennapod/ui/statistics/years/LineChartView.java
new file mode 100644
index 000000000..e56bb4f56
--- /dev/null
+++ b/ui/statistics/src/main/java/de/danoeh/antennapod/ui/statistics/years/LineChartView.java
@@ -0,0 +1,138 @@
+package de.danoeh.antennapod.ui.statistics.years;
+
+import android.annotation.SuppressLint;
+import android.content.Context;
+import android.graphics.Canvas;
+import android.graphics.Color;
+import android.graphics.ColorFilter;
+import android.graphics.DashPathEffect;
+import android.graphics.LinearGradient;
+import android.graphics.Paint;
+import android.graphics.Path;
+import android.graphics.PixelFormat;
+import android.graphics.Shader;
+import android.graphics.drawable.Drawable;
+import android.util.AttributeSet;
+import androidx.annotation.NonNull;
+import androidx.appcompat.widget.AppCompatImageView;
+import de.danoeh.antennapod.ui.common.ThemeUtils;
+import de.danoeh.antennapod.ui.statistics.R;
+import io.reactivex.annotations.Nullable;
+
+public class LineChartView extends AppCompatImageView {
+ private LineChartDrawable drawable;
+
+ public LineChartView(Context context) {
+ super(context);
+ setup();
+ }
+
+ public LineChartView(Context context, @Nullable AttributeSet attrs) {
+ super(context, attrs);
+ setup();
+ }
+
+ public LineChartView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ setup();
+ }
+
+ @SuppressLint("ClickableViewAccessibility")
+ private void setup() {
+ drawable = new LineChartDrawable();
+ setImageDrawable(drawable);
+ }
+
+ /**
+ * Set of data values to display.
+ */
+ public void setData(LineChartData data) {
+ drawable.data = data;
+ }
+
+ public static class LineChartData {
+ private final long valueMax;
+ private final long[] values;
+ private final long[] verticalLines;
+
+ public LineChartData(long[] values, long[] verticalLines) {
+ this.values = values;
+ long valueMax = 0;
+ for (long datum : values) {
+ valueMax = Math.max(datum, valueMax);
+ }
+ this.valueMax = valueMax;
+ this.verticalLines = verticalLines;
+ }
+
+ public float getHeight(int item) {
+ return (float) values[item] / valueMax;
+ }
+ }
+
+ private class LineChartDrawable extends Drawable {
+ private LineChartData data;
+ private final Paint paintLine;
+ private final Paint paintBackground;
+ private final Paint paintVerticalLines;
+
+ private LineChartDrawable() {
+ paintLine = new Paint();
+ paintLine.setFlags(Paint.ANTI_ALIAS_FLAG);
+ paintLine.setStyle(Paint.Style.STROKE);
+ paintLine.setStrokeJoin(Paint.Join.ROUND);
+ paintLine.setStrokeCap(Paint.Cap.ROUND);
+ paintLine.setColor(ThemeUtils.getColorFromAttr(getContext(), R.attr.colorAccent));
+ paintBackground = new Paint();
+ paintBackground.setStyle(Paint.Style.FILL);
+ paintVerticalLines = new Paint();
+ paintVerticalLines.setStyle(Paint.Style.STROKE);
+ paintVerticalLines.setPathEffect(new DashPathEffect(new float[] {10f, 10f}, 0f));
+ paintVerticalLines.setColor(0x66777777);
+ }
+
+ @Override
+ public void draw(@NonNull Canvas canvas) {
+ float width = getBounds().width();
+ float height = getBounds().height();
+ float usableHeight = height * 0.9f;
+ float stepSize = width / (data.values.length + 1);
+
+ paintVerticalLines.setStrokeWidth(height * 0.005f);
+ for (long line : data.verticalLines) {
+ canvas.drawLine((line + 1) * stepSize, 0, (line + 1) * stepSize, height, paintVerticalLines);
+ }
+
+ paintLine.setStrokeWidth(height * 0.015f);
+ Path path = new Path();
+ for (int i = 0; i < data.values.length; i++) {
+ if (i == 0) {
+ path.moveTo((i + 1) * stepSize, (1 - data.getHeight(i)) * usableHeight + height * 0.05f);
+ } else {
+ path.lineTo((i + 1) * stepSize, (1 - data.getHeight(i)) * usableHeight + height * 0.05f);
+ }
+ }
+ canvas.drawPath(path, paintLine);
+
+ path.lineTo(data.values.length * stepSize, height);
+ path.lineTo(stepSize, height);
+ paintBackground.setShader(new LinearGradient(0, 0, 0, height,
+ (ThemeUtils.getColorFromAttr(getContext(), R.attr.colorAccent) & 0xffffff) + 0x66000000,
+ Color.TRANSPARENT, Shader.TileMode.CLAMP));
+ canvas.drawPath(path, paintBackground);
+ }
+
+ @Override
+ public int getOpacity() {
+ return PixelFormat.TRANSLUCENT;
+ }
+
+ @Override
+ public void setAlpha(int alpha) {
+ }
+
+ @Override
+ public void setColorFilter(ColorFilter cf) {
+ }
+ }
+}
diff --git a/ui/statistics/src/main/java/de/danoeh/antennapod/ui/statistics/years/YearStatisticsListAdapter.java b/ui/statistics/src/main/java/de/danoeh/antennapod/ui/statistics/years/YearStatisticsListAdapter.java
new file mode 100644
index 000000000..ed5a7a4f1
--- /dev/null
+++ b/ui/statistics/src/main/java/de/danoeh/antennapod/ui/statistics/years/YearStatisticsListAdapter.java
@@ -0,0 +1,120 @@
+package de.danoeh.antennapod.ui.statistics.years;
+
+import android.content.Context;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.TextView;
+import androidx.annotation.NonNull;
+import androidx.recyclerview.widget.RecyclerView;
+import de.danoeh.antennapod.core.storage.DBReader;
+import de.danoeh.antennapod.core.util.LongList;
+import de.danoeh.antennapod.ui.statistics.R;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Locale;
+
+/**
+ * Adapter for the yearly playback statistics list.
+ */
+public class YearStatisticsListAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder> {
+ private static final int TYPE_HEADER = 0;
+ private static final int TYPE_FEED = 1;
+ final Context context;
+ private List<DBReader.MonthlyStatisticsItem> statisticsData = new ArrayList<>();
+ LineChartView.LineChartData lineChartData;
+
+ public YearStatisticsListAdapter(Context context) {
+ this.context = context;
+ }
+
+ @Override
+ public int getItemCount() {
+ return statisticsData.size() + 1;
+ }
+
+ @Override
+ public int getItemViewType(int position) {
+ return position == 0 ? TYPE_HEADER : TYPE_FEED;
+ }
+
+ @NonNull
+ @Override
+ public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
+ LayoutInflater inflater = LayoutInflater.from(context);
+ if (viewType == TYPE_HEADER) {
+ return new HeaderHolder(inflater.inflate(R.layout.statistics_listitem_linechart, parent, false));
+ }
+ return new StatisticsHolder(inflater.inflate(R.layout.statistics_year_listitem, parent, false));
+ }
+
+ @Override
+ public void onBindViewHolder(@NonNull RecyclerView.ViewHolder h, int position) {
+ if (getItemViewType(position) == TYPE_HEADER) {
+ HeaderHolder holder = (HeaderHolder) h;
+ holder.lineChart.setData(lineChartData);
+ } else {
+ StatisticsHolder holder = (StatisticsHolder) h;
+ DBReader.MonthlyStatisticsItem statsItem = statisticsData.get(position - 1);
+ holder.year.setText(String.format(Locale.getDefault(), "%d ", statsItem.year));
+ holder.hours.setText(String.format(Locale.getDefault(), "%.1f ", statsItem.timePlayed / 3600000.0f)
+ + context.getString(R.string.time_hours));
+ }
+ }
+
+ public void update(List<DBReader.MonthlyStatisticsItem> statistics) {
+ int lastYear = statistics.size() > 0 ? statistics.get(0).year : 0;
+ int lastDataPoint = statistics.size() > 0 ? (statistics.get(0).month - 1) + lastYear * 12 : 0;
+ long yearSum = 0;
+ statisticsData.clear();
+ LongList lineChartValues = new LongList();
+ LongList lineChartHorizontalLines = new LongList();
+ for (DBReader.MonthlyStatisticsItem statistic : statistics) {
+ if (statistic.year != lastYear) {
+ DBReader.MonthlyStatisticsItem yearAggregate = new DBReader.MonthlyStatisticsItem();
+ yearAggregate.year = lastYear;
+ yearAggregate.timePlayed = yearSum;
+ statisticsData.add(yearAggregate);
+ yearSum = 0;
+ lastYear = statistic.year;
+ lineChartHorizontalLines.add(lineChartValues.size());
+ }
+ yearSum += statistic.timePlayed;
+ while (lastDataPoint + 1 < (statistic.month - 1) + statistic.year * 12) {
+ lineChartValues.add(0); // Compensate for months without playback
+ lastDataPoint++;
+ }
+ lineChartValues.add(statistic.timePlayed);
+ lastDataPoint = (statistic.month - 1) + statistic.year * 12;
+ }
+ DBReader.MonthlyStatisticsItem yearAggregate = new DBReader.MonthlyStatisticsItem();
+ yearAggregate.year = lastYear;
+ yearAggregate.timePlayed = yearSum;
+ statisticsData.add(yearAggregate);
+ Collections.reverse(statisticsData);
+ lineChartData = new LineChartView.LineChartData(lineChartValues.toArray(), lineChartHorizontalLines.toArray());
+ notifyDataSetChanged();
+ }
+
+ static class HeaderHolder extends RecyclerView.ViewHolder {
+ LineChartView lineChart;
+
+ HeaderHolder(View itemView) {
+ super(itemView);
+ lineChart = itemView.findViewById(R.id.lineChart);
+ }
+ }
+
+ static class StatisticsHolder extends RecyclerView.ViewHolder {
+ TextView year;
+ TextView hours;
+
+ StatisticsHolder(View itemView) {
+ super(itemView);
+ year = itemView.findViewById(R.id.yearLabel);
+ hours = itemView.findViewById(R.id.hoursLabel);
+ }
+ }
+}
diff --git a/ui/statistics/src/main/java/de/danoeh/antennapod/ui/statistics/years/YearsStatisticsFragment.java b/ui/statistics/src/main/java/de/danoeh/antennapod/ui/statistics/years/YearsStatisticsFragment.java
new file mode 100644
index 000000000..89a2689ae
--- /dev/null
+++ b/ui/statistics/src/main/java/de/danoeh/antennapod/ui/statistics/years/YearsStatisticsFragment.java
@@ -0,0 +1,86 @@
+package de.danoeh.antennapod.ui.statistics.years;
+
+import android.os.Bundle;
+import android.util.Log;
+import android.view.LayoutInflater;
+import android.view.Menu;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ProgressBar;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.fragment.app.Fragment;
+import androidx.recyclerview.widget.LinearLayoutManager;
+import androidx.recyclerview.widget.RecyclerView;
+import de.danoeh.antennapod.core.storage.DBReader;
+import de.danoeh.antennapod.ui.statistics.R;
+import io.reactivex.Observable;
+import io.reactivex.android.schedulers.AndroidSchedulers;
+import io.reactivex.disposables.Disposable;
+import io.reactivex.schedulers.Schedulers;
+
+/**
+ * Displays the yearly statistics screen
+ */
+public class YearsStatisticsFragment extends Fragment {
+ private static final String TAG = YearsStatisticsFragment.class.getSimpleName();
+
+ private Disposable disposable;
+ private RecyclerView yearStatisticsList;
+ private ProgressBar progressBar;
+ private YearStatisticsListAdapter listAdapter;
+
+ @Nullable
+ @Override
+ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container,
+ @Nullable Bundle savedInstanceState) {
+ View root = inflater.inflate(R.layout.statistics_fragment, container, false);
+ yearStatisticsList = root.findViewById(R.id.statistics_list);
+ progressBar = root.findViewById(R.id.progressBar);
+ listAdapter = new YearStatisticsListAdapter(getContext());
+ yearStatisticsList.setLayoutManager(new LinearLayoutManager(getContext()));
+ yearStatisticsList.setAdapter(listAdapter);
+ return root;
+ }
+
+ @Override
+ public void onStart() {
+ super.onStart();
+ refreshStatistics();
+ }
+
+ @Override
+ public void onDestroyView() {
+ super.onDestroyView();
+ if (disposable != null) {
+ disposable.dispose();
+ }
+ }
+
+ @Override
+ public void onPrepareOptionsMenu(@NonNull Menu menu) {
+ super.onPrepareOptionsMenu(menu);
+ menu.findItem(R.id.statistics_reset).setVisible(false);
+ menu.findItem(R.id.statistics_filter).setVisible(false);
+ }
+
+ private void refreshStatistics() {
+ progressBar.setVisibility(View.VISIBLE);
+ yearStatisticsList.setVisibility(View.GONE);
+ loadStatistics();
+ }
+
+ private void loadStatistics() {
+ if (disposable != null) {
+ disposable.dispose();
+ }
+ disposable = Observable.fromCallable(DBReader::getMonthlyTimeStatistics)
+ .subscribeOn(Schedulers.io())
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribe(result -> {
+ listAdapter.update(result);
+ progressBar.setVisibility(View.GONE);
+ yearStatisticsList.setVisibility(View.VISIBLE);
+ }, error -> Log.e(TAG, Log.getStackTraceString(error)));
+ }
+}
diff --git a/ui/statistics/src/main/res/layout/feed_statistics.xml b/ui/statistics/src/main/res/layout/feed_statistics.xml
new file mode 100644
index 000000000..7897a7d5f
--- /dev/null
+++ b/ui/statistics/src/main/res/layout/feed_statistics.xml
@@ -0,0 +1,95 @@
+<?xml version="1.0" encoding="utf-8"?>
+<TableLayout
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:tools="http://schemas.android.com/tools"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content">
+
+ <TableRow
+ android:tag="detailed">
+
+ <TextView
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:text="@string/statistics_episodes_started_total" />
+
+ <TextView
+ android:id="@+id/startedTotalLabel"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginLeft="8dp"
+ android:layout_marginStart="8dp"
+ tools:text="0 / 0" />
+
+ </TableRow>
+
+ <TableRow>
+
+ <TextView
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:text="@string/statistics_time_played" />
+
+ <TextView
+ android:id="@+id/timePlayedLabel"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginLeft="8dp"
+ android:layout_marginStart="8dp"
+ tools:text="0 min" />
+
+ </TableRow>
+
+ <TableRow
+ android:tag="detailed">
+
+ <TextView
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:text="@string/statistics_total_duration" />
+
+ <TextView
+ android:id="@+id/totalDurationLabel"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginLeft="8dp"
+ android:layout_marginStart="8dp"
+ tools:text="0 min" />
+
+ </TableRow>
+
+ <TableRow>
+
+ <TextView
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:text="@string/statistics_episodes_on_device" />
+
+ <TextView
+ android:id="@+id/onDeviceLabel"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginLeft="8dp"
+ android:layout_marginStart="8dp"
+ tools:text="0" />
+
+ </TableRow>
+
+ <TableRow>
+
+ <TextView
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:text="@string/statistics_space_used" />
+
+ <TextView
+ android:id="@+id/spaceUsedLabel"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginLeft="8dp"
+ android:layout_marginStart="8dp"
+ tools:text="0 MB" />
+
+ </TableRow>
+
+</TableLayout>
diff --git a/ui/statistics/src/main/res/layout/feed_statistics_dialog.xml b/ui/statistics/src/main/res/layout/feed_statistics_dialog.xml
new file mode 100644
index 000000000..fcd36fe7a
--- /dev/null
+++ b/ui/statistics/src/main/res/layout/feed_statistics_dialog.xml
@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="utf-8"?>
+<androidx.fragment.app.FragmentContainerView
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ android:id="@+id/statisticsContainer"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:padding="16dp" />
diff --git a/ui/statistics/src/main/res/layout/statistics_filter_dialog.xml b/ui/statistics/src/main/res/layout/statistics_filter_dialog.xml
new file mode 100644
index 000000000..d37226c07
--- /dev/null
+++ b/ui/statistics/src/main/res/layout/statistics_filter_dialog.xml
@@ -0,0 +1,95 @@
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="vertical"
+ android:padding="16dp">
+
+ <CheckBox
+ android:id="@+id/includeMarkedCheckbox"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:text="@string/statistics_include_marked"
+ android:layout_marginBottom="8dp" />
+
+ <LinearLayout
+ android:id="@+id/dateSelectionContainer"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="vertical">
+
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal">
+
+ <TextView
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:text="@string/statistics_from"
+ android:padding="4dp"
+ android:layout_weight="1" />
+
+ <TextView
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:text="@string/statistics_to"
+ android:padding="4dp"
+ android:layout_weight="1" />
+
+ </LinearLayout>
+
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal">
+
+ <Spinner
+ android:id="@+id/timeFromSpinner"
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1" />
+
+ <Spinner
+ android:id="@+id/timeToSpinner"
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1" />
+
+ </LinearLayout>
+
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal">
+
+ <Button
+ android:id="@+id/lastYearButton"
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:text="@string/statistics_filter_last_year"
+ android:layout_weight="1"
+ android:layout_marginEnd="4dp"
+ style="@style/Widget.MaterialComponents.Button.OutlinedButton" />
+
+ <Button
+ android:id="@+id/allTimeButton"
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:text="@string/statistics_filter_all_time"
+ android:layout_weight="1"
+ android:layout_marginStart="4dp"
+ style="@style/Widget.MaterialComponents.Button.OutlinedButton" />
+
+ </LinearLayout>
+
+ </LinearLayout>
+
+ <TextView
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:text="@string/statistics_speed_not_counted"
+ android:layout_marginTop="16dp" />
+
+</LinearLayout>
diff --git a/ui/statistics/src/main/res/layout/statistics_fragment.xml b/ui/statistics/src/main/res/layout/statistics_fragment.xml
new file mode 100644
index 000000000..9d9cad438
--- /dev/null
+++ b/ui/statistics/src/main/res/layout/statistics_fragment.xml
@@ -0,0 +1,24 @@
+<?xml version="1.0" encoding="utf-8"?>
+<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:tools="http://schemas.android.com/tools"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:orientation="vertical">
+
+ <ProgressBar
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:id="@+id/progressBar"
+ android:layout_gravity="center"/>
+
+ <androidx.recyclerview.widget.RecyclerView
+ android:id="@+id/statistics_list"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:clipToPadding="false"
+ android:paddingBottom="@dimen/list_vertical_padding"
+ android:paddingTop="@dimen/list_vertical_padding"
+ android:scrollbarStyle="outsideOverlay"
+ tools:listitem="@layout/statistics_listitem"/>
+
+</FrameLayout>
diff --git a/ui/statistics/src/main/res/layout/statistics_listitem.xml b/ui/statistics/src/main/res/layout/statistics_listitem.xml
new file mode 100644
index 000000000..5989595e1
--- /dev/null
+++ b/ui/statistics/src/main/res/layout/statistics_listitem.xml
@@ -0,0 +1,71 @@
+<?xml version="1.0" encoding="utf-8"?>
+<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:tools="http://schemas.android.com/tools"
+ android:orientation="vertical"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:paddingLeft="16dp"
+ android:paddingRight="16dp"
+ android:paddingTop="8dp"
+ android:paddingBottom="8dp"
+ android:background="?android:attr/selectableItemBackground">
+
+ <ImageView
+ android:id="@+id/imgvCover"
+ android:importantForAccessibility="no"
+ android:layout_width="40dp"
+ android:layout_height="40dp"
+ android:layout_alignParentLeft="true"
+ android:layout_alignParentStart="true"
+ android:layout_centerVertical="true"
+ android:adjustViewBounds="true"
+ android:cropToPadding="true"
+ android:scaleType="fitCenter"
+ tools:src="@tools:sample/avatars"
+ tools:background="@android:color/holo_green_dark"/>
+
+ <TextView
+ android:id="@+id/txtvTitle"
+ android:lines="1"
+ android:ellipsize="end"
+ android:singleLine="true"
+ android:textColor="?android:attr/textColorPrimary"
+ android:textSize="16sp"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_marginLeft="16dp"
+ android:layout_marginStart="16dp"
+ android:layout_toRightOf="@id/imgvCover"
+ android:layout_toEndOf="@id/imgvCover"
+ android:layout_alignTop="@id/imgvCover"
+ android:layout_alignWithParentIfMissing="true"
+ tools:text="Feed title"/>
+
+ <TextView
+ android:id="@+id/chip"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:textSize="13sp"
+ android:layout_toEndOf="@+id/imgvCover"
+ android:layout_toRightOf="@+id/imgvCover"
+ android:layout_marginLeft="16dp"
+ android:layout_marginStart="16dp"
+ android:layout_below="@+id/txtvTitle"
+ android:layout_marginEnd="4dp"
+ android:layout_marginRight="4dp"
+ android:text="⬤"
+ tools:ignore="HardcodedText"/>
+
+ <TextView
+ android:id="@+id/txtvValue"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:lines="1"
+ android:textColor="?android:attr/textColorTertiary"
+ android:textSize="14sp"
+ android:layout_toEndOf="@+id/chip"
+ android:layout_toRightOf="@+id/chip"
+ android:layout_below="@+id/txtvTitle"
+ tools:text="23 hours"/>
+
+</RelativeLayout>
diff --git a/ui/statistics/src/main/res/layout/statistics_listitem_linechart.xml b/ui/statistics/src/main/res/layout/statistics_listitem_linechart.xml
new file mode 100644
index 000000000..e7d4052de
--- /dev/null
+++ b/ui/statistics/src/main/res/layout/statistics_listitem_linechart.xml
@@ -0,0 +1,22 @@
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="vertical"
+ android:padding="16dp">
+
+ <de.danoeh.antennapod.ui.statistics.years.LineChartView
+ android:id="@+id/lineChart"
+ android:layout_width="match_parent"
+ android:layout_height="200dp"
+ android:layout_marginStart="8dp"
+ android:layout_marginEnd="8dp" />
+
+ <View
+ android:layout_width="match_parent"
+ android:layout_height="1dp"
+ android:layout_marginTop="16dp"
+ android:background="?android:attr/dividerVertical" />
+
+</LinearLayout>
diff --git a/ui/statistics/src/main/res/layout/statistics_listitem_total.xml b/ui/statistics/src/main/res/layout/statistics_listitem_total.xml
new file mode 100644
index 000000000..4d5a77fec
--- /dev/null
+++ b/ui/statistics/src/main/res/layout/statistics_listitem_total.xml
@@ -0,0 +1,48 @@
+<?xml version="1.0" encoding="utf-8"?>
+<RelativeLayout
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:tools="http://schemas.android.com/tools"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:padding="16dp">
+
+ <de.danoeh.antennapod.ui.statistics.PieChartView
+ android:id="@+id/pie_chart"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_centerInParent="true"
+ android:layout_marginRight="8dp"
+ android:maxWidth="800dp"
+ android:minWidth="460dp"
+ android:layout_marginLeft="8dp" />
+
+ <TextView
+ android:id="@+id/total_time"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:textColor="?android:attr/textColorPrimary"
+ android:gravity="center_horizontal"
+ android:textSize="28sp"
+ android:layout_marginBottom="4dp"
+ android:layout_above="@id/total_description"
+ tools:text="10.0 hours" />
+
+ <TextView
+ android:id="@+id/total_description"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_centerHorizontal="true"
+ android:textAlignment="center"
+ android:maxLines="3"
+ android:textSize="14sp"
+ android:layout_marginBottom="16dp"
+ android:layout_alignBottom="@id/pie_chart" />
+
+ <View
+ android:layout_width="match_parent"
+ android:layout_height="1dp"
+ android:layout_marginTop="16dp"
+ android:background="?android:attr/dividerVertical"
+ android:layout_below="@+id/pie_chart" />
+
+</RelativeLayout>
diff --git a/ui/statistics/src/main/res/layout/statistics_year_listitem.xml b/ui/statistics/src/main/res/layout/statistics_year_listitem.xml
new file mode 100644
index 000000000..48b910c7f
--- /dev/null
+++ b/ui/statistics/src/main/res/layout/statistics_year_listitem.xml
@@ -0,0 +1,32 @@
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:tools="http://schemas.android.com/tools"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="vertical"
+ android:paddingLeft="16dp"
+ android:paddingRight="16dp"
+ android:paddingTop="16dp"
+ android:paddingBottom="8dp"
+ android:background="?android:attr/selectableItemBackground">
+
+ <TextView
+ android:id="@+id/yearLabel"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:lines="1"
+ android:textColor="?android:attr/textColorPrimary"
+ android:textSize="16sp"
+ tools:text="2020" />
+
+ <TextView
+ android:id="@+id/hoursLabel"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:lines="1"
+ android:textColor="?android:attr/textColorTertiary"
+ android:textSize="14sp"
+ tools:text="23 hours" />
+
+</LinearLayout>
diff --git a/ui/statistics/src/main/res/menu/statistics.xml b/ui/statistics/src/main/res/menu/statistics.xml
new file mode 100644
index 000000000..4610a7726
--- /dev/null
+++ b/ui/statistics/src/main/res/menu/statistics.xml
@@ -0,0 +1,17 @@
+<?xml version="1.0" encoding="utf-8"?>
+<menu xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:custom="http://schemas.android.com/apk/res-auto">
+
+ <item
+ android:id="@+id/statistics_reset"
+ android:title="@string/statistics_reset_data"
+ custom:showAsAction="never" />
+
+ <item
+ android:id="@+id/statistics_filter"
+ android:icon="@drawable/ic_filter"
+ android:title="@string/filter"
+ custom:showAsAction="ifRoom">
+ </item>
+
+</menu>