Merge pull request #5735 from ByteHamster/statistics-line-graph

Add line graph to statistics screen
This commit is contained in:
ByteHamster 2022-02-22 19:44:09 +01:00 committed by GitHub
commit be9093911f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 455 additions and 12 deletions

View File

@ -0,0 +1,121 @@
package de.danoeh.antennapod.adapter;
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.R;
import de.danoeh.antennapod.core.storage.DBReader;
import de.danoeh.antennapod.core.util.LongList;
import de.danoeh.antennapod.view.LineChartView;
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);
}
}
}

View File

@ -25,9 +25,10 @@ public class StatisticsFragment extends PagedToolbarFragment {
public static final String TAG = "StatisticsFragment";
private static final int POS_LISTENED_HOURS = 0;
private static final int POS_SPACE_TAKEN = 1;
private static final int TOTAL_COUNT = 2;
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;
@ -51,11 +52,14 @@ public class StatisticsFragment extends PagedToolbarFragment {
super.setupPagedToolbar(toolbar, viewPager);
new TabLayoutMediator(tabLayout, viewPager, (tab, position) -> {
switch (position) {
case POS_LISTENED_HOURS:
tab.setText(R.string.playback_statistics_label);
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.download_statistics_label);
tab.setText(R.string.downloads_label);
break;
default:
break;
@ -82,8 +86,10 @@ public class StatisticsFragment extends PagedToolbarFragment {
@Override
public Fragment createFragment(int position) {
switch (position) {
case POS_LISTENED_HOURS:
return new PlaybackStatisticsFragment();
case POS_SUBSCRIPTIONS:
return new SubscriptionStatisticsFragment();
case POS_YEARS:
return new YearsStatisticsFragment();
default:
case POS_SPACE_TAKEN:
return new DownloadStatisticsFragment();

View File

@ -43,8 +43,8 @@ import java.util.Locale;
/**
* Displays the 'playback statistics' screen
*/
public class PlaybackStatisticsFragment extends Fragment {
private static final String TAG = PlaybackStatisticsFragment.class.getSimpleName();
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";

View File

@ -0,0 +1,87 @@
package de.danoeh.antennapod.fragment.preferences;
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.R;
import de.danoeh.antennapod.adapter.YearStatisticsListAdapter;
import de.danoeh.antennapod.core.storage.DBReader;
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_activity, 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)));
}
}

View File

@ -0,0 +1,138 @@
package de.danoeh.antennapod.view;
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 androidx.appcompat.widget.ThemeUtils;
import de.danoeh.antennapod.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.getThemeAttrColor(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.getThemeAttrColor(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) {
}
}
}

View File

@ -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.view.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>

View File

@ -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>

View File

@ -771,6 +771,33 @@ public final class DBReader {
}
}
public static class MonthlyStatisticsItem {
public int year = 0;
public int month = 0;
public long timePlayed = 0;
}
@NonNull
public static List<MonthlyStatisticsItem> getMonthlyTimeStatistics() {
List<MonthlyStatisticsItem> months = new ArrayList<>();
PodDBAdapter adapter = PodDBAdapter.getInstance();
adapter.open();
try (Cursor cursor = adapter.getMonthlyStatisticsCursor()) {
int indexMonth = cursor.getColumnIndexOrThrow("month");
int indexYear = cursor.getColumnIndexOrThrow("year");
int indexTotalDuration = cursor.getColumnIndexOrThrow("total_duration");
while (cursor.moveToNext()) {
MonthlyStatisticsItem item = new MonthlyStatisticsItem();
item.month = Integer.parseInt(cursor.getString(indexMonth));
item.year = Integer.parseInt(cursor.getString(indexYear));
item.timePlayed = cursor.getLong(indexTotalDuration);
months.add(item);
}
}
adapter.close();
return months;
}
public static class StatisticsResult {
public List<StatisticsItem> feedTime = new ArrayList<>();
public long oldestDate = System.currentTimeMillis();

View File

@ -1133,6 +1133,17 @@ public class PodDBAdapter {
return db.rawQuery(query, null);
}
public final Cursor getMonthlyStatisticsCursor() {
final String query = "SELECT SUM(" + KEY_PLAYED_DURATION + ") AS total_duration"
+ ", strftime('%m', datetime(" + KEY_LAST_PLAYED_TIME + "/1000, 'unixepoch')) AS month"
+ ", strftime('%Y', datetime(" + KEY_LAST_PLAYED_TIME + "/1000, 'unixepoch')) AS year"
+ " FROM " + TABLE_NAME_FEED_MEDIA
+ " WHERE " + KEY_LAST_PLAYED_TIME + " > 0 AND " + KEY_PLAYED_DURATION + " > 0"
+ " GROUP BY year, month"
+ " ORDER BY year, month";
return db.rawQuery(query, null);
}
public int getQueueSize() {
final String query = String.format("SELECT COUNT(%s) FROM %s", KEY_ID, TABLE_NAME_QUEUE);
Cursor c = db.rawQuery(query, null);

View File

@ -28,8 +28,7 @@
<string name="gpodnet_main_label">gpodder.net</string>
<string name="episode_cache_full_title">Episode cache full</string>
<string name="episode_cache_full_message">The episode cache limit has been reached. You can increase the cache size in the Settings.</string>
<string name="playback_statistics_label">Playback</string>
<string name="download_statistics_label">Downloads</string>
<string name="years_statistics_label">Years</string>
<string name="notification_pref_fragment">Notifications</string>
<!-- Google Assistant -->