mirror of
https://codeberg.org/Freeyourgadget/Gadgetbridge.git
synced 2026-07-31 07:44:24 +02:00
Charts: Build fragment stats programatically
This would allow us to easily append further stats from device-specific charts providers.
This commit is contained in:
+7
-1
@@ -6,10 +6,16 @@ import nodomain.freeyourgadget.gadgetbridge.GBApplication
|
||||
import nodomain.freeyourgadget.gadgetbridge.R
|
||||
import nodomain.freeyourgadget.gadgetbridge.activities.charts.ActivityChartsActivity.UnknownFragment
|
||||
import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst
|
||||
import nodomain.freeyourgadget.gadgetbridge.activities.workouts.entries.ActivitySummaryEntry
|
||||
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice
|
||||
import java.util.Locale
|
||||
|
||||
object DefaultChartsProvider : DeviceChartsProvider {
|
||||
open class DefaultChartsProvider : DeviceChartsProvider {
|
||||
companion object {
|
||||
@JvmField
|
||||
val INSTANCE = DefaultChartsProvider()
|
||||
}
|
||||
|
||||
override fun getSupportedCharts(device: GBDevice): List<String> {
|
||||
val coordinator = device.deviceCoordinator
|
||||
val supportedCharts = mutableListOf<String>()
|
||||
|
||||
+23
-30
@@ -11,7 +11,6 @@ import android.widget.LinearLayout;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.gridlayout.widget.GridLayout;
|
||||
|
||||
import com.github.mikephil.charting.charts.Chart;
|
||||
import com.github.mikephil.charting.charts.LineChart;
|
||||
@@ -40,7 +39,6 @@ import java.util.Locale;
|
||||
import nodomain.freeyourgadget.gadgetbridge.GBApplication;
|
||||
import nodomain.freeyourgadget.gadgetbridge.R;
|
||||
import nodomain.freeyourgadget.gadgetbridge.activities.HeartRateUtils;
|
||||
import nodomain.freeyourgadget.gadgetbridge.activities.workouts.entries.ActivitySummaryEntry;
|
||||
import nodomain.freeyourgadget.gadgetbridge.activities.workouts.entries.ActivitySummarySimpleEntry;
|
||||
import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
|
||||
import nodomain.freeyourgadget.gadgetbridge.devices.SampleProvider;
|
||||
@@ -130,19 +128,19 @@ public class HeartRatePeriodFragment extends AbstractChartFragment<HeartRatePeri
|
||||
@Override
|
||||
protected void init() {
|
||||
Prefs prefs = GBApplication.getPrefs();
|
||||
CHART_TEXT_COLOR = GBApplication.getSecondaryTextColor(getContext());
|
||||
DESCRIPTION_COLOR = LEGEND_TEXT_COLOR = TEXT_COLOR = GBApplication.getTextColor(getContext());
|
||||
CHART_TEXT_COLOR = GBApplication.getSecondaryTextColor(requireContext());
|
||||
DESCRIPTION_COLOR = LEGEND_TEXT_COLOR = TEXT_COLOR = GBApplication.getTextColor(requireContext());
|
||||
if (prefs.getBoolean("chart_heartrate_color", false)) {
|
||||
HEARTRATE_COLOR = ContextCompat.getColor(getContext(), R.color.chart_heartrate_alternative);
|
||||
HEARTRATE_COLOR = ContextCompat.getColor(requireContext(), R.color.chart_heartrate_alternative);
|
||||
} else {
|
||||
HEARTRATE_COLOR = ContextCompat.getColor(getContext(), R.color.chart_heartrate);
|
||||
HEARTRATE_COLOR = ContextCompat.getColor(requireContext(), R.color.chart_heartrate);
|
||||
}
|
||||
HEARTRATE_MIN_COLOR = ContextCompat.getColor(getContext(), R.color.chart_heartrate_minimum);
|
||||
HEARTRATE_MAX_COLOR = ContextCompat.getColor(getContext(), R.color.chart_heartrate_maximum);
|
||||
HEARTRATE_RESTING_COLOR = ContextCompat.getColor(getContext(), R.color.chart_heartrate_resting);
|
||||
HEARTRATE_MIN_COLOR = ContextCompat.getColor(requireContext(), R.color.chart_heartrate_minimum);
|
||||
HEARTRATE_MAX_COLOR = ContextCompat.getColor(requireContext(), R.color.chart_heartrate_maximum);
|
||||
HEARTRATE_RESTING_COLOR = ContextCompat.getColor(requireContext(), R.color.chart_heartrate_resting);
|
||||
}
|
||||
|
||||
private HeartRateData fetchHeartRateDataForDay(ChartsHost chartsHost, DBHandler db, GBDevice device, int startTs) {
|
||||
private HeartRateData fetchHeartRateDataForDay(DBHandler db, GBDevice device, int startTs) {
|
||||
int endTs = startTs + SEC_PER_DAY - 1;
|
||||
List<? extends ActivitySample> samples = getActivitySamples(db, device, startTs, endTs);
|
||||
final HeartRateUtils heartRateUtilsInstance = HeartRateUtils.getInstance();
|
||||
@@ -180,7 +178,7 @@ public class HeartRatePeriodFragment extends AbstractChartFragment<HeartRatePeri
|
||||
|
||||
List<HeartRateData> result = new ArrayList<>();
|
||||
for (int i = 0; i < TOTAL_DAYS; i++) {
|
||||
HeartRateData dayData = fetchHeartRateDataForDay(chartsHost, db, device, startTs + i * SEC_PER_DAY);
|
||||
HeartRateData dayData = fetchHeartRateDataForDay(db, device, startTs + i * SEC_PER_DAY);
|
||||
result.add(dayData);
|
||||
}
|
||||
return new HeartRatePeriodData(result);
|
||||
@@ -293,36 +291,31 @@ public class HeartRatePeriodFragment extends AbstractChartFragment<HeartRatePeri
|
||||
private void setStatistics(int average, int minimum, int maximum, int resting) {
|
||||
hrStatsContainer.removeAllViews();
|
||||
|
||||
final List<Pair<String, ActivitySummaryEntry>> entries = new ArrayList<>();
|
||||
final GridTableBuilder builder = new GridTableBuilder(requireContext());
|
||||
|
||||
final ActivitySummarySimpleEntry empty = new ActivitySummarySimpleEntry("-", "string");
|
||||
|
||||
entries.add(Pair.of(
|
||||
builder.addEntry(
|
||||
getString(R.string.hr_minimum),
|
||||
minimum > 0 ? new ActivitySummarySimpleEntry(minimum, UNIT_BPM) : empty
|
||||
));
|
||||
minimum > 0 ? new ActivitySummarySimpleEntry(minimum, UNIT_BPM) : null
|
||||
);
|
||||
|
||||
entries.add(Pair.of(
|
||||
builder.addEntry(
|
||||
getString(R.string.hr_maximum),
|
||||
maximum > 0 ? new ActivitySummarySimpleEntry(maximum, UNIT_BPM) : empty
|
||||
));
|
||||
maximum > 0 ? new ActivitySummarySimpleEntry(maximum, UNIT_BPM) : null
|
||||
);
|
||||
|
||||
entries.add(Pair.of(
|
||||
builder.addEntry(
|
||||
getString(R.string.hr_average),
|
||||
average > 0 ? new ActivitySummarySimpleEntry(average, UNIT_BPM) : empty
|
||||
));
|
||||
average > 0 ? new ActivitySummarySimpleEntry(average, UNIT_BPM) : null
|
||||
);
|
||||
|
||||
if (supportsHeartRateRestingMeasurement()) {
|
||||
entries.add(Pair.of(
|
||||
builder.addEntry(
|
||||
getString(R.string.hr_resting),
|
||||
resting > 0 ? new ActivitySummarySimpleEntry(resting, UNIT_BPM) : empty
|
||||
));
|
||||
resting > 0 ? new ActivitySummarySimpleEntry(resting, UNIT_BPM) : null
|
||||
);
|
||||
}
|
||||
|
||||
// Build and add the grid
|
||||
final GridTableBuilder builder = new GridTableBuilder(requireContext());
|
||||
final GridLayout gridLayout = builder.buildGridLayout(entries);
|
||||
hrStatsContainer.addView(gridLayout);
|
||||
hrStatsContainer.addView(builder.build());
|
||||
|
||||
if (minimum > 0) {
|
||||
hrLineChart.getAxisLeft().setAxisMinimum(Math.max(minimum - 30, 0));
|
||||
|
||||
+32
-4
@@ -17,6 +17,8 @@
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>. */
|
||||
package nodomain.freeyourgadget.gadgetbridge.activities.charts;
|
||||
|
||||
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.UNIT_BPM;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.graphics.Color;
|
||||
@@ -56,6 +58,7 @@ import java.util.concurrent.TimeUnit;
|
||||
import nodomain.freeyourgadget.gadgetbridge.GBApplication;
|
||||
import nodomain.freeyourgadget.gadgetbridge.R;
|
||||
import nodomain.freeyourgadget.gadgetbridge.activities.HeartRateUtils;
|
||||
import nodomain.freeyourgadget.gadgetbridge.activities.workouts.entries.ActivitySummarySimpleEntry;
|
||||
import nodomain.freeyourgadget.gadgetbridge.activities.charts.SleepAnalysis.SleepSession;
|
||||
import nodomain.freeyourgadget.gadgetbridge.activities.charts.sleep.AbstractOverlayData;
|
||||
import nodomain.freeyourgadget.gadgetbridge.activities.charts.sleep.OverlayDataFloat;
|
||||
@@ -78,6 +81,7 @@ import nodomain.freeyourgadget.gadgetbridge.model.TemperatureSample;
|
||||
import nodomain.freeyourgadget.gadgetbridge.model.TimeSample;
|
||||
import nodomain.freeyourgadget.gadgetbridge.util.Accumulator;
|
||||
import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils;
|
||||
import nodomain.freeyourgadget.gadgetbridge.util.GridTableBuilder;
|
||||
import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
|
||||
|
||||
|
||||
@@ -420,10 +424,34 @@ public class SleepDailyFragment extends SleepFragment<SleepDailyFragment.MyChart
|
||||
int heartRateMax = mcd.getHeartRateAxisMax();
|
||||
int heartRateAvg = Math.round(mcd.getHeartRateAverage());
|
||||
float intensityTotal = mcd.getIntensityTotal();
|
||||
binding.sleepHrLowest.setText(String.valueOf(heartRateMin > 0 ? heartRateMin : "-"));
|
||||
binding.sleepHrHighest.setText(String.valueOf(heartRateMax > 0 ? heartRateMax : "-"));
|
||||
binding.sleepHrAverage.setText(String.valueOf(heartRateAvg > 0 ? heartRateAvg : "-"));
|
||||
binding.sleepMovementIntensity.setText(intensityTotal > 0 ? new DecimalFormat("###.#").format(intensityTotal) : "-");
|
||||
|
||||
// Build stats grid programmatically
|
||||
binding.sleepStatsContainer.removeAllViews();
|
||||
final GridTableBuilder statsBuilder = new GridTableBuilder(requireContext());
|
||||
|
||||
statsBuilder.addEntry(
|
||||
getString(R.string.minHR),
|
||||
heartRateMin > 0 ? new ActivitySummarySimpleEntry(heartRateMin, UNIT_BPM) : null
|
||||
);
|
||||
|
||||
statsBuilder.addEntry(
|
||||
getString(R.string.maxHR),
|
||||
heartRateMax > 0 ? new ActivitySummarySimpleEntry(heartRateMax, UNIT_BPM) : null
|
||||
);
|
||||
|
||||
statsBuilder.addEntry(
|
||||
getString(R.string.averageHR),
|
||||
heartRateAvg > 0 ? new ActivitySummarySimpleEntry(heartRateAvg, UNIT_BPM) : null
|
||||
);
|
||||
|
||||
if (intensityTotal > 0) {
|
||||
statsBuilder.addEntry(
|
||||
getString(R.string.movement_intensity),
|
||||
new ActivitySummarySimpleEntry(new DecimalFormat("###.#").format(intensityTotal), "string")
|
||||
);
|
||||
}
|
||||
|
||||
binding.sleepStatsContainer.addView(statsBuilder.build());
|
||||
|
||||
if (supportsHeartrate(getChartsHost().getDevice()) && SHOW_CHARTS_AVERAGE) {
|
||||
if (mcd.getHeartRateAxisMax() != 0 || mcd.getHeartRateAxisMin() != 0) {
|
||||
|
||||
+4
-5
@@ -329,11 +329,10 @@ class WorkoutDetailsFragment : Fragment(), MenuProvider {
|
||||
|
||||
private fun addGroupContent(entries: List<Pair<String, ActivitySummaryEntry>>) {
|
||||
val gridTableBuilder = GridTableBuilder(requireContext(), workoutValueFormatter)
|
||||
// Map keys from string resource names to actual human-readable labels
|
||||
val mappedLabels = entries.map { Pair.of(workoutValueFormatter.getStringResourceByName(it.key), it.value) }
|
||||
.toList()
|
||||
val gridLayout = gridTableBuilder.buildGridLayout(mappedLabels)
|
||||
binding.summaryDetails.addView(gridLayout)
|
||||
for ((key, entry) in entries) {
|
||||
gridTableBuilder.addEntry(workoutValueFormatter.getStringResourceByName(key), entry)
|
||||
}
|
||||
binding.summaryDetails.addView(gridTableBuilder.build())
|
||||
}
|
||||
|
||||
@Suppress("SameParameterValue")
|
||||
|
||||
+2
@@ -12,6 +12,8 @@ import nodomain.freeyourgadget.gadgetbridge.GBApplication;
|
||||
import nodomain.freeyourgadget.gadgetbridge.activities.workouts.WorkoutValueFormatter;
|
||||
|
||||
public class ActivitySummarySimpleEntry extends ActivitySummaryEntry {
|
||||
public static final ActivitySummarySimpleEntry EMPTY = new ActivitySummarySimpleEntry("-", "string");
|
||||
|
||||
private final Object value;
|
||||
private final String unit;
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import android.content.Context
|
||||
import android.view.Gravity
|
||||
import android.view.ViewGroup
|
||||
import android.widget.LinearLayout
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.isNotEmpty
|
||||
import androidx.gridlayout.widget.GridLayout
|
||||
import nodomain.freeyourgadget.gadgetbridge.GBApplication
|
||||
@@ -27,49 +28,66 @@ import nodomain.freeyourgadget.gadgetbridge.R
|
||||
import nodomain.freeyourgadget.gadgetbridge.activities.workouts.WorkoutValueFormatter
|
||||
import nodomain.freeyourgadget.gadgetbridge.activities.workouts.entries.ActivitySummaryEntry
|
||||
import nodomain.freeyourgadget.gadgetbridge.activities.workouts.entries.ActivitySummarySimpleEntry
|
||||
import org.apache.commons.lang3.tuple.Pair
|
||||
|
||||
class GridTableBuilder @JvmOverloads constructor(
|
||||
private val context: Context,
|
||||
private val workoutValueFormatter: WorkoutValueFormatter = WorkoutValueFormatter()
|
||||
) {
|
||||
fun buildGridLayout(entries: List<Pair<String, ActivitySummaryEntry>>): GridLayout {
|
||||
val gridLayout = GridLayout(context).apply {
|
||||
setBackgroundColor(resources.getColor(R.color.gauge_line_color))
|
||||
columnCount = 2
|
||||
layoutParams = ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
)
|
||||
private var cellNumber = 0
|
||||
private val columnSpans = mutableListOf<Int>()
|
||||
private val gridLayout = GridLayout(context).apply {
|
||||
setBackgroundColor(ContextCompat.getColor(context, R.color.gauge_line_color))
|
||||
columnCount = 2
|
||||
layoutParams = ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
)
|
||||
}
|
||||
|
||||
fun addEntry(key: String, entry: ActivitySummaryEntry?) {
|
||||
val entry = entry ?: ActivitySummarySimpleEntry.EMPTY
|
||||
|
||||
val columnSpan = entry.columnSpan
|
||||
if (columnSpan == 2 && cellNumber % 2 != 0) {
|
||||
cellNumber++
|
||||
}
|
||||
|
||||
val totalCells = entries.sumOf { it.right.columnSpan }.let { it + (it and 1) }
|
||||
var cellNumber = 0
|
||||
val linearLayout = generateLinearLayout(cellNumber, columnSpan)
|
||||
entry.populate(key, linearLayout, workoutValueFormatter)
|
||||
gridLayout.addView(linearLayout)
|
||||
columnSpans.add(columnSpan)
|
||||
cellNumber += columnSpan
|
||||
}
|
||||
|
||||
for ((key, entry) in entries) {
|
||||
val columnSpan = entry.columnSpan
|
||||
if (columnSpan == 2 && cellNumber % 2 != 0) {
|
||||
cellNumber++
|
||||
}
|
||||
|
||||
val linearLayout = generateLinearLayout(cellNumber, cellNumber + 2 >= totalCells, columnSpan)
|
||||
entry.populate(key, linearLayout, workoutValueFormatter)
|
||||
gridLayout.addView(linearLayout)
|
||||
cellNumber += columnSpan
|
||||
fun build(): GridLayout {
|
||||
if (gridLayout.isNotEmpty() && cellNumber % 2 != 0) {
|
||||
// When in an odd number of cells, add an empty one to prevent a gray hole from showing up
|
||||
val emptyLayout = generateLinearLayout(cellNumber, 1)
|
||||
ActivitySummarySimpleEntry("", "string").populate("", emptyLayout, workoutValueFormatter)
|
||||
gridLayout.addView(emptyLayout)
|
||||
columnSpans.add(1)
|
||||
}
|
||||
|
||||
// Then, adjust the bottom margin for the last row
|
||||
var adjustedColumns = 0
|
||||
if (gridLayout.isNotEmpty()) {
|
||||
if (cellNumber % 2 != 0) {
|
||||
val emptyLayout = generateLinearLayout(cellNumber, true, 1)
|
||||
ActivitySummarySimpleEntry(null, "", "string").populate("", emptyLayout, workoutValueFormatter)
|
||||
gridLayout.addView(emptyLayout)
|
||||
for (i in gridLayout.childCount - 1 downTo 0) {
|
||||
val layoutParams = gridLayout.getChildAt(i).layoutParams
|
||||
if (layoutParams is GridLayout.LayoutParams) {
|
||||
layoutParams.bottomMargin = dpToPx(2)
|
||||
adjustedColumns += columnSpans[i]
|
||||
|
||||
if (adjustedColumns >= gridLayout.columnCount) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return gridLayout
|
||||
}
|
||||
|
||||
private fun generateLinearLayout(i: Int, lastRow: Boolean, columnSize: Int): LinearLayout {
|
||||
private fun generateLinearLayout(i: Int, columnSize: Int): LinearLayout {
|
||||
return LinearLayout(context).apply {
|
||||
val layoutParams = GridLayout.LayoutParams(
|
||||
GridLayout.spec(GridLayout.UNDEFINED, GridLayout.FILL, 1f),
|
||||
@@ -85,7 +103,7 @@ class GridTableBuilder @JvmOverloads constructor(
|
||||
val marginLeft = if (i % 2 == 0) 0 else 1
|
||||
val marginRight = if (i % 2 == 0) 1 else 0
|
||||
val marginTop = 2
|
||||
val marginBottom = if (lastRow) 2 else 0
|
||||
val marginBottom = 0 // will be changed to 2 for the last row
|
||||
|
||||
layoutParams.setMargins(dpToPx(marginLeft), dpToPx(marginTop), dpToPx(marginRight), dpToPx(marginBottom))
|
||||
}
|
||||
|
||||
@@ -192,93 +192,12 @@
|
||||
android:layout_marginEnd="10dp"
|
||||
android:layout_marginBottom="10dp"/>
|
||||
|
||||
<GridLayout
|
||||
android:background="@color/gauge_line_color"
|
||||
<LinearLayout
|
||||
android:id="@+id/sleep_stats_container"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:columnCount="2"
|
||||
android:rowCount="2"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="15dp"
|
||||
>
|
||||
<LinearLayout
|
||||
android:layout_marginTop="2dp"
|
||||
android:layout_marginEnd="1dp"
|
||||
style="@style/GridTile"
|
||||
>
|
||||
<TextView
|
||||
android:id="@+id/sleep_hr_lowest"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/stats_empty_value"
|
||||
android:textColor="?attr/textColorPrimary"
|
||||
android:textSize="20sp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/minHR"
|
||||
android:textSize="12sp" />
|
||||
</LinearLayout>
|
||||
<LinearLayout
|
||||
android:layout_marginTop="2dp"
|
||||
android:layout_marginStart="1dp"
|
||||
style="@style/GridTile"
|
||||
>
|
||||
<TextView
|
||||
android:id="@+id/sleep_hr_highest"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/stats_empty_value"
|
||||
android:textColor="?attr/textColorPrimary"
|
||||
android:textSize="20sp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/maxHR"
|
||||
android:textSize="12sp" />
|
||||
</LinearLayout>
|
||||
<LinearLayout
|
||||
android:layout_marginBottom="2dp"
|
||||
android:layout_marginEnd="1dp"
|
||||
style="@style/GridTile"
|
||||
>
|
||||
<TextView
|
||||
android:id="@+id/sleep_hr_average"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/stats_empty_value"
|
||||
android:textColor="?attr/textColorPrimary"
|
||||
android:textSize="20sp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/averageHR"
|
||||
android:textSize="12sp" />
|
||||
</LinearLayout>
|
||||
<LinearLayout
|
||||
android:id="@+id/sleep_chart_legend_movement_intensity_wrapper"
|
||||
android:layout_marginBottom="2dp"
|
||||
android:layout_marginEnd="1dp"
|
||||
style="@style/GridTile"
|
||||
>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/sleep_movement_intensity"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/stats_empty_value"
|
||||
android:textColor="?attr/textColorPrimary"
|
||||
android:textSize="20sp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/movement_intensity"
|
||||
android:textSize="12sp" />
|
||||
</LinearLayout>
|
||||
</GridLayout>
|
||||
android:orientation="vertical"/>
|
||||
|
||||
<com.github.mikephil.charting.charts.LineChart
|
||||
android:id="@+id/sleepchart"
|
||||
|
||||
Reference in New Issue
Block a user