mirror of
https://github.com/rt-bishop/Look4Sat.git
synced 2026-10-02 03:15:37 +00:00
Now showing the active transmitters of the selected satellite in the RadarView recycler
This commit is contained in:
1 parent
52b23bf628
commit
b44ff7272f
9 files changed
+283
-43
No files matched your search
@@ -17,6 +17,7 @@ import com.google.android.gms.location.LocationServices
|
||||
import com.rtbishop.lookingsat.repo.Repository
|
||||
import com.rtbishop.lookingsat.repo.SatPass
|
||||
import com.rtbishop.lookingsat.repo.SatPassPrefs
|
||||
import com.rtbishop.lookingsat.repo.Transmitter
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -150,6 +151,10 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getTransmittersForSat(id: Int): List<Transmitter> {
|
||||
return repository.getTransmittersForSat(id)
|
||||
}
|
||||
}
|
||||
|
||||
fun SharedPreferences.Editor.putDouble(key: String, double: Double)
|
||||
|
||||
@@ -9,10 +9,15 @@ import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.TextView
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.github.amsacode.predict4java.SatPos
|
||||
import com.rtbishop.lookingsat.MainViewModel
|
||||
import com.rtbishop.lookingsat.R
|
||||
import com.rtbishop.lookingsat.repo.SatPass
|
||||
import kotlinx.coroutines.launch
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
import java.util.concurrent.Executors
|
||||
@@ -25,14 +30,22 @@ class RadarFragment : Fragment() {
|
||||
private val delay = 5000L
|
||||
private val service = Executors.newSingleThreadScheduledExecutor()
|
||||
|
||||
private lateinit var viewModel: MainViewModel
|
||||
private lateinit var satPass: SatPass
|
||||
private lateinit var radarView: RadarView
|
||||
private lateinit var radarSkyFrame: FrameLayout
|
||||
private lateinit var radarRecycler: RecyclerView
|
||||
private lateinit var transRecycler: RecyclerView
|
||||
private lateinit var transAdapter: TransAdapter
|
||||
private lateinit var radarSatName: TextView
|
||||
private lateinit var radarMaxEl: TextView
|
||||
private lateinit var radarAos: TextView
|
||||
private lateinit var radarLos: TextView
|
||||
private lateinit var transNoFound: TextView
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
viewModel = ViewModelProvider(activity as MainActivity).get(MainViewModel::class.java)
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
@@ -46,13 +59,36 @@ class RadarFragment : Fragment() {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
satPass = arguments?.get("satPass") as SatPass
|
||||
radarSkyFrame = view.findViewById(R.id.radar_sky_frame)
|
||||
radarRecycler = view.findViewById(R.id.radar_recycler)
|
||||
|
||||
transRecycler = view.findViewById(R.id.radar_recycler)
|
||||
radarSatName = view.findViewById(R.id.radar_sat_name)
|
||||
radarMaxEl = view.findViewById(R.id.radar_maxEl)
|
||||
radarAos = view.findViewById(R.id.radar_aos)
|
||||
radarLos = view.findViewById(R.id.radar_los)
|
||||
transNoFound = view.findViewById(R.id.radar_trans_no_found)
|
||||
|
||||
setupRadarView()
|
||||
setupTransRecycler()
|
||||
}
|
||||
|
||||
private fun setupTransRecycler() {
|
||||
transAdapter = TransAdapter()
|
||||
transRecycler.apply {
|
||||
layoutManager = LinearLayoutManager(activity as MainActivity)
|
||||
adapter = transAdapter
|
||||
}
|
||||
lifecycleScope.launch {
|
||||
val transList = viewModel.getTransmittersForSat(satPass.tle.catnum)
|
||||
if (transList.isNotEmpty()) {
|
||||
transAdapter.setList(transList)
|
||||
transAdapter.notifyDataSetChanged()
|
||||
} else {
|
||||
transRecycler.visibility = View.INVISIBLE
|
||||
transNoFound.visibility = View.VISIBLE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupRadarView() {
|
||||
radarView = RadarView(activity as MainActivity)
|
||||
radarSkyFrame.addView(radarView)
|
||||
|
||||
|
||||
@@ -12,7 +12,9 @@ import com.rtbishop.lookingsat.repo.SatPass
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
class SatPassAdapter : RecyclerView.Adapter<SatPassAdapter.ViewHolder>() {
|
||||
class SatPassAdapter : RecyclerView.Adapter<SatPassAdapter.PassHolder>() {
|
||||
|
||||
inner class PassHolder(itemView: View) : RecyclerView.ViewHolder(itemView)
|
||||
|
||||
private var satPassList = emptyList<SatPass>()
|
||||
|
||||
@@ -24,14 +26,14 @@ class SatPassAdapter : RecyclerView.Adapter<SatPassAdapter.ViewHolder>() {
|
||||
return satPassList.size
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PassHolder {
|
||||
val itemView = LayoutInflater
|
||||
.from(parent.context)
|
||||
.inflate(R.layout.sat_pass_card, parent, false)
|
||||
return ViewHolder(itemView)
|
||||
return PassHolder(itemView)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
||||
override fun onBindViewHolder(holder: PassHolder, position: Int) {
|
||||
val satPass = satPassList[position]
|
||||
|
||||
val satName = holder.itemView.findViewById<TextView>(R.id.card_sat_name)
|
||||
@@ -56,6 +58,4 @@ class SatPassAdapter : RecyclerView.Adapter<SatPassAdapter.ViewHolder>() {
|
||||
holder.itemView.findNavController().navigate(R.id.action_nav_sky_to_nav_radar, bundle)
|
||||
}
|
||||
}
|
||||
|
||||
inner class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView)
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package com.rtbishop.lookingsat.ui
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.TextView
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.rtbishop.lookingsat.R
|
||||
import com.rtbishop.lookingsat.repo.Transmitter
|
||||
|
||||
class TransAdapter : RecyclerView.Adapter<TransAdapter.TransHolder>() {
|
||||
|
||||
inner class TransHolder(itemView: View) : RecyclerView.ViewHolder(itemView)
|
||||
|
||||
private var transList = emptyList<Transmitter>()
|
||||
|
||||
fun setList(list: List<Transmitter>) {
|
||||
transList = list
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int {
|
||||
return transList.size
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TransHolder {
|
||||
val itemView = LayoutInflater
|
||||
.from(parent.context)
|
||||
.inflate(R.layout.card_transmitter, parent, false)
|
||||
return TransHolder(itemView)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: TransHolder, position: Int) {
|
||||
val context = holder.itemView.context
|
||||
val freqDivider = 1000000f
|
||||
val transmitter = transList[position]
|
||||
val description = holder.itemView.findViewById<TextView>(R.id.trans_description)
|
||||
val downlink = holder.itemView.findViewById<TextView>(R.id.trans_downlink)
|
||||
val uplink = holder.itemView.findViewById<TextView>(R.id.trans_uplink)
|
||||
val mode = holder.itemView.findViewById<TextView>(R.id.trans_mode)
|
||||
val inverted = holder.itemView.findViewById<TextView>(R.id.trans_inverted)
|
||||
|
||||
description.text = transmitter.description
|
||||
|
||||
if (transmitter.downlinkLow != null && transmitter.downlinkHigh == null) {
|
||||
downlink.text = String.format(
|
||||
context.getString(R.string.pattern_trans_downlink_low),
|
||||
transmitter.downlinkLow / freqDivider
|
||||
)
|
||||
} else if (transmitter.downlinkLow != null && transmitter.downlinkHigh != null) {
|
||||
downlink.text = String.format(
|
||||
context.getString(R.string.pattern_trans_downlink_lowHigh),
|
||||
transmitter.downlinkLow / freqDivider,
|
||||
transmitter.downlinkHigh / freqDivider
|
||||
)
|
||||
} else {
|
||||
downlink.text = context.getString(R.string.no_downlink)
|
||||
}
|
||||
|
||||
if (transmitter.uplinkLow != null && transmitter.uplinkHigh == null) {
|
||||
uplink.text = String.format(
|
||||
context.getString(R.string.pattern_trans_uplink_low),
|
||||
transmitter.uplinkLow / freqDivider
|
||||
)
|
||||
} else if (transmitter.uplinkLow != null && transmitter.uplinkHigh != null) {
|
||||
uplink.text = String.format(
|
||||
context.getString(R.string.pattern_trans_uplink_lowHigh),
|
||||
transmitter.uplinkLow / freqDivider,
|
||||
transmitter.uplinkHigh / freqDivider
|
||||
)
|
||||
} else {
|
||||
uplink.text = context.getString(R.string.no_uplink)
|
||||
}
|
||||
|
||||
if (transmitter.mode != null) {
|
||||
mode.text =
|
||||
String.format(
|
||||
context.getString(R.string.pattern_trans_mode),
|
||||
transmitter.mode
|
||||
)
|
||||
} else {
|
||||
mode.text = context.getString(R.string.no_mode)
|
||||
}
|
||||
if (transmitter.isInverted) {
|
||||
inverted.text = String.format(
|
||||
context.getString(R.string.pattern_trans_inverted),
|
||||
context.getString(R.string.yes)
|
||||
)
|
||||
} else {
|
||||
inverted.text = String.format(
|
||||
context.getString(R.string.pattern_trans_inverted),
|
||||
context.getString(R.string.no)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:id="@+id/card_trans"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="4dp"
|
||||
android:layout_marginTop="4dp"
|
||||
android:layout_marginEnd="4dp"
|
||||
app:cardElevation="4dp">
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/trans_description"
|
||||
style="@style/CardTransText"
|
||||
android:layout_marginStart="8dp"
|
||||
android:layout_marginTop="4dp"
|
||||
android:text="@string/trans_description"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/trans_downlink"
|
||||
style="@style/CardTransText"
|
||||
android:text="@string/trans_downlink"
|
||||
app:layout_constraintStart_toStartOf="@+id/trans_description"
|
||||
app:layout_constraintTop_toBottomOf="@+id/trans_description" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/trans_uplink"
|
||||
style="@style/CardTransText"
|
||||
android:text="@string/trans_uplink"
|
||||
app:layout_constraintStart_toStartOf="@+id/trans_description"
|
||||
app:layout_constraintTop_toBottomOf="@+id/trans_downlink" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/trans_mode"
|
||||
style="@style/CardTransText"
|
||||
android:layout_marginBottom="4dp"
|
||||
android:text="@string/trans_mode"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintStart_toStartOf="@+id/trans_description"
|
||||
app:layout_constraintTop_toBottomOf="@+id/trans_uplink" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/trans_inverted"
|
||||
style="@style/CardTransText"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:text="@string/trans_inverted"
|
||||
app:layout_constraintBaseline_toBaselineOf="@+id/trans_mode"
|
||||
app:layout_constraintEnd_toEndOf="parent" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
@@ -17,29 +17,16 @@
|
||||
|
||||
</FrameLayout>
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/radar_recycler"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/radar_sky_frame" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/radar_sat_name"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
style="@style/RadarText"
|
||||
android:text="@string/card_sat_name"
|
||||
app:layout_constraintEnd_toEndOf="@+id/radar_aos"
|
||||
app:layout_constraintStart_toStartOf="@+id/radar_aos"
|
||||
app:layout_constraintTop_toTopOf="@+id/radar_maxEl" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/radar_maxEl"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
style="@style/RadarText"
|
||||
android:layout_marginEnd="4dp"
|
||||
android:text="@string/card_max_el"
|
||||
app:layout_constraintEnd_toEndOf="@+id/radar_sky_frame"
|
||||
@@ -47,21 +34,45 @@
|
||||
|
||||
<TextView
|
||||
android:id="@+id/radar_aos"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
style="@style/RadarText"
|
||||
android:layout_marginStart="4dp"
|
||||
android:layout_marginBottom="4dp"
|
||||
android:text="@string/card_pass_aos_time"
|
||||
app:layout_constraintBottom_toBottomOf="@+id/radar_sky_frame"
|
||||
app:layout_constraintStart_toStartOf="@+id/radar_sky_frame" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/radar_los"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
style="@style/RadarText"
|
||||
android:layout_marginEnd="4dp"
|
||||
android:layout_marginBottom="4dp"
|
||||
android:text="@string/card_pass_los_time"
|
||||
app:layout_constraintBottom_toBottomOf="@+id/radar_sky_frame"
|
||||
app:layout_constraintEnd_toEndOf="@+id/radar_sky_frame" />
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/radar_recycler"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/radar_trans_header" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/radar_trans_header"
|
||||
style="@style/RadarText"
|
||||
android:text="@string/trans_header"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintHorizontal_bias="0.5"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/radar_sky_frame" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/radar_trans_no_found"
|
||||
style="@style/RadarText"
|
||||
android:text="@string/no_trans"
|
||||
android:visibility="invisible"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintHorizontal_bias="0.5"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/radar_trans_header" />
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
@@ -3,11 +3,10 @@
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:id="@+id/card_view"
|
||||
style="@style/CardSatPass"
|
||||
android:layout_height="104dp"
|
||||
android:layout_marginStart="4dp"
|
||||
android:layout_marginTop="4dp"
|
||||
android:layout_marginEnd="4dp"
|
||||
app:cardElevation="8dp">
|
||||
app:cardElevation="4dp">
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:layout_width="match_parent"
|
||||
@@ -29,10 +28,10 @@
|
||||
style="@style/CardSatPass.SideText"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:layout_marginTop="8dp"
|
||||
android:layout_marginTop="4dp"
|
||||
android:text="@string/card_sat_name"
|
||||
app:layout_constraintBottom_toTopOf="@+id/card_pass_vector"
|
||||
app:layout_constraintEnd_toEndOf="@+id/card_pass_vector"
|
||||
app:layout_constraintEnd_toStartOf="@+id/card_max_el"
|
||||
app:layout_constraintStart_toStartOf="@+id/card_pass_vector"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
@@ -40,12 +39,12 @@
|
||||
android:id="@+id/card_pass_vector"
|
||||
style="@style/CardSatPass.SideText"
|
||||
android:layout_width="0dp"
|
||||
android:layout_marginStart="8dp"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:layout_marginStart="4dp"
|
||||
android:layout_marginBottom="4dp"
|
||||
android:text="@string/card_pass_vector"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toStartOf="@+id/card_max_el"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/card_max_el" />
|
||||
app:layout_constraintStart_toStartOf="parent" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/card_max_el"
|
||||
@@ -59,17 +58,20 @@
|
||||
<TextView
|
||||
android:id="@+id/card_pass_start"
|
||||
style="@style/CardSatPass.SideText"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:layout_marginTop="4dp"
|
||||
android:layout_marginEnd="4dp"
|
||||
android:text="@string/card_pass_aos_time"
|
||||
app:layout_constraintBottom_toTopOf="@+id/card_max_el"
|
||||
app:layout_constraintEnd_toEndOf="parent" />
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintTop_toTopOf="@+id/imageView" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/card_pass_end"
|
||||
style="@style/CardSatPass.SideText"
|
||||
android:layout_marginEnd="4dp"
|
||||
android:layout_marginBottom="4dp"
|
||||
android:text="@string/card_pass_los_time"
|
||||
app:layout_constraintStart_toStartOf="@+id/card_pass_start"
|
||||
app:layout_constraintTop_toBottomOf="@+id/card_max_el" />
|
||||
app:layout_constraintBottom_toBottomOf="@+id/imageView"
|
||||
app:layout_constraintEnd_toEndOf="parent" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
@@ -39,7 +39,29 @@
|
||||
<string name="pass_pref_tv_maxEl">Show passes with max elevation above</string>
|
||||
<string name="pass_pref_hint_maxEl">Max elevation</string>
|
||||
|
||||
<string name="no">No</string>
|
||||
<string name="yes">Yes</string>
|
||||
|
||||
<string name="no_mode">Mode: Null</string>
|
||||
<string name="no_uplink">Uplink: Null</string>
|
||||
<string name="no_downlink">Downlink: Null</string>
|
||||
<string name="no_trans">No transmitters found for this Satellite</string>
|
||||
|
||||
<string name="trans_description">ARGOS-3 A-DCS 465.9875MHz</string>
|
||||
<string name="trans_downlink">Downlink: 465.9875 MHz</string>
|
||||
<string name="trans_uplink">Uplink: 465.9875 MHz</string>
|
||||
<string name="trans_mode">Mode: BPSK400</string>
|
||||
<string name="trans_inverted">Inverted: Yes</string>
|
||||
<string name="trans_header">Transmitters:</string>
|
||||
|
||||
<string name="pattern_aos">AOS -%02d:%02d:%02d</string>
|
||||
<string name="pattern_trans_mode">Mode: %s</string>
|
||||
<string name="pattern_trans_inverted">Inverted: %s</string>
|
||||
<string name="pattern_trans_uplink_low">Uplink: %.4f MHz</string>
|
||||
<string name="pattern_trans_uplink_lowHigh">Uplink: %.4f - %.4f MHz</string>
|
||||
<string name="pattern_trans_downlink_low">Downlink: %.4f MHz</string>
|
||||
<string name="pattern_trans_downlink_lowHigh">Downlink: %.4f - %.4f MHz</string>
|
||||
|
||||
<string name="map_content">Nasa Map</string>
|
||||
<string name="placeholder">Placeholder</string>
|
||||
</resources>
|
||||
@@ -85,4 +85,16 @@
|
||||
<item name="android:textStyle">bold</item>
|
||||
</style>
|
||||
|
||||
<style name="RadarText">
|
||||
<item name="android:layout_width">wrap_content</item>
|
||||
<item name="android:layout_height">wrap_content</item>
|
||||
<item name="android:textSize">14sp</item>
|
||||
</style>
|
||||
|
||||
<style name="CardTransText">
|
||||
<item name="android:layout_width">wrap_content</item>
|
||||
<item name="android:layout_height">wrap_content</item>
|
||||
<item name="android:textSize">16sp</item>
|
||||
</style>
|
||||
|
||||
</resources>
|
||||
Reference in new issue
Block a user