Merge pull request #5735 from ByteHamster/statistics-line-graph
Add line graph to statistics screen
This commit is contained in:
commit
be9093911f
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
|
|
|
@ -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";
|
|
@ -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)));
|
||||
}
|
||||
}
|
|
@ -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) {
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -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>
|
|
@ -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();
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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 -->
|
||||
|
|
Loading…
Reference in New Issue