Compare commits
6 Commits
55f0bec0b4
...
1cb22bf4a0
Author | SHA1 | Date |
---|---|---|
Danila Fedorin | 1cb22bf4a0 | |
Danila Fedorin | 9ab9377201 | |
Danila Fedorin | b48317c0c0 | |
Danila Fedorin | ce0096d94f | |
Danila Fedorin | 5d0ec6738d | |
Danila Fedorin | b18890851c |
|
@ -4,6 +4,17 @@ apply plugin: 'kotlin-android'
|
|||
|
||||
apply plugin: 'kotlin-android-extensions'
|
||||
|
||||
buildscript {
|
||||
repositories {
|
||||
jcenter()
|
||||
}
|
||||
dependencies {
|
||||
classpath "org.jetbrains.dokka:dokka-gradle-plugin:0.10.1"
|
||||
}
|
||||
}
|
||||
|
||||
apply plugin: 'org.jetbrains.dokka'
|
||||
|
||||
android {
|
||||
compileSdkVersion 29
|
||||
buildToolsVersion "29.0.2"
|
||||
|
@ -23,6 +34,15 @@ android {
|
|||
}
|
||||
}
|
||||
|
||||
dokka {
|
||||
outputFormat = 'html'
|
||||
outputDirectory = "$buildDir/dokka"
|
||||
|
||||
configuration {
|
||||
includeNonPublic = true
|
||||
}
|
||||
}
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
google()
|
||||
|
|
|
@ -2,6 +2,16 @@ package com.danilafe.fencelessgrazing
|
|||
|
||||
import com.danilafe.fencelessgrazing.model.CollarSummary
|
||||
|
||||
/**
|
||||
* Simple interface used for the [CollarSummaryAdapter] to
|
||||
* allow items in the collar list to respond to click events.
|
||||
*/
|
||||
interface CollarClickListener {
|
||||
|
||||
/**
|
||||
* Method called when a collar is clicked in the [CollarSummaryAdapter].
|
||||
*
|
||||
* @param collar the collar that was clicked.
|
||||
*/
|
||||
fun onCollarClick(collar: CollarSummary?)
|
||||
}
|
|
@ -8,7 +8,6 @@ import android.widget.Toast
|
|||
import com.android.volley.RequestQueue
|
||||
import com.android.volley.Response
|
||||
import com.android.volley.toolbox.Volley
|
||||
import com.danilafe.fencelessgrazing.model.CollarPos
|
||||
import com.danilafe.fencelessgrazing.model.Polygon
|
||||
import com.danilafe.fencelessgrazing.requests.CollarDetailRequest
|
||||
import com.danilafe.fencelessgrazing.requests.CollarHistoryRequest
|
||||
|
@ -19,22 +18,76 @@ import org.osmdroid.views.overlay.Polyline
|
|||
import java.util.*
|
||||
import kotlin.concurrent.timerTask
|
||||
|
||||
/**
|
||||
* The activity that handles the display of a single collar, including
|
||||
* the map with the collar's location, the collar's name, and how many
|
||||
* times the collar left the prescribed grazing area in the last day. This
|
||||
* information is gathered from repeated [history][CollarHistoryRequest] and
|
||||
* [details][CollarDetailRequest] requests.
|
||||
*/
|
||||
class CollarDetailActivity : AppCompatActivity() {
|
||||
|
||||
/**
|
||||
* The [TextView] holding the collar's current name.
|
||||
*/
|
||||
private lateinit var collarName: TextView
|
||||
|
||||
/**
|
||||
* The [TextView] holding the collar's current position.
|
||||
*/
|
||||
private lateinit var collarPos: TextView
|
||||
|
||||
/**
|
||||
* The [TextView] holding the number of times the collar left the grazing area.
|
||||
*/
|
||||
private lateinit var collarStimulus: TextView
|
||||
|
||||
/** Cached value of the API token retrieved from shared preferences.
|
||||
*/
|
||||
private lateinit var token: String
|
||||
|
||||
/**
|
||||
* Volley queue used for sending requests to the API.
|
||||
*/
|
||||
private lateinit var queue: RequestQueue
|
||||
|
||||
/**
|
||||
* The identifier of the collar whose details are being viewed. This is
|
||||
* provided by the activity's [Intent][android.content.Intent].
|
||||
*/
|
||||
private var collarId: Int = -1
|
||||
|
||||
/**
|
||||
* Whether or not the map has been centered on the location of the collar.
|
||||
* Rather than have the map constantly jump around following the collar as it moves, we only
|
||||
* move the map's center when this variable is `false`.
|
||||
*/
|
||||
private var centerSet: Boolean = false
|
||||
|
||||
/**
|
||||
* The map view that actually handles the display of the OpenStreetMaps tiles and markers.
|
||||
*/
|
||||
private lateinit var map: MapView
|
||||
|
||||
/**
|
||||
* The timer used for scheduling repeated API calls. The timer is started
|
||||
* in [onResume], and stopped in [onPause].
|
||||
*/
|
||||
private var refreshTimer = Timer()
|
||||
|
||||
/**
|
||||
* The OpenStreetMaps polygon to be drawn on the map. This polygon is used
|
||||
* to display the valid grazing area for the collar; its vertices
|
||||
* are updated whenever the [boundingBox] property is changed.
|
||||
*/
|
||||
private lateinit var mapPolygon: org.osmdroid.views.overlay.Polygon
|
||||
|
||||
/**
|
||||
* The [Polygon] that represents the collar's valid grazing area.
|
||||
* Setting this property will also update the [mapPolygon], which
|
||||
* is the visual representation of the grazing area on the map.
|
||||
* Additionally, setting this property will also update the [map].
|
||||
*/
|
||||
private var boundingBox: Polygon? = null
|
||||
set(newValue) {
|
||||
if(newValue != null) updateBoundingBox(field, newValue)
|
||||
|
@ -42,8 +95,24 @@ class CollarDetailActivity : AppCompatActivity() {
|
|||
field = newValue
|
||||
}
|
||||
|
||||
/**
|
||||
* The OpenStreetMaps marker used to display the collar's current location
|
||||
* on the map. This marker is updated whenever the [dataPoints] property is set.
|
||||
*/
|
||||
private lateinit var mapMarker: Marker
|
||||
|
||||
/**
|
||||
* The OpenStreetMaps line used to display the path the collar has taken thus fur.
|
||||
* This line is updated whenever the [dataPoints] property is set.
|
||||
*/
|
||||
private lateinit var mapPolyline: Polyline
|
||||
|
||||
/**
|
||||
* The full history of the collar's location. Setting this property
|
||||
* updates the [mapMarker] and [mapPolyline] properties, which
|
||||
* are used to display the collar and its location history, respectively, on the map.
|
||||
* Additionally, setting this property will also update the [map].
|
||||
*/
|
||||
private var dataPoints: List<GeoPoint> = listOf()
|
||||
set(newValue) {
|
||||
if(newValue.isNotEmpty()) updateAnimalHistory(newValue)
|
||||
|
@ -55,9 +124,7 @@ class CollarDetailActivity : AppCompatActivity() {
|
|||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_collar_detail)
|
||||
|
||||
collarName = findViewById(R.id.collarName)
|
||||
collarPos = findViewById(R.id.collarPos)
|
||||
collarStimulus = findViewById(R.id.collarStimulus)
|
||||
findViews()
|
||||
|
||||
token = getSharedPreferences("FencelessGrazing", 0).getString("token", null)!!
|
||||
collarId = intent.getIntExtra("identifier", -1)
|
||||
|
@ -69,15 +136,6 @@ class CollarDetailActivity : AppCompatActivity() {
|
|||
mapMarker = Marker(map)
|
||||
mapPolyline = Polyline(map)
|
||||
configureMapPolygon()
|
||||
|
||||
boundingBox = Polygon(
|
||||
listOf(
|
||||
CollarPos("20", "20"),
|
||||
CollarPos("20", "21"),
|
||||
CollarPos("21", "21"),
|
||||
CollarPos("21", "20")
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
|
@ -94,6 +152,19 @@ class CollarDetailActivity : AppCompatActivity() {
|
|||
refreshTimer.schedule(timerTask { triggerRefresh() }, 0L, 5000L)
|
||||
}
|
||||
|
||||
/**
|
||||
* Locates all the views in the activity, setting the respective
|
||||
* `lateinit` properties.
|
||||
*/
|
||||
private fun findViews() {
|
||||
collarName = findViewById(R.id.collarName)
|
||||
collarPos = findViewById(R.id.collarPos)
|
||||
collarStimulus = findViewById(R.id.collarStimulus)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends API requests that retrieve updated information about the collar.
|
||||
*/
|
||||
private fun triggerRefresh() {
|
||||
val historyRequest = CollarHistoryRequest(getString(R.string.apiUrl), collarId, token,
|
||||
Response.Listener {
|
||||
|
@ -116,6 +187,9 @@ class CollarDetailActivity : AppCompatActivity() {
|
|||
queue.add(detailRequest)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets [mapPolygon]'s color, borders, and other relevant settings.
|
||||
*/
|
||||
private fun configureMapPolygon() {
|
||||
mapPolygon.fillPaint.color = Color.parseColor("#32a852")
|
||||
mapPolygon.fillPaint.alpha = 127
|
||||
|
@ -124,6 +198,12 @@ class CollarDetailActivity : AppCompatActivity() {
|
|||
mapPolygon.title = "Valid Grazing Area"
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a new location history of the animal, configures
|
||||
* updates the [map] to include the relevant elements (such as the animal's
|
||||
* [marker][mapMarker]). Additionally, if the map is not centered,
|
||||
* this sets its center to the animal's most recent location.
|
||||
*/
|
||||
private fun updateAnimalHistory(points : List<GeoPoint>) {
|
||||
val currentPoint = points.first()
|
||||
collarPos.text = getString(R.string.collarSummaryLocation, currentPoint.longitude, currentPoint.latitude)
|
||||
|
@ -138,12 +218,20 @@ class CollarDetailActivity : AppCompatActivity() {
|
|||
map.invalidate()
|
||||
}
|
||||
|
||||
/**
|
||||
* If an animal's history is empty, hides all the elements
|
||||
* that depend on said history from the [map].
|
||||
*/
|
||||
private fun clearAnimalHistory() {
|
||||
map.overlays.remove(mapMarker)
|
||||
map.overlays.remove(mapPolyline)
|
||||
if(map.overlays.contains(mapMarker)) map.overlays.remove(mapMarker)
|
||||
if(map.overlays.contains(mapPolyline)) map.overlays.remove(mapPolyline)
|
||||
map.invalidate()
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the [mapPolygon] with the new valid grazing area.
|
||||
* The polygon is added to the [map] if necessary.
|
||||
*/
|
||||
private fun updateBoundingBox(oldValue: Polygon?, polygon: Polygon) {
|
||||
if(oldValue == null) map.overlays.add(mapPolygon)
|
||||
val points = polygon.dataPoints.map { GeoPoint(it.longitude.toDouble(), it.latitude.toDouble()) }
|
||||
|
@ -153,6 +241,10 @@ class CollarDetailActivity : AppCompatActivity() {
|
|||
map.invalidate()
|
||||
}
|
||||
|
||||
/**
|
||||
* If an animal's bounding box is not set, hides
|
||||
* the [mapPolygon] from the [map].
|
||||
*/
|
||||
private fun clearBoundingBox() {
|
||||
map.overlays.remove(mapPolygon)
|
||||
map.invalidate()
|
||||
|
|
|
@ -4,7 +4,6 @@ import android.content.Intent
|
|||
import androidx.appcompat.app.AppCompatActivity
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.widget.Button
|
||||
import android.widget.Toast
|
||||
import androidx.preference.PreferenceManager
|
||||
import androidx.recyclerview.widget.DividerItemDecoration
|
||||
|
@ -24,21 +23,54 @@ import java.util.*
|
|||
import kotlin.concurrent.timerTask
|
||||
|
||||
class CollarListActivity : AppCompatActivity() {
|
||||
// The list of collar summaries and its list adapter.
|
||||
|
||||
/**
|
||||
* The list of collar summaries retrieved from the API.
|
||||
*/
|
||||
private val summaries : MutableList<CollarSummary> = mutableListOf()
|
||||
|
||||
/**
|
||||
* The adapter used to display the [summaries] on the screen.
|
||||
*/
|
||||
private lateinit var summaryAdapter: CollarSummaryAdapter
|
||||
|
||||
/**
|
||||
* The view of the summaries backed by the [summaryAdapter].
|
||||
*/
|
||||
private lateinit var collarList: RecyclerView
|
||||
|
||||
// The API token and request queue.
|
||||
/**
|
||||
* The API token and request queue.
|
||||
*/
|
||||
private lateinit var token: String
|
||||
|
||||
/**
|
||||
* The Volley queue used for sending API requests.
|
||||
*/
|
||||
private lateinit var queue: RequestQueue
|
||||
|
||||
// The OpenStreetMap map.
|
||||
/**
|
||||
* Whether or not the map has been centered on the location of the collars.
|
||||
* Rather than have the map constantly jump around following the collars as they move, we only
|
||||
* move the map's center when this variable is `false`.
|
||||
*/
|
||||
private var centerSet: Boolean = false
|
||||
|
||||
/**
|
||||
* The actual view used to display the OpenStreetMaps tiles and the locations of the collars.
|
||||
*/
|
||||
private lateinit var map: MapView
|
||||
|
||||
/**
|
||||
* Mapping of collar identifiers to the marker that represents them, used
|
||||
* to update markers without clearing the user's selection.
|
||||
*/
|
||||
private val collarOverlays: MutableMap<Int, Marker> = mutableMapOf()
|
||||
|
||||
// Timer used to schedule refresh
|
||||
/**
|
||||
* The timer used for scheduling repeated API calls. The timer is started
|
||||
* in [onResume], and stopped in [onPause].
|
||||
*/
|
||||
private var refreshTimer = Timer()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
|
@ -72,10 +104,17 @@ class CollarListActivity : AppCompatActivity() {
|
|||
refreshTimer.purge()
|
||||
}
|
||||
|
||||
/**
|
||||
* Method called by the "open statistics" button. Opens the statistics
|
||||
* activity.
|
||||
*/
|
||||
fun openStatistics(view: View) {
|
||||
startActivity(Intent(this, StatisticsActivity::class.java))
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends the request to the API to retrieve an updated list of active collars.
|
||||
*/
|
||||
private fun triggerRefresh() {
|
||||
val request = CollarRequest(getString(R.string.apiUrl), token,
|
||||
Response.Listener {
|
||||
|
@ -91,11 +130,21 @@ class CollarListActivity : AppCompatActivity() {
|
|||
queue.add(request)
|
||||
}
|
||||
|
||||
/**
|
||||
* Locates all the views in the activity, setting the respective
|
||||
* `lateinit` properties.
|
||||
*/
|
||||
private fun findViews() {
|
||||
collarList = findViewById(R.id.collarSummaryList)
|
||||
map = findViewById(R.id.map)
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the activity [CollarDetailActivity] to give the user more information
|
||||
* on a particular collar.
|
||||
*
|
||||
* @param collar the collar being viewed.
|
||||
*/
|
||||
private fun startCollarDetailActivity(collar: CollarSummary) {
|
||||
val newIntent = Intent(this, CollarDetailActivity::class.java).apply {
|
||||
putExtra("identifier", collar.id)
|
||||
|
@ -103,6 +152,10 @@ class CollarListActivity : AppCompatActivity() {
|
|||
startActivity(newIntent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Configures the [collarList] with the [summaryAdapter], allowing it to
|
||||
* properly display and handle clicks on the items.
|
||||
*/
|
||||
private fun setupCollarList() {
|
||||
val layoutManager = LinearLayoutManager(collarList.context)
|
||||
summaryAdapter = CollarSummaryAdapter(summaries, object : CollarClickListener {
|
||||
|
@ -116,11 +169,17 @@ class CollarListActivity : AppCompatActivity() {
|
|||
collarList.addItemDecoration(DividerItemDecoration(collarList.context, layoutManager.orientation))
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the map with new collar information.
|
||||
*/
|
||||
private fun updateMap() {
|
||||
updateMapCenter()
|
||||
updateMapOverlay()
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the map's center to the average location of the animals.
|
||||
*/
|
||||
private fun updateMapCenter() {
|
||||
if(centerSet) return
|
||||
|
||||
|
@ -130,6 +189,9 @@ class CollarListActivity : AppCompatActivity() {
|
|||
map.controller.setCenter(GeoPoint(averageLongitude, averageLatitude))
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the markers on the map with the animals' new locations.
|
||||
*/
|
||||
private fun updateMapOverlay() {
|
||||
val currentSet = mutableSetOf<Int>()
|
||||
summaries.forEach {
|
||||
|
|
|
@ -11,12 +11,23 @@ import com.android.volley.toolbox.Volley
|
|||
import com.danilafe.fencelessgrazing.model.CollarSummary
|
||||
import com.danilafe.fencelessgrazing.requests.CollarRequest
|
||||
|
||||
/**
|
||||
* A [ListAdapter] subclass to display and manage a list of [CollarSummary] items
|
||||
* collected from a [CollarRequest].
|
||||
*
|
||||
* @param items the list of items that are contained in this adapter.
|
||||
* @param collarClickListener the action to be executed when an item in the list is clicked.
|
||||
*/
|
||||
class CollarSummaryAdapter(
|
||||
private val items: List<CollarSummary>,
|
||||
private val collarClickListener: CollarClickListener
|
||||
) : ListAdapter<CollarSummary, CollarViewHolder>(DiffCallback()) {
|
||||
|
||||
class DiffCallback : DiffUtil.ItemCallback<CollarSummary>() {
|
||||
/**
|
||||
* [DiffUtil.ItemCallback] used for the [ListAdapter]. Compares items using
|
||||
* the standard equality method generated for data classes.
|
||||
*/
|
||||
internal class DiffCallback : DiffUtil.ItemCallback<CollarSummary>() {
|
||||
override fun areItemsTheSame(oldItem: CollarSummary, newItem: CollarSummary): Boolean
|
||||
= oldItem.id == newItem.id
|
||||
|
||||
|
|
|
@ -5,13 +5,30 @@ import android.widget.TextView
|
|||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.danilafe.fencelessgrazing.model.CollarSummary
|
||||
|
||||
/**
|
||||
* A [RecyclerView.ViewHolder] used to render [CollarSummary] item entries.
|
||||
*/
|
||||
class CollarViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
|
||||
/**
|
||||
* The [TextView] that holds the name of a collar.
|
||||
*/
|
||||
private val nameView: TextView = itemView.findViewById(R.id.collarSummaryName)
|
||||
|
||||
/**
|
||||
* The [TextView] that holds the position of the collar.
|
||||
*/
|
||||
private val positionView: TextView = itemView.findViewById(R.id.collarSummaryPos)
|
||||
|
||||
/**
|
||||
* Called by the [CollarSummaryAdapter] to update the contents of the view this
|
||||
* class holds.
|
||||
*
|
||||
* @param summary the collar summary whose data should be extracted and placed into the views.
|
||||
* @param collarClickListener the action that should be called when a summary is clicked.
|
||||
*/
|
||||
fun bindData(summary: CollarSummary, collarClickListener: CollarClickListener) {
|
||||
nameView.text = summary.name
|
||||
positionView.text = nameView.resources.getString(R.string.collarSummaryLocation,
|
||||
positionView.text = itemView.resources.getString(R.string.collarSummaryLocation,
|
||||
summary.pos.longitude.toDouble(), summary.pos.latitude.toDouble())
|
||||
itemView.setOnClickListener {
|
||||
collarClickListener.onCollarClick(summary)
|
||||
|
|
|
@ -9,18 +9,31 @@ import androidx.fragment.app.Fragment
|
|||
import com.android.volley.RequestQueue
|
||||
import com.android.volley.Response
|
||||
import com.android.volley.toolbox.Volley
|
||||
import com.danilafe.fencelessgrazing.model.CollarDistance
|
||||
import com.danilafe.fencelessgrazing.requests.DistanceTraveledRequest
|
||||
import com.github.mikephil.charting.charts.BarChart
|
||||
import com.github.mikephil.charting.components.XAxis
|
||||
import com.github.mikephil.charting.data.BarData
|
||||
import com.github.mikephil.charting.data.BarDataSet
|
||||
import com.github.mikephil.charting.data.BarEntry
|
||||
import com.github.mikephil.charting.formatter.IndexAxisValueFormatter
|
||||
import com.github.mikephil.charting.utils.ColorTemplate
|
||||
|
||||
/**
|
||||
* One of the tabs from the [StatisticsActivity]. Contains the graph
|
||||
* of the distance traveled by each animal, retrieved via the [DistanceTraveledRequest]
|
||||
* from the API.
|
||||
*/
|
||||
class DistanceTraveledGraph() : Fragment() {
|
||||
|
||||
/**
|
||||
* The BarChart used to display the distance data.
|
||||
*/
|
||||
private lateinit var distanceTraveledChart: BarChart
|
||||
|
||||
/**
|
||||
* The Volley queue used for making API requests.
|
||||
*/
|
||||
private lateinit var queue : RequestQueue
|
||||
|
||||
override fun onCreateView(
|
||||
|
@ -30,6 +43,7 @@ class DistanceTraveledGraph() : Fragment() {
|
|||
): View? {
|
||||
val view = inflater.inflate(R.layout.distance_traveled_layoyt, container, false)
|
||||
distanceTraveledChart = view.findViewById(R.id.distanceTraveledChart)
|
||||
setupChart()
|
||||
return view
|
||||
}
|
||||
|
||||
|
@ -40,28 +54,16 @@ class DistanceTraveledGraph() : Fragment() {
|
|||
triggerRefresh()
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the API request to retrieve updated distance data.
|
||||
*/
|
||||
private fun triggerRefresh() {
|
||||
val activity = requireActivity()
|
||||
val sharedPrefs = activity.getSharedPreferences("FencelessGrazing", 0)
|
||||
val token = sharedPrefs.getString("token", null)!!
|
||||
val request = DistanceTraveledRequest(activity.getString(R.string.apiUrl), token,
|
||||
Response.Listener {
|
||||
val entries = mutableListOf<BarEntry>()
|
||||
val labels = it.map { c -> c.name }
|
||||
it.forEachIndexed { i, v ->
|
||||
entries.add(BarEntry(i.toFloat(), v.distance))
|
||||
}
|
||||
val dataSet = BarDataSet(entries, "Distance Traveled")
|
||||
dataSet.colors = ColorTemplate.VORDIPLOM_COLORS.toList()
|
||||
val data = BarData(dataSet)
|
||||
data.barWidth = 0.9f
|
||||
distanceTraveledChart.legend.textSize = 20.0f
|
||||
distanceTraveledChart.data = data
|
||||
distanceTraveledChart.setFitBars(true)
|
||||
distanceTraveledChart.xAxis.valueFormatter = IndexAxisValueFormatter(labels)
|
||||
distanceTraveledChart.xAxis.granularity = 1.0f
|
||||
distanceTraveledChart.xAxis.isGranularityEnabled = true
|
||||
distanceTraveledChart.invalidate()
|
||||
updateChartData(it)
|
||||
},
|
||||
Response.ErrorListener {
|
||||
Toast.makeText(activity, "Failed to retrieve distance traveled!", Toast.LENGTH_SHORT).show()
|
||||
|
@ -69,4 +71,33 @@ class DistanceTraveledGraph() : Fragment() {
|
|||
)
|
||||
queue.add(request)
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the [distanceTraveledChart] with newly retrieved distance data.
|
||||
*/
|
||||
private fun updateChartData(distances: List<CollarDistance>) {
|
||||
val entries = mutableListOf<BarEntry>()
|
||||
val labels = distances.map { c -> c.name }
|
||||
distances.forEachIndexed { i, v ->
|
||||
entries.add(BarEntry(i.toFloat(), v.distance))
|
||||
}
|
||||
val dataSet = BarDataSet(entries, "Distance Traveled")
|
||||
dataSet.colors = ColorTemplate.PASTEL_COLORS.toList()
|
||||
val data = BarData(dataSet)
|
||||
data.barWidth = 0.8f
|
||||
distanceTraveledChart.data = data
|
||||
distanceTraveledChart.xAxis.valueFormatter = IndexAxisValueFormatter(labels)
|
||||
distanceTraveledChart.invalidate()
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure the [distanceTraveledChart] with the proper visual settings.
|
||||
*/
|
||||
private fun setupChart() {
|
||||
distanceTraveledChart.setFitBars(true)
|
||||
distanceTraveledChart.xAxis.granularity = 1.0f
|
||||
distanceTraveledChart.xAxis.isGranularityEnabled = true
|
||||
distanceTraveledChart.xAxis.position = XAxis.XAxisPosition.BOTTOM
|
||||
distanceTraveledChart.invalidate()
|
||||
}
|
||||
}
|
|
@ -10,13 +10,24 @@ import com.android.volley.Response
|
|||
import com.android.volley.toolbox.Volley
|
||||
import com.danilafe.fencelessgrazing.requests.LoginRequest
|
||||
|
||||
/**
|
||||
* First activity in the app. Prompts the user for their
|
||||
* login information, and attempts authentication to the API.
|
||||
*/
|
||||
class MainActivity : AppCompatActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_main)
|
||||
|
||||
// If we've already logged in, no need to do it again.
|
||||
val prefs = getSharedPreferences("FencelessGrazing", 0)
|
||||
if(prefs.contains("token")) startCollarActivity()
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the authentication request to the API.
|
||||
*/
|
||||
fun attemptLogin(view: View) {
|
||||
val usernameField: TextView = findViewById(R.id.username)
|
||||
val passwordField: TextView = findViewById(R.id.password)
|
||||
|
@ -30,12 +41,19 @@ class MainActivity : AppCompatActivity() {
|
|||
editor.putString("token", it.token)
|
||||
editor.apply()
|
||||
|
||||
val newIntent = Intent(this, CollarListActivity::class.java)
|
||||
startActivity(newIntent)
|
||||
startCollarActivity()
|
||||
},
|
||||
Response.ErrorListener {
|
||||
Toast.makeText(this, "Failed to log in! $it", Toast.LENGTH_LONG).show()
|
||||
})
|
||||
requestQueue.add(loginRequest)
|
||||
}
|
||||
|
||||
/**
|
||||
* Once authentication is complete, moves on to the list of collars.
|
||||
*/
|
||||
private fun startCollarActivity() {
|
||||
val newIntent = Intent(this, CollarListActivity::class.java)
|
||||
startActivity(newIntent)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -8,12 +8,25 @@ import androidx.viewpager2.widget.ViewPager2
|
|||
import com.google.android.material.tabs.TabLayout
|
||||
import com.google.android.material.tabs.TabLayoutMediator
|
||||
|
||||
/**
|
||||
* Activity containing various graphs created from collar data on the server.
|
||||
*/
|
||||
class StatisticsActivity : AppCompatActivity() {
|
||||
|
||||
/**
|
||||
* The tab layout allowing users to switch between graphs.
|
||||
*/
|
||||
private lateinit var tabLayout: TabLayout
|
||||
|
||||
/**
|
||||
* The view pager providing swiping functionality between views.
|
||||
*/
|
||||
private lateinit var viewPager: ViewPager2
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* The list of tab names, in the order they appear in the [viewPager].
|
||||
*/
|
||||
val tabNames = arrayOf("Distance Traveled")
|
||||
}
|
||||
|
||||
|
|
|
@ -4,6 +4,9 @@ import androidx.fragment.app.Fragment
|
|||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.viewpager2.adapter.FragmentStateAdapter
|
||||
|
||||
/**
|
||||
* Adapter for the [ViewPager2][androidx.viewpager2.widget.ViewPager2] class.
|
||||
*/
|
||||
class StatisticsGraphAdapter(activity : FragmentActivity) : FragmentStateAdapter(activity) {
|
||||
override fun getItemCount(): Int = 1
|
||||
override fun createFragment(position: Int): Fragment = DistanceTraveledGraph()
|
||||
|
|
|
@ -1,3 +1,10 @@
|
|||
package com.danilafe.fencelessgrazing.model
|
||||
|
||||
/**
|
||||
* Information returned from the `/collar/<id>/details` API endpoint.
|
||||
*
|
||||
* @param id the collar's internal unique identifier.
|
||||
* @param name the collar's current designation in the system.
|
||||
* @param stimulus the number of stimulus activation reports in the last 24 hours.
|
||||
*/
|
||||
data class CollarDetails(val id: Int, val name: String, val stimulus: Int)
|
|
@ -1,3 +1,10 @@
|
|||
package com.danilafe.fencelessgrazing.model
|
||||
|
||||
/**
|
||||
* Component of the information returned from the `/collars/stats/distance` AP endpoint.
|
||||
*
|
||||
* @param name the current designation of the collar to which this data point belongs.
|
||||
* @param id the unique identifier of the collar in the system.
|
||||
* @param distance the distance, in kilometers, traveled by this collar.
|
||||
*/
|
||||
data class CollarDistance(val name: String, val id: Int, val distance: Float)
|
|
@ -0,0 +1,9 @@
|
|||
package com.danilafe.fencelessgrazing.model
|
||||
|
||||
/**
|
||||
* GPS coordinate returned by many of the project's API endpoints.
|
||||
*
|
||||
* @param longitude the longitude of the GPS point.
|
||||
* @param latitude the latitude of the GPS point.
|
||||
*/
|
||||
data class CollarPos(val longitude: String, val latitude: String)
|
|
@ -1,4 +1,10 @@
|
|||
package com.danilafe.fencelessgrazing.model
|
||||
|
||||
data class CollarPos(val longitude: String, val latitude: String)
|
||||
/**
|
||||
* Minimal information about one of the collars returned by the `/collars` API endpoints.
|
||||
*
|
||||
* @param id the internal identifier of the collar in the system.
|
||||
* @param name the collar's current designation.
|
||||
* @param pos the collar's most recent location.
|
||||
*/
|
||||
data class CollarSummary(val id: Int, val name: String, val pos: CollarPos)
|
||||
|
|
|
@ -1,3 +1,8 @@
|
|||
package com.danilafe.fencelessgrazing.model
|
||||
|
||||
/**
|
||||
* The result of a successful authentication via the `/login` endpoint.
|
||||
*
|
||||
* @param token the JWT authorization token to be used for future API requests.
|
||||
*/
|
||||
data class LoginResult(val token: String)
|
|
@ -1,3 +1,8 @@
|
|||
package com.danilafe.fencelessgrazing.model
|
||||
|
||||
/**
|
||||
* A general polygon returned by many of the system's API endpoints.
|
||||
*
|
||||
* @param dataPoints the vertices of the polygon, in order.
|
||||
*/
|
||||
data class Polygon(val dataPoints : List<CollarPos>)
|
|
@ -0,0 +1,36 @@
|
|||
package com.danilafe.fencelessgrazing.requests
|
||||
|
||||
import com.android.volley.Response
|
||||
import com.android.volley.toolbox.StringRequest
|
||||
|
||||
/**
|
||||
* General request aimed at a protected endpoint of the API. The token can be retrieved
|
||||
* via a [LoginRequest], or, if you've made it past the login screen, via Shared Preferences.
|
||||
*
|
||||
* The [AuthenticatedRequest] expects to receive a string from the API endpoint; how this string
|
||||
* is handled is determined by the [Response.Listener] object. One may use
|
||||
* [Response.Listener.toGsonListener] to create a JSON deserializer that expects a [String].
|
||||
*
|
||||
* @param baseUrl the API URL base (for example, `https://dev.danilafe.com`)
|
||||
* @param apiEndpoint the API endpoint to which the request is being made.
|
||||
* @param token the API token used for authentication.
|
||||
* @param listener the listener object called if a valid response is received.
|
||||
* @param error the error listener for the request.
|
||||
*/
|
||||
open class AuthenticatedRequest(
|
||||
baseUrl: String,
|
||||
apiEndpoint: String,
|
||||
private val token: String,
|
||||
listener: Response.Listener<String>,
|
||||
error: Response.ErrorListener
|
||||
) : StringRequest(
|
||||
Method.GET, "${baseUrl}${apiEndpoint}",
|
||||
listener, error
|
||||
) {
|
||||
|
||||
override fun getHeaders(): MutableMap<String, String> {
|
||||
val newMap = HashMap(super.getHeaders())
|
||||
newMap["Authorization"] = "Bearer $token"
|
||||
return newMap
|
||||
}
|
||||
}
|
|
@ -7,22 +7,23 @@ import com.danilafe.fencelessgrazing.model.CollarPos
|
|||
import com.danilafe.fencelessgrazing.model.CollarSummary
|
||||
import com.google.gson.reflect.TypeToken
|
||||
|
||||
/**
|
||||
* Request to the `/collars/<id>/details` API endpoint. Retrieves detailed information
|
||||
* about a single collar, stored in a [CollarDetails] object.
|
||||
*
|
||||
* @param baseUrl the base URL of the API.
|
||||
* @param collarId the identifier of the collar whose details are being retrieved.
|
||||
* @param token the API token for authentication.
|
||||
* @param listener the listener that will be called if a valid list of collar details is received.
|
||||
* @param error the error listener for the request.
|
||||
*/
|
||||
class CollarDetailRequest(
|
||||
baseUrl: String,
|
||||
collarId: Int,
|
||||
private val token : String,
|
||||
token : String,
|
||||
listener: Response.Listener<CollarDetails>,
|
||||
error: Response.ErrorListener
|
||||
) : StringRequest(
|
||||
Method.GET, "${baseUrl}/collars/$collarId/details",
|
||||
GsonListener(
|
||||
object : TypeToken<CollarDetails>() {}.type,
|
||||
listener
|
||||
), error
|
||||
) {
|
||||
|
||||
override fun getHeaders(): MutableMap<String, String> {
|
||||
return mutableMapOf("Authorization" to "Bearer $token")
|
||||
}
|
||||
|
||||
}
|
||||
) : AuthenticatedRequest(
|
||||
baseUrl, "/collars/$collarId/details", token,
|
||||
listener.toGsonListener(), error
|
||||
)
|
|
@ -6,22 +6,24 @@ import com.danilafe.fencelessgrazing.model.CollarPos
|
|||
import com.danilafe.fencelessgrazing.model.CollarSummary
|
||||
import com.google.gson.reflect.TypeToken
|
||||
|
||||
/**
|
||||
* A request to the `/collars/<id>/history` API endpoint. Retrieves the entire list
|
||||
* of coordinates, represented as [CollarPos] objects, that the collar has transmitted.
|
||||
* The coordinates are sorted by date.
|
||||
*
|
||||
* @param baseUrl the base URL of the API.
|
||||
* @param collarId the identifier of the collar whose history is being retrieved.
|
||||
* @param token the API token for authentication.
|
||||
* @param listener the listener that will be called if a valid list of coordinates is received.
|
||||
* @param error the error listener for the request.
|
||||
*/
|
||||
class CollarHistoryRequest(
|
||||
baseUrl: String,
|
||||
collarId: Int,
|
||||
private val token : String,
|
||||
token : String,
|
||||
listener: Response.Listener<List<CollarPos>>,
|
||||
error: Response.ErrorListener
|
||||
) : StringRequest(
|
||||
Method.GET, "${baseUrl}/collars/$collarId/history",
|
||||
GsonListener(
|
||||
object : TypeToken<List<CollarPos>>() {}.type,
|
||||
listener
|
||||
), error
|
||||
) {
|
||||
|
||||
override fun getHeaders(): MutableMap<String, String> {
|
||||
return mutableMapOf("Authorization" to "Bearer $token")
|
||||
}
|
||||
|
||||
}
|
||||
) : AuthenticatedRequest(
|
||||
baseUrl, "/collars/$collarId/history", token,
|
||||
listener.toGsonListener(), error
|
||||
)
|
|
@ -5,21 +5,21 @@ import com.android.volley.toolbox.StringRequest
|
|||
import com.danilafe.fencelessgrazing.model.CollarSummary
|
||||
import com.google.gson.reflect.TypeToken
|
||||
|
||||
/**
|
||||
* A request to the `/collars` API endpoint. Retrieves a list of collar summaries, represented
|
||||
* as [CollarSummary] objects.
|
||||
*
|
||||
* @param baseUrl the base URL of the API.
|
||||
* @param token the API token for authentication.
|
||||
* @param listener the listener that will be called if a valid list of coordinates is received.
|
||||
* @param error the error listener for the request.
|
||||
*/
|
||||
class CollarRequest(
|
||||
baseUrl: String,
|
||||
private val token : String,
|
||||
token : String,
|
||||
listener: Response.Listener<List<CollarSummary>>,
|
||||
error: Response.ErrorListener
|
||||
) : StringRequest(
|
||||
Method.GET, "${baseUrl}/collars",
|
||||
GsonListener(
|
||||
object : TypeToken<List<CollarSummary>>() {}.type,
|
||||
listener
|
||||
), error
|
||||
) {
|
||||
|
||||
override fun getHeaders(): MutableMap<String, String> {
|
||||
return mutableMapOf("Authorization" to "Bearer $token")
|
||||
}
|
||||
|
||||
}
|
||||
) : AuthenticatedRequest(
|
||||
baseUrl, "/collars", token,
|
||||
listener.toGsonListener(), error
|
||||
)
|
|
@ -5,21 +5,22 @@ import com.android.volley.Response
|
|||
import com.android.volley.toolbox.StringRequest
|
||||
import com.google.gson.reflect.TypeToken
|
||||
|
||||
/**
|
||||
* A request to the `/collars/stats/distance` API endpoint. Retrieves
|
||||
* a list of [CollarDistance] objects, containing, among other things, the total
|
||||
* distance traveled by each animal.
|
||||
*
|
||||
* @param baseUrl the base URL of the API.
|
||||
* @param token the API token for authentication.
|
||||
* @param listener the listener that will be called if a valid list of distances is received.
|
||||
* @param error the error listener for the request.
|
||||
*/
|
||||
class DistanceTraveledRequest(
|
||||
baseUrl: String,
|
||||
private val token : String,
|
||||
token : String,
|
||||
listener: Response.Listener<List<CollarDistance>>,
|
||||
error: Response.ErrorListener
|
||||
) : StringRequest(
|
||||
Method.GET, "${baseUrl}/collars/stats/distance",
|
||||
GsonListener(
|
||||
object : TypeToken<List<CollarDistance>>() {}.type,
|
||||
listener
|
||||
), error
|
||||
) {
|
||||
|
||||
override fun getHeaders(): MutableMap<String, String> {
|
||||
return mutableMapOf("Authorization" to "Bearer $token")
|
||||
}
|
||||
|
||||
}
|
||||
) : AuthenticatedRequest(
|
||||
baseUrl, "/collars/stats/distance", token,
|
||||
listener.toGsonListener(), error
|
||||
)
|
|
@ -3,15 +3,36 @@ package com.danilafe.fencelessgrazing.requests
|
|||
import android.util.Log
|
||||
import com.android.volley.Response
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import java.lang.reflect.Type
|
||||
|
||||
class GsonListener<T : Any>(private val targetType : Type, private val stringListener : Response.Listener<T>) : Response.Listener<String> {
|
||||
|
||||
private val gson = Gson()
|
||||
|
||||
/**
|
||||
* It appears as though a type parameter, even on that's `reified`, is erased when an anonymous
|
||||
* object is declared inside an `inline` function. This means that [Gson] serialization with
|
||||
* a [TypeToken] created inside this anonymous object wil _still_ fail, even though the
|
||||
* surrounding function is `inline` and with a `reified` type. To work around this,
|
||||
* we create the type in the function itself, and pass it as an argument to this
|
||||
* [GsonListener].
|
||||
*
|
||||
* @param listener the underlying listener expecting a value of type [T].
|
||||
* @param type the type to which [Gson] should deserialize the expected string.
|
||||
*/
|
||||
class GsonListener<T>(val listener: Response.Listener<T>, private val type: Type) : Response.Listener<String> {
|
||||
override fun onResponse(response: String?) {
|
||||
if(response == null) { return stringListener.onResponse(null) }
|
||||
stringListener.onResponse(gson.fromJson<T>(response, targetType))
|
||||
val transformed = response?.let { Gson().fromJson<T>(it, type) }
|
||||
listener.onResponse(transformed)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a [Response.Listener] of an arbitrary type [T] to a listener of type [String].
|
||||
* This is done by converting the input [String] into [T] via [Gson].
|
||||
* This function must be reified to prevent type erasure.
|
||||
*
|
||||
* @receiver the listener that will be called when the [String] is converted into [T].
|
||||
* @return the resulting [String]-based listener.
|
||||
*/
|
||||
inline fun <reified T> Response.Listener<T>.toGsonListener(): Response.Listener<String> {
|
||||
return GsonListener(this, object : TypeToken<T>(){}.type)
|
||||
}
|
||||
|
||||
}
|
|
@ -4,6 +4,16 @@ import com.android.volley.Response
|
|||
import com.android.volley.toolbox.StringRequest
|
||||
import com.danilafe.fencelessgrazing.model.LoginResult
|
||||
|
||||
/**
|
||||
* A request to the `/login` endpoint of the API, intended to retrieve the JWT authentication
|
||||
* token to be used for the remainder of the interaction with the API.
|
||||
*
|
||||
* @param baseUrl the base URL of the API.
|
||||
* @param username the username with which to attempt to authenticate.
|
||||
* @param password the password with which to attempt to authenticate.
|
||||
* @param listener the listener that will be called if authentication succeeds.
|
||||
* @param error the error listener for the request.
|
||||
*/
|
||||
class LoginRequest(
|
||||
baseUrl: String,
|
||||
private val username: String,
|
||||
|
@ -12,7 +22,7 @@ class LoginRequest(
|
|||
error: Response.ErrorListener
|
||||
) : StringRequest (
|
||||
Method.POST, "${baseUrl}/login",
|
||||
GsonListener(LoginResult::class.java, listener), error
|
||||
listener.toGsonListener(), error
|
||||
) {
|
||||
|
||||
override fun getParams(): MutableMap<String, String> {
|
||||
|
|
Loading…
Reference in New Issue