Make yearly statistics a bar chart instead (#5759)
This commit is contained in:
parent
a7e795241e
commit
3b47deb705
|
@ -0,0 +1,135 @@
|
||||||
|
package de.danoeh.antennapod.ui.statistics.years;
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint;
|
||||||
|
import android.content.Context;
|
||||||
|
import android.graphics.Canvas;
|
||||||
|
import android.graphics.ColorFilter;
|
||||||
|
import android.graphics.DashPathEffect;
|
||||||
|
import android.graphics.Paint;
|
||||||
|
import android.graphics.PixelFormat;
|
||||||
|
import android.graphics.drawable.Drawable;
|
||||||
|
import android.util.AttributeSet;
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
|
import androidx.appcompat.widget.AppCompatImageView;
|
||||||
|
import de.danoeh.antennapod.core.storage.DBReader;
|
||||||
|
import de.danoeh.antennapod.ui.common.ThemeUtils;
|
||||||
|
import de.danoeh.antennapod.ui.statistics.R;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public class BarChartView extends AppCompatImageView {
|
||||||
|
private BarChartDrawable drawable;
|
||||||
|
|
||||||
|
public BarChartView(Context context) {
|
||||||
|
super(context);
|
||||||
|
setup();
|
||||||
|
}
|
||||||
|
|
||||||
|
public BarChartView(Context context, @Nullable AttributeSet attrs) {
|
||||||
|
super(context, attrs);
|
||||||
|
setup();
|
||||||
|
}
|
||||||
|
|
||||||
|
public BarChartView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
|
||||||
|
super(context, attrs, defStyleAttr);
|
||||||
|
setup();
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("ClickableViewAccessibility")
|
||||||
|
private void setup() {
|
||||||
|
drawable = new BarChartDrawable();
|
||||||
|
setImageDrawable(drawable);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set of data values to display.
|
||||||
|
*/
|
||||||
|
public void setData(List<DBReader.MonthlyStatisticsItem> data) {
|
||||||
|
drawable.data = data;
|
||||||
|
drawable.maxValue = 1;
|
||||||
|
for (DBReader.MonthlyStatisticsItem item : data) {
|
||||||
|
drawable.maxValue = Math.max(drawable.maxValue, item.timePlayed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class BarChartDrawable extends Drawable {
|
||||||
|
private static final long ONE_HOUR = 3600000L;
|
||||||
|
private List<DBReader.MonthlyStatisticsItem> data;
|
||||||
|
private long maxValue = 1;
|
||||||
|
private final Paint paintBars;
|
||||||
|
private final Paint paintGridLines;
|
||||||
|
private final Paint paintGridText;
|
||||||
|
private final int[] colors = {0, 0xff9c27b0};
|
||||||
|
|
||||||
|
private BarChartDrawable() {
|
||||||
|
colors[0] = ThemeUtils.getColorFromAttr(getContext(), R.attr.colorAccent);
|
||||||
|
paintBars = new Paint();
|
||||||
|
paintBars.setStyle(Paint.Style.FILL);
|
||||||
|
paintBars.setAntiAlias(true);
|
||||||
|
paintGridLines = new Paint();
|
||||||
|
paintGridLines.setStyle(Paint.Style.STROKE);
|
||||||
|
paintGridLines.setPathEffect(new DashPathEffect(new float[] {10f, 10f}, 0f));
|
||||||
|
paintGridLines.setColor(ThemeUtils.getColorFromAttr(getContext(), android.R.attr.textColorSecondary));
|
||||||
|
paintGridText = new Paint();
|
||||||
|
paintGridText.setAntiAlias(true);
|
||||||
|
paintGridText.setColor(ThemeUtils.getColorFromAttr(getContext(), android.R.attr.textColorSecondary));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void draw(@NonNull Canvas canvas) {
|
||||||
|
final float width = getBounds().width();
|
||||||
|
final float height = getBounds().height();
|
||||||
|
final float barHeight = height * 0.9f;
|
||||||
|
final float textPadding = width * 0.05f;
|
||||||
|
final float stepSize = (width - textPadding) / (data.size() + 2);
|
||||||
|
final float textSize = height * 0.06f;
|
||||||
|
paintGridText.setTextSize(textSize);
|
||||||
|
|
||||||
|
paintBars.setStrokeWidth(height * 0.015f);
|
||||||
|
paintBars.setColor(colors[0]);
|
||||||
|
int colorIndex = 0;
|
||||||
|
int lastYear = data.size() > 0 ? data.get(0).year : 0;
|
||||||
|
for (int i = 0; i < data.size(); i++) {
|
||||||
|
float x = textPadding + (i + 1) * stepSize;
|
||||||
|
if (lastYear != data.get(i).year) {
|
||||||
|
lastYear = data.get(i).year;
|
||||||
|
colorIndex++;
|
||||||
|
paintBars.setColor(colors[colorIndex % 2]);
|
||||||
|
if (i < data.size() - 2) {
|
||||||
|
canvas.drawText(String.valueOf(data.get(i).year), x + stepSize,
|
||||||
|
barHeight + (height - barHeight + textSize) / 2, paintGridText);
|
||||||
|
}
|
||||||
|
canvas.drawLine(x, height, x, barHeight, paintGridText);
|
||||||
|
}
|
||||||
|
|
||||||
|
float valuePercentage = (float) Math.max(0.005, (float) data.get(i).timePlayed / maxValue);
|
||||||
|
float y = (1 - valuePercentage) * barHeight;
|
||||||
|
canvas.drawRect(x, y, x + stepSize * 0.95f, barHeight, paintBars);
|
||||||
|
}
|
||||||
|
|
||||||
|
float maxLine = (float) (Math.floor(maxValue / (10.0 * ONE_HOUR)) * 10 * ONE_HOUR);
|
||||||
|
float y = (1 - (maxLine / maxValue)) * barHeight;
|
||||||
|
canvas.drawLine(0, y, width, y, paintGridLines);
|
||||||
|
canvas.drawText(String.valueOf((long) maxLine / ONE_HOUR), 0, y + 1.2f * textSize, paintGridText);
|
||||||
|
|
||||||
|
float midLine = maxLine / 2;
|
||||||
|
y = (1 - (midLine / maxValue)) * barHeight;
|
||||||
|
canvas.drawLine(0, y, width, y, paintGridLines);
|
||||||
|
canvas.drawText(String.valueOf((long) midLine / ONE_HOUR), 0, y + 1.2f * textSize, paintGridText);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int getOpacity() {
|
||||||
|
return PixelFormat.TRANSLUCENT;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setAlpha(int alpha) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setColorFilter(ColorFilter cf) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,138 +0,0 @@
|
||||||
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) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -8,7 +8,6 @@ import android.widget.TextView;
|
||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
import androidx.recyclerview.widget.RecyclerView;
|
import androidx.recyclerview.widget.RecyclerView;
|
||||||
import de.danoeh.antennapod.core.storage.DBReader;
|
import de.danoeh.antennapod.core.storage.DBReader;
|
||||||
import de.danoeh.antennapod.core.util.LongList;
|
|
||||||
import de.danoeh.antennapod.ui.statistics.R;
|
import de.danoeh.antennapod.ui.statistics.R;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
@ -23,8 +22,8 @@ public class YearStatisticsListAdapter extends RecyclerView.Adapter<RecyclerView
|
||||||
private static final int TYPE_HEADER = 0;
|
private static final int TYPE_HEADER = 0;
|
||||||
private static final int TYPE_FEED = 1;
|
private static final int TYPE_FEED = 1;
|
||||||
final Context context;
|
final Context context;
|
||||||
private List<DBReader.MonthlyStatisticsItem> statisticsData = new ArrayList<>();
|
private final List<DBReader.MonthlyStatisticsItem> statisticsData = new ArrayList<>();
|
||||||
LineChartView.LineChartData lineChartData;
|
private final List<DBReader.MonthlyStatisticsItem> yearlyAggregate = new ArrayList<>();
|
||||||
|
|
||||||
public YearStatisticsListAdapter(Context context) {
|
public YearStatisticsListAdapter(Context context) {
|
||||||
this.context = context;
|
this.context = context;
|
||||||
|
@ -32,7 +31,7 @@ public class YearStatisticsListAdapter extends RecyclerView.Adapter<RecyclerView
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public int getItemCount() {
|
public int getItemCount() {
|
||||||
return statisticsData.size() + 1;
|
return yearlyAggregate.size() + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
@ -45,7 +44,7 @@ public class YearStatisticsListAdapter extends RecyclerView.Adapter<RecyclerView
|
||||||
public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
|
public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
|
||||||
LayoutInflater inflater = LayoutInflater.from(context);
|
LayoutInflater inflater = LayoutInflater.from(context);
|
||||||
if (viewType == TYPE_HEADER) {
|
if (viewType == TYPE_HEADER) {
|
||||||
return new HeaderHolder(inflater.inflate(R.layout.statistics_listitem_linechart, parent, false));
|
return new HeaderHolder(inflater.inflate(R.layout.statistics_listitem_barchart, parent, false));
|
||||||
}
|
}
|
||||||
return new StatisticsHolder(inflater.inflate(R.layout.statistics_year_listitem, parent, false));
|
return new StatisticsHolder(inflater.inflate(R.layout.statistics_year_listitem, parent, false));
|
||||||
}
|
}
|
||||||
|
@ -54,10 +53,10 @@ public class YearStatisticsListAdapter extends RecyclerView.Adapter<RecyclerView
|
||||||
public void onBindViewHolder(@NonNull RecyclerView.ViewHolder h, int position) {
|
public void onBindViewHolder(@NonNull RecyclerView.ViewHolder h, int position) {
|
||||||
if (getItemViewType(position) == TYPE_HEADER) {
|
if (getItemViewType(position) == TYPE_HEADER) {
|
||||||
HeaderHolder holder = (HeaderHolder) h;
|
HeaderHolder holder = (HeaderHolder) h;
|
||||||
holder.lineChart.setData(lineChartData);
|
holder.barChart.setData(statisticsData);
|
||||||
} else {
|
} else {
|
||||||
StatisticsHolder holder = (StatisticsHolder) h;
|
StatisticsHolder holder = (StatisticsHolder) h;
|
||||||
DBReader.MonthlyStatisticsItem statsItem = statisticsData.get(position - 1);
|
DBReader.MonthlyStatisticsItem statsItem = yearlyAggregate.get(position - 1);
|
||||||
holder.year.setText(String.format(Locale.getDefault(), "%d ", statsItem.year));
|
holder.year.setText(String.format(Locale.getDefault(), "%d ", statsItem.year));
|
||||||
holder.hours.setText(String.format(Locale.getDefault(), "%.1f ", statsItem.timePlayed / 3600000.0f)
|
holder.hours.setText(String.format(Locale.getDefault(), "%.1f ", statsItem.timePlayed / 3600000.0f)
|
||||||
+ context.getString(R.string.time_hours));
|
+ context.getString(R.string.time_hours));
|
||||||
|
@ -68,42 +67,44 @@ public class YearStatisticsListAdapter extends RecyclerView.Adapter<RecyclerView
|
||||||
int lastYear = statistics.size() > 0 ? statistics.get(0).year : 0;
|
int lastYear = statistics.size() > 0 ? statistics.get(0).year : 0;
|
||||||
int lastDataPoint = statistics.size() > 0 ? (statistics.get(0).month - 1) + lastYear * 12 : 0;
|
int lastDataPoint = statistics.size() > 0 ? (statistics.get(0).month - 1) + lastYear * 12 : 0;
|
||||||
long yearSum = 0;
|
long yearSum = 0;
|
||||||
|
yearlyAggregate.clear();
|
||||||
statisticsData.clear();
|
statisticsData.clear();
|
||||||
LongList lineChartValues = new LongList();
|
|
||||||
LongList lineChartHorizontalLines = new LongList();
|
|
||||||
for (DBReader.MonthlyStatisticsItem statistic : statistics) {
|
for (DBReader.MonthlyStatisticsItem statistic : statistics) {
|
||||||
if (statistic.year != lastYear) {
|
if (statistic.year != lastYear) {
|
||||||
DBReader.MonthlyStatisticsItem yearAggregate = new DBReader.MonthlyStatisticsItem();
|
DBReader.MonthlyStatisticsItem yearAggregate = new DBReader.MonthlyStatisticsItem();
|
||||||
yearAggregate.year = lastYear;
|
yearAggregate.year = lastYear;
|
||||||
yearAggregate.timePlayed = yearSum;
|
yearAggregate.timePlayed = yearSum;
|
||||||
statisticsData.add(yearAggregate);
|
yearlyAggregate.add(yearAggregate);
|
||||||
yearSum = 0;
|
yearSum = 0;
|
||||||
lastYear = statistic.year;
|
lastYear = statistic.year;
|
||||||
lineChartHorizontalLines.add(lineChartValues.size());
|
|
||||||
}
|
}
|
||||||
yearSum += statistic.timePlayed;
|
yearSum += statistic.timePlayed;
|
||||||
while (lastDataPoint + 1 < (statistic.month - 1) + statistic.year * 12) {
|
while (lastDataPoint + 1 < (statistic.month - 1) + statistic.year * 12) {
|
||||||
lineChartValues.add(0); // Compensate for months without playback
|
|
||||||
lastDataPoint++;
|
lastDataPoint++;
|
||||||
|
DBReader.MonthlyStatisticsItem item = new DBReader.MonthlyStatisticsItem();
|
||||||
|
item.year = lastDataPoint / 12;
|
||||||
|
item.month = lastDataPoint % 12 + 1;
|
||||||
|
statisticsData.add(item); // Compensate for months without playback
|
||||||
|
System.out.println("aaaaa extra:" + item.month + "/" + item.year);
|
||||||
}
|
}
|
||||||
lineChartValues.add(statistic.timePlayed);
|
System.out.println("aaaaa add:" + statistic.month + "/" + statistic.year);
|
||||||
|
statisticsData.add(statistic);
|
||||||
lastDataPoint = (statistic.month - 1) + statistic.year * 12;
|
lastDataPoint = (statistic.month - 1) + statistic.year * 12;
|
||||||
}
|
}
|
||||||
DBReader.MonthlyStatisticsItem yearAggregate = new DBReader.MonthlyStatisticsItem();
|
DBReader.MonthlyStatisticsItem yearAggregate = new DBReader.MonthlyStatisticsItem();
|
||||||
yearAggregate.year = lastYear;
|
yearAggregate.year = lastYear;
|
||||||
yearAggregate.timePlayed = yearSum;
|
yearAggregate.timePlayed = yearSum;
|
||||||
statisticsData.add(yearAggregate);
|
yearlyAggregate.add(yearAggregate);
|
||||||
Collections.reverse(statisticsData);
|
Collections.reverse(yearlyAggregate);
|
||||||
lineChartData = new LineChartView.LineChartData(lineChartValues.toArray(), lineChartHorizontalLines.toArray());
|
|
||||||
notifyDataSetChanged();
|
notifyDataSetChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
static class HeaderHolder extends RecyclerView.ViewHolder {
|
static class HeaderHolder extends RecyclerView.ViewHolder {
|
||||||
LineChartView lineChart;
|
BarChartView barChart;
|
||||||
|
|
||||||
HeaderHolder(View itemView) {
|
HeaderHolder(View itemView) {
|
||||||
super(itemView);
|
super(itemView);
|
||||||
lineChart = itemView.findViewById(R.id.lineChart);
|
barChart = itemView.findViewById(R.id.barChart);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -6,12 +6,10 @@
|
||||||
android:orientation="vertical"
|
android:orientation="vertical"
|
||||||
android:padding="16dp">
|
android:padding="16dp">
|
||||||
|
|
||||||
<de.danoeh.antennapod.ui.statistics.years.LineChartView
|
<de.danoeh.antennapod.ui.statistics.years.BarChartView
|
||||||
android:id="@+id/lineChart"
|
android:id="@+id/barChart"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="200dp"
|
android:layout_height="200dp" />
|
||||||
android:layout_marginStart="8dp"
|
|
||||||
android:layout_marginEnd="8dp" />
|
|
||||||
|
|
||||||
<View
|
<View
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
Loading…
Reference in New Issue