Compare commits
19 Commits
55f0bec0b4
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 31885f5a73 | |||
| 3bcf1ff545 | |||
| b0e8b7fe1c | |||
| a4f29c4325 | |||
| d7b227958c | |||
| 006aac384f | |||
| 33af724fbf | |||
| 9a75af4045 | |||
| 03c55f370e | |||
| efb3921dc2 | |||
| 48a6d1fff1 | |||
| 7cd3706b7d | |||
| e13572ae67 | |||
| 1cb22bf4a0 | |||
| 9ab9377201 | |||
| b48317c0c0 | |||
| ce0096d94f | |||
| 5d0ec6738d | |||
| b18890851c |
27
README.md
Normal file
27
README.md
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# Fenceless Grazing System App
|
||||||
|
|
||||||
|
This is the repository for the mobile application part of the Fenceless
|
||||||
|
Grazing System. The app is a rather standard Android application,
|
||||||
|
written in the Kotlin programming language. To build the application,
|
||||||
|
it is sufficient to run:
|
||||||
|
|
||||||
|
```Bash
|
||||||
|
./gradlew assembleDebug
|
||||||
|
```
|
||||||
|
|
||||||
|
This will produce an APK file which can then be loaded onto
|
||||||
|
an Android device or emulator.
|
||||||
|
|
||||||
|
Note that the application makes HTTP requests to a predefined
|
||||||
|
API server URL, currently `dev.danilafe.com`. This is not suitable
|
||||||
|
for testing if you do not have access to the domain. To
|
||||||
|
reconfigure the application to use a different API
|
||||||
|
URL, change the `apiUrl` entry in the application's `strings.xml` file:
|
||||||
|
|
||||||
|
```XML
|
||||||
|
<string name="apiUrl">http://your.domain </string>
|
||||||
|
```
|
||||||
|
|
||||||
|
The application will not be of much use if it is unable
|
||||||
|
to reach the Fenceless Grazing API server. The server's
|
||||||
|
repository can be found [here](https://dev.danilafe.com/CS-46X/server).
|
||||||
10
REVIEW.md
Normal file
10
REVIEW.md
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
# Code Review Changes
|
||||||
|
|
||||||
|
https://docs.google.com/document/d/19d5U-ieQyGVVNQjj0rUouzIqF53evEP6yss-wyqNXiQ/edit?usp=sharing
|
||||||
|
|
||||||
|
|Comment|Adjustment|
|
||||||
|
|-------|----------|
|
||||||
|
|The package `com.danilafe.fencelessgrazing` can be changed to something more relevant.| Package name follows standard conventions of reverse domain name. No changes made.|
|
||||||
|
| (General remark) commenting is sparse.| Every function and field in the codebase documented using KDoc; documentation intermittently updated on [student website](https://web.engr.oregonstate.edu/~fedorind/CS46X/app/index.html).|
|
||||||
|
| No unit tests| Not worth it to do UI tests; [cow description language](https://dev.danilafe.com/CS-46x/cdl) developed for testing databases elsewhere.|
|
||||||
|
| Missing README. | README added with compilation instructions; gradle wrapper adjusted to work out of the box.|
|
||||||
@@ -4,6 +4,17 @@ apply plugin: 'kotlin-android'
|
|||||||
|
|
||||||
apply plugin: 'kotlin-android-extensions'
|
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 {
|
android {
|
||||||
compileSdkVersion 29
|
compileSdkVersion 29
|
||||||
buildToolsVersion "29.0.2"
|
buildToolsVersion "29.0.2"
|
||||||
@@ -23,6 +34,15 @@ android {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dokka {
|
||||||
|
outputFormat = 'html'
|
||||||
|
outputDirectory = "$buildDir/dokka"
|
||||||
|
|
||||||
|
configuration {
|
||||||
|
includeNonPublic = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
repositories {
|
repositories {
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
google()
|
google()
|
||||||
|
|||||||
@@ -13,10 +13,11 @@
|
|||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/AppTheme">
|
android:theme="@style/AppTheme">
|
||||||
<activity android:name=".StatisticsActivity"/>
|
<activity android:name=".ui.activities.BoundaryEditorActivity"></activity>
|
||||||
<activity android:name=".CollarDetailActivity" />
|
<activity android:name=".ui.activities.StatisticsActivity" />
|
||||||
<activity android:name=".CollarListActivity" />
|
<activity android:name=".ui.activities.CollarDetailActivity" />
|
||||||
<activity android:name=".MainActivity">
|
<activity android:name=".ui.activities.CollarListActivity" />
|
||||||
|
<activity android:name=".ui.activities.MainActivity">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
package com.danilafe.fencelessgrazing
|
|
||||||
|
|
||||||
import com.danilafe.fencelessgrazing.model.CollarSummary
|
|
||||||
|
|
||||||
interface CollarClickListener {
|
|
||||||
fun onCollarClick(collar: CollarSummary?)
|
|
||||||
}
|
|
||||||
@@ -1,160 +0,0 @@
|
|||||||
package com.danilafe.fencelessgrazing
|
|
||||||
|
|
||||||
import android.graphics.Color
|
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.widget.TextView
|
|
||||||
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
|
|
||||||
import org.osmdroid.util.GeoPoint
|
|
||||||
import org.osmdroid.views.MapView
|
|
||||||
import org.osmdroid.views.overlay.Marker
|
|
||||||
import org.osmdroid.views.overlay.Polyline
|
|
||||||
import java.util.*
|
|
||||||
import kotlin.concurrent.timerTask
|
|
||||||
|
|
||||||
class CollarDetailActivity : AppCompatActivity() {
|
|
||||||
|
|
||||||
private lateinit var collarName: TextView
|
|
||||||
private lateinit var collarPos: TextView
|
|
||||||
private lateinit var collarStimulus: TextView
|
|
||||||
|
|
||||||
private lateinit var token: String
|
|
||||||
private lateinit var queue: RequestQueue
|
|
||||||
private var collarId: Int = -1
|
|
||||||
|
|
||||||
private var centerSet: Boolean = false
|
|
||||||
private lateinit var map: MapView
|
|
||||||
|
|
||||||
private var refreshTimer = Timer()
|
|
||||||
|
|
||||||
private lateinit var mapPolygon: org.osmdroid.views.overlay.Polygon
|
|
||||||
private var boundingBox: Polygon? = null
|
|
||||||
set(newValue) {
|
|
||||||
if(newValue != null) updateBoundingBox(field, newValue)
|
|
||||||
else clearBoundingBox()
|
|
||||||
field = newValue
|
|
||||||
}
|
|
||||||
|
|
||||||
private lateinit var mapMarker: Marker
|
|
||||||
private lateinit var mapPolyline: Polyline
|
|
||||||
private var dataPoints: List<GeoPoint> = listOf()
|
|
||||||
set(newValue) {
|
|
||||||
if(newValue.isNotEmpty()) updateAnimalHistory(newValue)
|
|
||||||
else clearAnimalHistory()
|
|
||||||
field = newValue
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
|
||||||
super.onCreate(savedInstanceState)
|
|
||||||
setContentView(R.layout.activity_collar_detail)
|
|
||||||
|
|
||||||
collarName = findViewById(R.id.collarName)
|
|
||||||
collarPos = findViewById(R.id.collarPos)
|
|
||||||
collarStimulus = findViewById(R.id.collarStimulus)
|
|
||||||
|
|
||||||
token = getSharedPreferences("FencelessGrazing", 0).getString("token", null)!!
|
|
||||||
collarId = intent.getIntExtra("identifier", -1)
|
|
||||||
queue = Volley.newRequestQueue(applicationContext)
|
|
||||||
|
|
||||||
map = findViewById(R.id.detailMap)
|
|
||||||
map.controller.setZoom(9.5)
|
|
||||||
mapPolygon = org.osmdroid.views.overlay.Polygon(map)
|
|
||||||
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() {
|
|
||||||
super.onPause()
|
|
||||||
map.onPause()
|
|
||||||
refreshTimer.cancel()
|
|
||||||
refreshTimer.purge()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onResume() {
|
|
||||||
super.onResume()
|
|
||||||
map.onResume()
|
|
||||||
refreshTimer = Timer()
|
|
||||||
refreshTimer.schedule(timerTask { triggerRefresh() }, 0L, 5000L)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun triggerRefresh() {
|
|
||||||
val historyRequest = CollarHistoryRequest(getString(R.string.apiUrl), collarId, token,
|
|
||||||
Response.Listener {
|
|
||||||
dataPoints = it.map { p -> GeoPoint(p.longitude.toDouble(), p.latitude.toDouble()) }
|
|
||||||
},
|
|
||||||
Response.ErrorListener {
|
|
||||||
Toast.makeText(this, "Failed to retrieve history of collar", Toast.LENGTH_SHORT).show()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
val detailRequest = CollarDetailRequest(getString(R.string.apiUrl), collarId, token,
|
|
||||||
Response.Listener {
|
|
||||||
collarName.text = it.name
|
|
||||||
collarStimulus.text = getString(R.string.collarSummaryStimulus, it.stimulus)
|
|
||||||
},
|
|
||||||
Response.ErrorListener {
|
|
||||||
Toast.makeText(this, "Failed to retrieve details of collar", Toast.LENGTH_SHORT).show()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
queue.add(historyRequest)
|
|
||||||
queue.add(detailRequest)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun configureMapPolygon() {
|
|
||||||
mapPolygon.fillPaint.color = Color.parseColor("#32a852")
|
|
||||||
mapPolygon.fillPaint.alpha = 127
|
|
||||||
mapPolygon.outlinePaint.color = Color.parseColor("#227539")
|
|
||||||
mapPolygon.outlinePaint.strokeWidth = 0.0f
|
|
||||||
mapPolygon.title = "Valid Grazing Area"
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun updateAnimalHistory(points : List<GeoPoint>) {
|
|
||||||
val currentPoint = points.first()
|
|
||||||
collarPos.text = getString(R.string.collarSummaryLocation, currentPoint.longitude, currentPoint.latitude)
|
|
||||||
if(!map.overlays.contains(mapMarker)) map.overlays.add(mapMarker)
|
|
||||||
if(!map.overlays.contains(mapPolyline)) map.overlays.add(mapPolyline)
|
|
||||||
mapMarker.position = currentPoint
|
|
||||||
mapPolyline.setPoints(points)
|
|
||||||
if(!centerSet) {
|
|
||||||
centerSet = true
|
|
||||||
map.controller.setCenter(currentPoint)
|
|
||||||
}
|
|
||||||
map.invalidate()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun clearAnimalHistory() {
|
|
||||||
map.overlays.remove(mapMarker)
|
|
||||||
map.overlays.remove(mapPolyline)
|
|
||||||
map.invalidate()
|
|
||||||
}
|
|
||||||
|
|
||||||
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()) }
|
|
||||||
val polygonPoints = points.toMutableList()
|
|
||||||
polygonPoints.add(polygonPoints[0])
|
|
||||||
mapPolygon.points = polygonPoints
|
|
||||||
map.invalidate()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun clearBoundingBox() {
|
|
||||||
map.overlays.remove(mapPolygon)
|
|
||||||
map.invalidate()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
package com.danilafe.fencelessgrazing
|
|
||||||
|
|
||||||
import android.view.View
|
|
||||||
import android.widget.TextView
|
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
|
||||||
import com.danilafe.fencelessgrazing.model.CollarSummary
|
|
||||||
|
|
||||||
class CollarViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
|
|
||||||
private val nameView: TextView = itemView.findViewById(R.id.collarSummaryName)
|
|
||||||
private val positionView: TextView = itemView.findViewById(R.id.collarSummaryPos)
|
|
||||||
|
|
||||||
fun bindData(summary: CollarSummary, collarClickListener: CollarClickListener) {
|
|
||||||
nameView.text = summary.name
|
|
||||||
positionView.text = nameView.resources.getString(R.string.collarSummaryLocation,
|
|
||||||
summary.pos.longitude.toDouble(), summary.pos.latitude.toDouble())
|
|
||||||
itemView.setOnClickListener {
|
|
||||||
collarClickListener.onCollarClick(summary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
package com.danilafe.fencelessgrazing
|
|
||||||
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.view.LayoutInflater
|
|
||||||
import android.view.View
|
|
||||||
import android.view.ViewGroup
|
|
||||||
import android.widget.Toast
|
|
||||||
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.requests.DistanceTraveledRequest
|
|
||||||
import com.github.mikephil.charting.charts.BarChart
|
|
||||||
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
|
|
||||||
|
|
||||||
class DistanceTraveledGraph() : Fragment() {
|
|
||||||
|
|
||||||
private lateinit var distanceTraveledChart: BarChart
|
|
||||||
|
|
||||||
private lateinit var queue : RequestQueue
|
|
||||||
|
|
||||||
override fun onCreateView(
|
|
||||||
inflater: LayoutInflater,
|
|
||||||
container: ViewGroup?,
|
|
||||||
savedInstanceState: Bundle?
|
|
||||||
): View? {
|
|
||||||
val view = inflater.inflate(R.layout.distance_traveled_layoyt, container, false)
|
|
||||||
distanceTraveledChart = view.findViewById(R.id.distanceTraveledChart)
|
|
||||||
return view
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
|
||||||
super.onCreate(savedInstanceState)
|
|
||||||
queue = Volley.newRequestQueue(requireActivity().applicationContext)
|
|
||||||
|
|
||||||
triggerRefresh()
|
|
||||||
}
|
|
||||||
|
|
||||||
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()
|
|
||||||
},
|
|
||||||
Response.ErrorListener {
|
|
||||||
Toast.makeText(activity, "Failed to retrieve distance traveled!", Toast.LENGTH_SHORT).show()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
queue.add(request)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
package com.danilafe.fencelessgrazing
|
|
||||||
|
|
||||||
import androidx.fragment.app.Fragment
|
|
||||||
import androidx.fragment.app.FragmentActivity
|
|
||||||
import androidx.viewpager2.adapter.FragmentStateAdapter
|
|
||||||
|
|
||||||
class StatisticsGraphAdapter(activity : FragmentActivity) : FragmentStateAdapter(activity) {
|
|
||||||
override fun getItemCount(): Int = 1
|
|
||||||
override fun createFragment(position: Int): Fragment = DistanceTraveledGraph()
|
|
||||||
}
|
|
||||||
@@ -1,3 +1,11 @@
|
|||||||
package com.danilafe.fencelessgrazing.model
|
package com.danilafe.fencelessgrazing.model
|
||||||
|
|
||||||
data class CollarDetails(val id: Int, val name: String, val stimulus: Int)
|
/**
|
||||||
|
* 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.
|
||||||
|
* @param boundary the collar's current valid grazing boundary.
|
||||||
|
*/
|
||||||
|
data class CollarDetails(val id: Int, val name: String, val stimulus: Int, val boundary: List<CollarPos>)
|
||||||
@@ -1,3 +1,10 @@
|
|||||||
package com.danilafe.fencelessgrazing.model
|
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)
|
data class CollarDistance(val name: String, val id: Int, val distance: Float)
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package com.danilafe.fencelessgrazing.model
|
||||||
|
|
||||||
|
import android.os.Parcelable
|
||||||
|
import kotlinx.android.parcel.Parcelize
|
||||||
|
import org.osmdroid.util.GeoPoint
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
@Parcelize
|
||||||
|
data class CollarPos(val longitude: String, val latitude: String) : Parcelable {
|
||||||
|
fun toGeoPoint(): GeoPoint = GeoPoint(latitude.toDouble(), longitude.toDouble())
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,4 +1,10 @@
|
|||||||
package com.danilafe.fencelessgrazing.model
|
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)
|
data class CollarSummary(val id: Int, val name: String, val pos: CollarPos)
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
package com.danilafe.fencelessgrazing.model
|
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)
|
data class LoginResult(val token: String)
|
||||||
@@ -1,3 +1,8 @@
|
|||||||
package com.danilafe.fencelessgrazing.model
|
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>)
|
data class Polygon(val dataPoints : List<CollarPos>)
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
package com.danilafe.fencelessgrazing.requests
|
|
||||||
|
|
||||||
import com.android.volley.Response
|
|
||||||
import com.android.volley.toolbox.StringRequest
|
|
||||||
import com.danilafe.fencelessgrazing.model.CollarDetails
|
|
||||||
import com.danilafe.fencelessgrazing.model.CollarPos
|
|
||||||
import com.danilafe.fencelessgrazing.model.CollarSummary
|
|
||||||
import com.google.gson.reflect.TypeToken
|
|
||||||
|
|
||||||
class CollarDetailRequest(
|
|
||||||
baseUrl: String,
|
|
||||||
collarId: Int,
|
|
||||||
private val 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")
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
package com.danilafe.fencelessgrazing.requests
|
|
||||||
|
|
||||||
import com.android.volley.Response
|
|
||||||
import com.android.volley.toolbox.StringRequest
|
|
||||||
import com.danilafe.fencelessgrazing.model.CollarPos
|
|
||||||
import com.danilafe.fencelessgrazing.model.CollarSummary
|
|
||||||
import com.google.gson.reflect.TypeToken
|
|
||||||
|
|
||||||
class CollarHistoryRequest(
|
|
||||||
baseUrl: String,
|
|
||||||
collarId: Int,
|
|
||||||
private val 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")
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
package com.danilafe.fencelessgrazing.requests
|
|
||||||
|
|
||||||
import com.android.volley.Response
|
|
||||||
import com.android.volley.toolbox.StringRequest
|
|
||||||
import com.danilafe.fencelessgrazing.model.CollarSummary
|
|
||||||
import com.google.gson.reflect.TypeToken
|
|
||||||
|
|
||||||
class CollarRequest(
|
|
||||||
baseUrl: String,
|
|
||||||
private val 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")
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
package com.danilafe.fencelessgrazing.requests
|
|
||||||
|
|
||||||
import com.danilafe.fencelessgrazing.model.CollarDistance
|
|
||||||
import com.android.volley.Response
|
|
||||||
import com.android.volley.toolbox.StringRequest
|
|
||||||
import com.google.gson.reflect.TypeToken
|
|
||||||
|
|
||||||
class DistanceTraveledRequest(
|
|
||||||
baseUrl: String,
|
|
||||||
private val 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")
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -3,15 +3,36 @@ package com.danilafe.fencelessgrazing.requests
|
|||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.android.volley.Response
|
import com.android.volley.Response
|
||||||
import com.google.gson.Gson
|
import com.google.gson.Gson
|
||||||
|
import com.google.gson.reflect.TypeToken
|
||||||
import java.lang.reflect.Type
|
import java.lang.reflect.Type
|
||||||
|
|
||||||
class GsonListener<T : Any>(private val targetType : Type, private val stringListener : Response.Listener<T>) : Response.Listener<String> {
|
/**
|
||||||
|
* It appears as though a type parameter, even on that's `reified`, is erased when an anonymous
|
||||||
private val gson = Gson()
|
* 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?) {
|
override fun onResponse(response: String?) {
|
||||||
if(response == null) { return stringListener.onResponse(null) }
|
val transformed = response?.let { Gson().fromJson<T>(it, type) }
|
||||||
stringListener.onResponse(gson.fromJson<T>(response, targetType))
|
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.android.volley.toolbox.StringRequest
|
||||||
import com.danilafe.fencelessgrazing.model.LoginResult
|
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(
|
class LoginRequest(
|
||||||
baseUrl: String,
|
baseUrl: String,
|
||||||
private val username: String,
|
private val username: String,
|
||||||
@@ -12,7 +22,7 @@ class LoginRequest(
|
|||||||
error: Response.ErrorListener
|
error: Response.ErrorListener
|
||||||
) : StringRequest (
|
) : StringRequest (
|
||||||
Method.POST, "${baseUrl}/login",
|
Method.POST, "${baseUrl}/login",
|
||||||
GsonListener(LoginResult::class.java, listener), error
|
listener.toGsonListener(), error
|
||||||
) {
|
) {
|
||||||
|
|
||||||
override fun getParams(): MutableMap<String, String> {
|
override fun getParams(): MutableMap<String, String> {
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package com.danilafe.fencelessgrazing.requests.authenticated
|
||||||
|
|
||||||
|
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 [AuthenticatedGetRequest] 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 AuthenticatedGetRequest(
|
||||||
|
baseUrl: String,
|
||||||
|
apiEndpoint: String,
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package com.danilafe.fencelessgrazing.requests.authenticated
|
||||||
|
|
||||||
|
import com.android.volley.Response
|
||||||
|
import com.android.volley.toolbox.JsonObjectRequest
|
||||||
|
import com.android.volley.toolbox.StringRequest
|
||||||
|
import com.google.gson.Gson
|
||||||
|
import com.google.gson.JsonObject
|
||||||
|
import org.json.JSONObject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* General POST request aimed at a protected endpoint of the API. The token can be retrieved
|
||||||
|
* via a [com.danilafe.fencelessgrazing.requests.LoginRequest], or, if you've made it past the login
|
||||||
|
* screen, via Shared Preferences.
|
||||||
|
*
|
||||||
|
* @param value the value to send as JSON to the API.
|
||||||
|
* @param baseUrl the base URL of the API.
|
||||||
|
* @param apiEndpoint the API endpoint to send the request to.
|
||||||
|
* @param token the token to use to authenticate with the API.
|
||||||
|
* @param listener the listener to be called when a response is received.
|
||||||
|
* @param error the error handler to be called if a request fails.
|
||||||
|
*/
|
||||||
|
open class AuthenticatedPostRequest<T>(
|
||||||
|
private val value: T,
|
||||||
|
baseUrl: String,
|
||||||
|
apiEndpoint: String,
|
||||||
|
private val token: String,
|
||||||
|
listener: Response.Listener<String>,
|
||||||
|
error: Response.ErrorListener
|
||||||
|
) : StringRequest(Method.POST, "${baseUrl}${apiEndpoint}",
|
||||||
|
listener, error
|
||||||
|
) {
|
||||||
|
|
||||||
|
override fun getBody(): ByteArray = Gson().toJson(value).toByteArray()
|
||||||
|
|
||||||
|
override fun getBodyContentType(): String = "application/json"
|
||||||
|
|
||||||
|
override fun getHeaders(): MutableMap<String, String> {
|
||||||
|
val newMap = HashMap(super.getHeaders())
|
||||||
|
newMap["Authorization"] = "Bearer $token"
|
||||||
|
return newMap
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package com.danilafe.fencelessgrazing.requests.authenticated
|
||||||
|
|
||||||
|
import com.android.volley.Response
|
||||||
|
import com.danilafe.fencelessgrazing.model.CollarDetails
|
||||||
|
import com.danilafe.fencelessgrazing.requests.toGsonListener
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
token : String,
|
||||||
|
listener: Response.Listener<CollarDetails>,
|
||||||
|
error: Response.ErrorListener
|
||||||
|
) : AuthenticatedGetRequest(
|
||||||
|
baseUrl, "/collars/$collarId/details", token,
|
||||||
|
listener.toGsonListener(), error
|
||||||
|
)
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package com.danilafe.fencelessgrazing.requests.authenticated
|
||||||
|
|
||||||
|
import com.android.volley.Response
|
||||||
|
import com.danilafe.fencelessgrazing.model.CollarPos
|
||||||
|
import com.danilafe.fencelessgrazing.requests.toGsonListener
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
token : String,
|
||||||
|
listener: Response.Listener<List<CollarPos>>,
|
||||||
|
error: Response.ErrorListener
|
||||||
|
) : AuthenticatedGetRequest(
|
||||||
|
baseUrl, "/collars/$collarId/history", token,
|
||||||
|
listener.toGsonListener(), error
|
||||||
|
)
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package com.danilafe.fencelessgrazing.requests.authenticated
|
||||||
|
|
||||||
|
import com.android.volley.Response
|
||||||
|
import com.danilafe.fencelessgrazing.model.CollarSummary
|
||||||
|
import com.danilafe.fencelessgrazing.requests.toGsonListener
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
token : String,
|
||||||
|
listener: Response.Listener<List<CollarSummary>>,
|
||||||
|
error: Response.ErrorListener
|
||||||
|
) : AuthenticatedGetRequest(
|
||||||
|
baseUrl, "/collars", token,
|
||||||
|
listener.toGsonListener(), error
|
||||||
|
)
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package com.danilafe.fencelessgrazing.requests.authenticated
|
||||||
|
|
||||||
|
import com.danilafe.fencelessgrazing.model.CollarDistance
|
||||||
|
import com.android.volley.Response
|
||||||
|
import com.danilafe.fencelessgrazing.requests.toGsonListener
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
token : String,
|
||||||
|
listener: Response.Listener<List<CollarDistance>>,
|
||||||
|
error: Response.ErrorListener
|
||||||
|
) : AuthenticatedGetRequest(
|
||||||
|
baseUrl, "/collars/stats/distance", token,
|
||||||
|
listener.toGsonListener(), error
|
||||||
|
)
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package com.danilafe.fencelessgrazing.requests.authenticated
|
||||||
|
|
||||||
|
import com.android.volley.Response
|
||||||
|
import com.danilafe.fencelessgrazing.model.CollarPos
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request to the API's `/collar/<id>/boundary/set` endpoint, used to update a collar's
|
||||||
|
* valid grazing area. Note that although this request takes an arbitrary number of points,
|
||||||
|
* the collar itself only supports at most 10 vertices.
|
||||||
|
*
|
||||||
|
* @param baseUrl the base URl of the API.
|
||||||
|
* @param token the API token used for authentication.
|
||||||
|
* @param identifier the collar whose boundary is being set.
|
||||||
|
* @param coordinates the list of coordinates representing, in order, the new grazing boundary's vertices.
|
||||||
|
* @param listener the listener to be called when an OK response is received.
|
||||||
|
* @param error the error listener.
|
||||||
|
*/
|
||||||
|
class SetBoundaryRequest(
|
||||||
|
baseUrl: String,
|
||||||
|
token: String,
|
||||||
|
identifier: Int,
|
||||||
|
coordinates: List<CollarPos>,
|
||||||
|
listener: Response.Listener<String>,
|
||||||
|
error: Response.ErrorListener
|
||||||
|
) : AuthenticatedPostRequest<List<CollarPos>>(
|
||||||
|
coordinates, baseUrl, "/collars/${identifier}/boundary/set",
|
||||||
|
token, listener, error
|
||||||
|
)
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
package com.danilafe.fencelessgrazing.ui.activities
|
||||||
|
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.os.PersistableBundle
|
||||||
|
import android.view.View
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.appcompat.app.AlertDialog
|
||||||
|
import com.android.volley.RequestQueue
|
||||||
|
import com.android.volley.Response
|
||||||
|
import com.android.volley.toolbox.Volley
|
||||||
|
import com.danilafe.fencelessgrazing.R
|
||||||
|
import com.danilafe.fencelessgrazing.model.CollarPos
|
||||||
|
import com.danilafe.fencelessgrazing.requests.authenticated.SetBoundaryRequest
|
||||||
|
import com.danilafe.fencelessgrazing.ui.components.GrazingPolygon
|
||||||
|
import org.osmdroid.util.GeoPoint
|
||||||
|
import org.osmdroid.views.MapView
|
||||||
|
import org.osmdroid.views.overlay.Marker
|
||||||
|
import org.osmdroid.views.overlay.Polygon
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Activity used to update a grazing boundary for a particular collar.
|
||||||
|
*/
|
||||||
|
class BoundaryEditorActivity : AppCompatActivity(),
|
||||||
|
Marker.OnMarkerDragListener,
|
||||||
|
Marker.OnMarkerClickListener {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The map displaying the boundary vertices and polygon.
|
||||||
|
*/
|
||||||
|
private lateinit var map: MapView
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The list of markers representing the new grazing boundary's edges.
|
||||||
|
*/
|
||||||
|
private val markers: MutableList<Marker> = mutableListOf()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The polygon used to display the grazing area on the [map].
|
||||||
|
*/
|
||||||
|
private lateinit var polygon: GrazingPolygon
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The center around which the [map] was originally centered,
|
||||||
|
* used also for creating new vertices.
|
||||||
|
*/
|
||||||
|
private lateinit var center: GeoPoint
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The identifier of the collar whose boundary is being changed.
|
||||||
|
*/
|
||||||
|
private var identifier: Int = -1
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Volley queue used for sending API requests.
|
||||||
|
*/
|
||||||
|
private lateinit var queue: RequestQueue
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
setContentView(R.layout.activity_boundary_editor)
|
||||||
|
|
||||||
|
val requestedCenter = intent.getParcelableExtra<CollarPos>("center")!!
|
||||||
|
center = requestedCenter.toGeoPoint()
|
||||||
|
identifier = intent.getIntExtra("identifier", -1)
|
||||||
|
queue = Volley.newRequestQueue(applicationContext)
|
||||||
|
|
||||||
|
map = findViewById(R.id.editorMap)
|
||||||
|
polygon = GrazingPolygon(map)
|
||||||
|
map.overlays.add(polygon)
|
||||||
|
map.controller.setZoom(9.5)
|
||||||
|
map.controller.setCenter(center)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes a marker from the [map].
|
||||||
|
*/
|
||||||
|
private fun removeMarker(m: Marker) {
|
||||||
|
markers.remove(m)
|
||||||
|
map.overlays.remove(m)
|
||||||
|
updatePolygon()
|
||||||
|
map.invalidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends the current list of points to the API endpoint, closing
|
||||||
|
* the activity if successful.
|
||||||
|
*/
|
||||||
|
fun sendBoundary(v: View) {
|
||||||
|
val request = SetBoundaryRequest(
|
||||||
|
getString(R.string.apiUrl),
|
||||||
|
getSharedPreferences("FencelessGrazing",0).getString("token",null)!!,
|
||||||
|
identifier, markers.map {
|
||||||
|
CollarPos(it.position.longitude.toString(), it.position.latitude.toString())
|
||||||
|
},
|
||||||
|
Response.Listener<String> {
|
||||||
|
finish()
|
||||||
|
},
|
||||||
|
Response.ErrorListener {
|
||||||
|
Toast.makeText(
|
||||||
|
this,
|
||||||
|
"Failed to update grazing boundaries",
|
||||||
|
Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
queue.add(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a new marker to the [map], starting it at the previously-configured
|
||||||
|
* [center].
|
||||||
|
*/
|
||||||
|
fun addMarker(v : View) {
|
||||||
|
if(markers.size >= 10) {
|
||||||
|
Toast.makeText(
|
||||||
|
this,
|
||||||
|
"Cannot add more than ten vertices to grazing boundary",
|
||||||
|
Toast.LENGTH_SHORT).show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val newMarker = Marker(map)
|
||||||
|
newMarker.isDraggable = true
|
||||||
|
newMarker.setOnMarkerClickListener(this)
|
||||||
|
newMarker.setOnMarkerDragListener(this)
|
||||||
|
newMarker.position = center
|
||||||
|
markers.add(newMarker)
|
||||||
|
map.overlays.add(newMarker)
|
||||||
|
updatePolygon()
|
||||||
|
map.invalidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensures that the polygon's vertices are synchronized
|
||||||
|
* with the user-placed markers, making it so that the
|
||||||
|
* grazing area is visible to the user.
|
||||||
|
*/
|
||||||
|
private fun updatePolygon() {
|
||||||
|
polygon.points.clear()
|
||||||
|
if(markers.isEmpty()) return
|
||||||
|
val newPoints = markers.map { it.position }
|
||||||
|
polygon.points = newPoints
|
||||||
|
polygon.points.add(newPoints.first())
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onMarkerDragEnd(marker: Marker?) {
|
||||||
|
updatePolygon()
|
||||||
|
map.invalidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onMarkerDragStart(marker: Marker?) {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onMarkerDrag(marker: Marker?) {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onMarkerClick(m: Marker, mv: MapView): Boolean {
|
||||||
|
AlertDialog.Builder(this)
|
||||||
|
.setTitle("Delete Marker")
|
||||||
|
.setMessage("Do you want to delete this marker?")
|
||||||
|
.setPositiveButton("Delete") { _, _ -> removeMarker(m) }
|
||||||
|
.setNegativeButton("Cancel") { d, _ -> d.dismiss() }
|
||||||
|
.show()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
package com.danilafe.fencelessgrazing.ui.activities
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.graphics.Color
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.util.Log
|
||||||
|
import android.view.View
|
||||||
|
import android.widget.TextView
|
||||||
|
import android.widget.Toast
|
||||||
|
import com.android.volley.RequestQueue
|
||||||
|
import com.android.volley.Response
|
||||||
|
import com.android.volley.toolbox.Volley
|
||||||
|
import com.danilafe.fencelessgrazing.R
|
||||||
|
import com.danilafe.fencelessgrazing.model.CollarPos
|
||||||
|
import com.danilafe.fencelessgrazing.model.Polygon
|
||||||
|
import com.danilafe.fencelessgrazing.requests.authenticated.CollarDetailRequest
|
||||||
|
import com.danilafe.fencelessgrazing.requests.authenticated.CollarHistoryRequest
|
||||||
|
import com.danilafe.fencelessgrazing.ui.components.GrazingPolygon
|
||||||
|
import org.osmdroid.util.GeoPoint
|
||||||
|
import org.osmdroid.views.MapView
|
||||||
|
import org.osmdroid.views.overlay.Marker
|
||||||
|
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: GrazingPolygon
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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)
|
||||||
|
else clearBoundingBox()
|
||||||
|
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)
|
||||||
|
else clearAnimalHistory()
|
||||||
|
field = newValue
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
setContentView(R.layout.activity_collar_detail)
|
||||||
|
|
||||||
|
findViews()
|
||||||
|
|
||||||
|
token = getSharedPreferences("FencelessGrazing", 0).getString("token", null)!!
|
||||||
|
collarId = intent.getIntExtra("identifier", -1)
|
||||||
|
queue = Volley.newRequestQueue(applicationContext)
|
||||||
|
|
||||||
|
map = findViewById(R.id.detailMap)
|
||||||
|
map.controller.setZoom(9.5)
|
||||||
|
mapPolygon = GrazingPolygon(map)
|
||||||
|
mapMarker = Marker(map)
|
||||||
|
mapPolyline = Polyline(map)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPause() {
|
||||||
|
super.onPause()
|
||||||
|
map.onPause()
|
||||||
|
refreshTimer.cancel()
|
||||||
|
refreshTimer.purge()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
map.onResume()
|
||||||
|
refreshTimer = Timer()
|
||||||
|
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 {
|
||||||
|
dataPoints =
|
||||||
|
it.map { p -> p.toGeoPoint() }
|
||||||
|
},
|
||||||
|
Response.ErrorListener {
|
||||||
|
Toast.makeText(this, "Failed to retrieve history of collar", Toast.LENGTH_SHORT)
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
val detailRequest =
|
||||||
|
CollarDetailRequest(getString(R.string.apiUrl),
|
||||||
|
collarId,
|
||||||
|
token,
|
||||||
|
Response.Listener {
|
||||||
|
collarName.text = it.name
|
||||||
|
collarStimulus.text = getString(R.string.collarSummaryStimulus, it.stimulus)
|
||||||
|
boundingBox = if (it.boundary.size >= 3) Polygon(it.boundary) else null
|
||||||
|
},
|
||||||
|
Response.ErrorListener {
|
||||||
|
Toast.makeText(this, "Failed to retrieve details of collar", Toast.LENGTH_SHORT)
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
queue.add(historyRequest)
|
||||||
|
queue.add(detailRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the [BoundaryEditorActivity] to edit the current collar's grazing boundary.
|
||||||
|
*/
|
||||||
|
fun changeGrazingBoundary(v : View) {
|
||||||
|
startActivity(Intent(this, BoundaryEditorActivity::class.java).apply {
|
||||||
|
val center = dataPoints.lastOrNull()?.let {
|
||||||
|
CollarPos(it.longitude.toString(), it.latitude.toString())
|
||||||
|
} ?: CollarPos("0", "0")
|
||||||
|
putExtra("center", center)
|
||||||
|
putExtra("identifier", collarId)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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)
|
||||||
|
if(!map.overlays.contains(mapMarker)) map.overlays.add(mapMarker)
|
||||||
|
if(!map.overlays.contains(mapPolyline)) map.overlays.add(mapPolyline)
|
||||||
|
mapMarker.position = currentPoint
|
||||||
|
mapPolyline.setPoints(points)
|
||||||
|
if(!centerSet) {
|
||||||
|
centerSet = true
|
||||||
|
map.controller.setCenter(currentPoint)
|
||||||
|
}
|
||||||
|
map.invalidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If an animal's history is empty, hides all the elements
|
||||||
|
* that depend on said history from the [map].
|
||||||
|
*/
|
||||||
|
private fun clearAnimalHistory() {
|
||||||
|
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 { it.toGeoPoint() }
|
||||||
|
val polygonPoints = points.toMutableList()
|
||||||
|
polygonPoints.add(polygonPoints[0])
|
||||||
|
mapPolygon.points = polygonPoints
|
||||||
|
map.invalidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If an animal's bounding box is not set, hides
|
||||||
|
* the [mapPolygon] from the [map].
|
||||||
|
*/
|
||||||
|
private fun clearBoundingBox() {
|
||||||
|
Log.d("FencelessGrazing", "Clearing...")
|
||||||
|
map.overlays.remove(mapPolygon)
|
||||||
|
map.invalidate()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,9 @@
|
|||||||
package com.danilafe.fencelessgrazing
|
package com.danilafe.fencelessgrazing.ui.activities
|
||||||
|
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.widget.Button
|
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.preference.PreferenceManager
|
import androidx.preference.PreferenceManager
|
||||||
import androidx.recyclerview.widget.DividerItemDecoration
|
import androidx.recyclerview.widget.DividerItemDecoration
|
||||||
@@ -13,8 +12,11 @@ import androidx.recyclerview.widget.RecyclerView
|
|||||||
import com.android.volley.RequestQueue
|
import com.android.volley.RequestQueue
|
||||||
import com.android.volley.Response
|
import com.android.volley.Response
|
||||||
import com.android.volley.toolbox.Volley
|
import com.android.volley.toolbox.Volley
|
||||||
|
import com.danilafe.fencelessgrazing.ui.components.CollarClickListener
|
||||||
|
import com.danilafe.fencelessgrazing.ui.components.CollarSummaryAdapter
|
||||||
|
import com.danilafe.fencelessgrazing.R
|
||||||
import com.danilafe.fencelessgrazing.model.CollarSummary
|
import com.danilafe.fencelessgrazing.model.CollarSummary
|
||||||
import com.danilafe.fencelessgrazing.requests.CollarRequest
|
import com.danilafe.fencelessgrazing.requests.authenticated.CollarRequest
|
||||||
import org.osmdroid.config.Configuration
|
import org.osmdroid.config.Configuration
|
||||||
import org.osmdroid.tileprovider.tilesource.TileSourceFactory
|
import org.osmdroid.tileprovider.tilesource.TileSourceFactory
|
||||||
import org.osmdroid.util.GeoPoint
|
import org.osmdroid.util.GeoPoint
|
||||||
@@ -24,21 +26,54 @@ import java.util.*
|
|||||||
import kotlin.concurrent.timerTask
|
import kotlin.concurrent.timerTask
|
||||||
|
|
||||||
class CollarListActivity : AppCompatActivity() {
|
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()
|
private val summaries : MutableList<CollarSummary> = mutableListOf()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The adapter used to display the [summaries] on the screen.
|
||||||
|
*/
|
||||||
private lateinit var summaryAdapter: CollarSummaryAdapter
|
private lateinit var summaryAdapter: CollarSummaryAdapter
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The view of the summaries backed by the [summaryAdapter].
|
||||||
|
*/
|
||||||
private lateinit var collarList: RecyclerView
|
private lateinit var collarList: RecyclerView
|
||||||
|
|
||||||
// The API token and request queue.
|
/**
|
||||||
|
* The API token and request queue.
|
||||||
|
*/
|
||||||
private lateinit var token: String
|
private lateinit var token: String
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Volley queue used for sending API requests.
|
||||||
|
*/
|
||||||
private lateinit var queue: RequestQueue
|
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
|
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
|
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()
|
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()
|
private var refreshTimer = Timer()
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
@@ -72,30 +107,50 @@ class CollarListActivity : AppCompatActivity() {
|
|||||||
refreshTimer.purge()
|
refreshTimer.purge()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Method called by the "open statistics" button. Opens the statistics
|
||||||
|
* activity.
|
||||||
|
*/
|
||||||
fun openStatistics(view: View) {
|
fun openStatistics(view: View) {
|
||||||
startActivity(Intent(this, StatisticsActivity::class.java))
|
startActivity(Intent(this, StatisticsActivity::class.java))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends the request to the API to retrieve an updated list of active collars.
|
||||||
|
*/
|
||||||
private fun triggerRefresh() {
|
private fun triggerRefresh() {
|
||||||
val request = CollarRequest(getString(R.string.apiUrl), token,
|
val request =
|
||||||
Response.Listener {
|
CollarRequest(getString(R.string.apiUrl),
|
||||||
summaries.clear()
|
token,
|
||||||
summaries.addAll(it)
|
Response.Listener {
|
||||||
summaryAdapter.notifyDataSetChanged()
|
summaries.clear()
|
||||||
updateMap()
|
summaries.addAll(it)
|
||||||
},
|
summaryAdapter.notifyDataSetChanged()
|
||||||
Response.ErrorListener {
|
updateMap()
|
||||||
Toast.makeText(this, "Failed to retrieve collar list!", Toast.LENGTH_SHORT).show()
|
},
|
||||||
}
|
Response.ErrorListener {
|
||||||
)
|
Toast.makeText(this, "Failed to retrieve collar list!", Toast.LENGTH_SHORT)
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
)
|
||||||
queue.add(request)
|
queue.add(request)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Locates all the views in the activity, setting the respective
|
||||||
|
* `lateinit` properties.
|
||||||
|
*/
|
||||||
private fun findViews() {
|
private fun findViews() {
|
||||||
collarList = findViewById(R.id.collarSummaryList)
|
collarList = findViewById(R.id.collarSummaryList)
|
||||||
map = findViewById(R.id.map)
|
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) {
|
private fun startCollarDetailActivity(collar: CollarSummary) {
|
||||||
val newIntent = Intent(this, CollarDetailActivity::class.java).apply {
|
val newIntent = Intent(this, CollarDetailActivity::class.java).apply {
|
||||||
putExtra("identifier", collar.id)
|
putExtra("identifier", collar.id)
|
||||||
@@ -103,40 +158,57 @@ class CollarListActivity : AppCompatActivity() {
|
|||||||
startActivity(newIntent)
|
startActivity(newIntent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configures the [collarList] with the [summaryAdapter], allowing it to
|
||||||
|
* properly display and handle clicks on the items.
|
||||||
|
*/
|
||||||
private fun setupCollarList() {
|
private fun setupCollarList() {
|
||||||
val layoutManager = LinearLayoutManager(collarList.context)
|
val layoutManager = LinearLayoutManager(collarList.context)
|
||||||
summaryAdapter = CollarSummaryAdapter(summaries, object : CollarClickListener {
|
summaryAdapter =
|
||||||
override fun onCollarClick(collar: CollarSummary?) {
|
CollarSummaryAdapter(
|
||||||
if (collar == null) return
|
summaries,
|
||||||
startCollarDetailActivity(collar)
|
object :
|
||||||
}
|
CollarClickListener {
|
||||||
})
|
override fun onCollarClick(collar: CollarSummary?) {
|
||||||
|
if (collar == null) return
|
||||||
|
startCollarDetailActivity(collar)
|
||||||
|
}
|
||||||
|
})
|
||||||
collarList.adapter = summaryAdapter
|
collarList.adapter = summaryAdapter
|
||||||
collarList.layoutManager = layoutManager
|
collarList.layoutManager = layoutManager
|
||||||
collarList.addItemDecoration(DividerItemDecoration(collarList.context, layoutManager.orientation))
|
collarList.addItemDecoration(DividerItemDecoration(collarList.context, layoutManager.orientation))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the map with new collar information.
|
||||||
|
*/
|
||||||
private fun updateMap() {
|
private fun updateMap() {
|
||||||
updateMapCenter()
|
updateMapCenter()
|
||||||
updateMapOverlay()
|
updateMapOverlay()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the map's center to the average location of the animals.
|
||||||
|
*/
|
||||||
private fun updateMapCenter() {
|
private fun updateMapCenter() {
|
||||||
if(centerSet) return
|
if(centerSet) return
|
||||||
|
|
||||||
centerSet = true
|
centerSet = true
|
||||||
val averageLongitude = summaries.map { it.pos.longitude.toDouble() }.average()
|
val averageLongitude = summaries.map { it.pos.longitude.toDouble() }.average()
|
||||||
val averageLatitude = summaries.map { it.pos.latitude.toDouble() }.average()
|
val averageLatitude = summaries.map { it.pos.latitude.toDouble() }.average()
|
||||||
map.controller.setCenter(GeoPoint(averageLongitude, averageLatitude))
|
map.controller.setCenter(GeoPoint(averageLatitude, averageLongitude))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the markers on the map with the animals' new locations.
|
||||||
|
*/
|
||||||
private fun updateMapOverlay() {
|
private fun updateMapOverlay() {
|
||||||
val currentSet = mutableSetOf<Int>()
|
val currentSet = mutableSetOf<Int>()
|
||||||
summaries.forEach {
|
summaries.forEach {
|
||||||
// Create or update overlay
|
// Create or update overlay
|
||||||
val overlay = collarOverlays[it.id] ?: Marker(map)
|
val overlay = collarOverlays[it.id] ?: Marker(map)
|
||||||
overlay.title = it.name
|
overlay.title = it.name
|
||||||
overlay.position = GeoPoint(it.pos.longitude.toDouble(), it.pos.latitude.toDouble())
|
overlay.position = it.pos.toGeoPoint()
|
||||||
|
|
||||||
// Store new / existing overlay.
|
// Store new / existing overlay.
|
||||||
if(!collarOverlays.containsKey(it.id)) map.overlays.add(overlay)
|
if(!collarOverlays.containsKey(it.id)) map.overlays.add(overlay)
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
package com.danilafe.fencelessgrazing.ui.activities
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.widget.Toast
|
||||||
|
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.R
|
||||||
|
import com.danilafe.fencelessgrazing.model.CollarDistance
|
||||||
|
import com.danilafe.fencelessgrazing.requests.authenticated.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(
|
||||||
|
inflater: LayoutInflater,
|
||||||
|
container: ViewGroup?,
|
||||||
|
savedInstanceState: Bundle?
|
||||||
|
): View? {
|
||||||
|
val view = inflater.inflate(R.layout.distance_traveled_layoyt, container, false)
|
||||||
|
distanceTraveledChart = view.findViewById(R.id.distanceTraveledChart)
|
||||||
|
setupChart()
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
queue = Volley.newRequestQueue(requireActivity().applicationContext)
|
||||||
|
|
||||||
|
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 {
|
||||||
|
updateChartData(it)
|
||||||
|
},
|
||||||
|
Response.ErrorListener {
|
||||||
|
Toast.makeText(
|
||||||
|
activity,
|
||||||
|
"Failed to retrieve distance traveled!",
|
||||||
|
Toast.LENGTH_SHORT
|
||||||
|
).show()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.danilafe.fencelessgrazing
|
package com.danilafe.fencelessgrazing.ui.activities
|
||||||
|
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
@@ -8,15 +8,27 @@ import android.widget.TextView
|
|||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import com.android.volley.Response
|
import com.android.volley.Response
|
||||||
import com.android.volley.toolbox.Volley
|
import com.android.volley.toolbox.Volley
|
||||||
|
import com.danilafe.fencelessgrazing.R
|
||||||
import com.danilafe.fencelessgrazing.requests.LoginRequest
|
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() {
|
class MainActivity : AppCompatActivity() {
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
setContentView(R.layout.activity_main)
|
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) {
|
fun attemptLogin(view: View) {
|
||||||
val usernameField: TextView = findViewById(R.id.username)
|
val usernameField: TextView = findViewById(R.id.username)
|
||||||
val passwordField: TextView = findViewById(R.id.password)
|
val passwordField: TextView = findViewById(R.id.password)
|
||||||
@@ -30,12 +42,19 @@ class MainActivity : AppCompatActivity() {
|
|||||||
editor.putString("token", it.token)
|
editor.putString("token", it.token)
|
||||||
editor.apply()
|
editor.apply()
|
||||||
|
|
||||||
val newIntent = Intent(this, CollarListActivity::class.java)
|
startCollarActivity()
|
||||||
startActivity(newIntent)
|
|
||||||
},
|
},
|
||||||
Response.ErrorListener {
|
Response.ErrorListener {
|
||||||
Toast.makeText(this, "Failed to log in! $it", Toast.LENGTH_LONG).show()
|
Toast.makeText(this, "Failed to log in! $it", Toast.LENGTH_LONG).show()
|
||||||
})
|
})
|
||||||
requestQueue.add(loginRequest)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,19 +1,32 @@
|
|||||||
package com.danilafe.fencelessgrazing
|
package com.danilafe.fencelessgrazing.ui.activities
|
||||||
|
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.fragment.app.FragmentActivity
|
|
||||||
import androidx.viewpager.widget.ViewPager
|
|
||||||
import androidx.viewpager2.widget.ViewPager2
|
import androidx.viewpager2.widget.ViewPager2
|
||||||
|
import com.danilafe.fencelessgrazing.R
|
||||||
|
import com.danilafe.fencelessgrazing.ui.components.StatisticsGraphAdapter
|
||||||
import com.google.android.material.tabs.TabLayout
|
import com.google.android.material.tabs.TabLayout
|
||||||
import com.google.android.material.tabs.TabLayoutMediator
|
import com.google.android.material.tabs.TabLayoutMediator
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Activity containing various graphs created from collar data on the server.
|
||||||
|
*/
|
||||||
class StatisticsActivity : AppCompatActivity() {
|
class StatisticsActivity : AppCompatActivity() {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tab layout allowing users to switch between graphs.
|
||||||
|
*/
|
||||||
private lateinit var tabLayout: TabLayout
|
private lateinit var tabLayout: TabLayout
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The view pager providing swiping functionality between views.
|
||||||
|
*/
|
||||||
private lateinit var viewPager: ViewPager2
|
private lateinit var viewPager: ViewPager2
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
/**
|
||||||
|
* The list of tab names, in the order they appear in the [viewPager].
|
||||||
|
*/
|
||||||
val tabNames = arrayOf("Distance Traveled")
|
val tabNames = arrayOf("Distance Traveled")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,7 +37,10 @@ class StatisticsActivity : AppCompatActivity() {
|
|||||||
tabLayout = findViewById(R.id.statisticsTabs)
|
tabLayout = findViewById(R.id.statisticsTabs)
|
||||||
viewPager = findViewById(R.id.statisticsPager)
|
viewPager = findViewById(R.id.statisticsPager)
|
||||||
|
|
||||||
viewPager.adapter = StatisticsGraphAdapter(this)
|
viewPager.adapter =
|
||||||
|
StatisticsGraphAdapter(
|
||||||
|
this
|
||||||
|
)
|
||||||
TabLayoutMediator(tabLayout, viewPager) { tab, position ->
|
TabLayoutMediator(tabLayout, viewPager) { tab, position ->
|
||||||
tab.text = tabNames[position]
|
tab.text = tabNames[position]
|
||||||
}.attach()
|
}.attach()
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package com.danilafe.fencelessgrazing.ui.components
|
||||||
|
|
||||||
|
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?)
|
||||||
|
}
|
||||||
@@ -1,22 +1,32 @@
|
|||||||
package com.danilafe.fencelessgrazing
|
package com.danilafe.fencelessgrazing.ui.components
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.Toast
|
|
||||||
import androidx.recyclerview.widget.ListAdapter
|
import androidx.recyclerview.widget.ListAdapter
|
||||||
import androidx.recyclerview.widget.DiffUtil
|
import androidx.recyclerview.widget.DiffUtil
|
||||||
import com.android.volley.Response
|
import com.danilafe.fencelessgrazing.R
|
||||||
import com.android.volley.toolbox.Volley
|
|
||||||
import com.danilafe.fencelessgrazing.model.CollarSummary
|
import com.danilafe.fencelessgrazing.model.CollarSummary
|
||||||
import com.danilafe.fencelessgrazing.requests.CollarRequest
|
import com.danilafe.fencelessgrazing.requests.authenticated.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(
|
class CollarSummaryAdapter(
|
||||||
private val items: List<CollarSummary>,
|
private val items: List<CollarSummary>,
|
||||||
private val collarClickListener: CollarClickListener
|
private val collarClickListener: CollarClickListener
|
||||||
) : ListAdapter<CollarSummary, CollarViewHolder>(DiffCallback()) {
|
) : 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
|
override fun areItemsTheSame(oldItem: CollarSummary, newItem: CollarSummary): Boolean
|
||||||
= oldItem.id == newItem.id
|
= oldItem.id == newItem.id
|
||||||
|
|
||||||
@@ -26,14 +36,17 @@ class CollarSummaryAdapter(
|
|||||||
|
|
||||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CollarViewHolder {
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CollarViewHolder {
|
||||||
val layout = LayoutInflater.from(parent.context).inflate(viewType, parent, false)
|
val layout = LayoutInflater.from(parent.context).inflate(viewType, parent, false)
|
||||||
return CollarViewHolder(layout)
|
return CollarViewHolder(
|
||||||
|
layout
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onBindViewHolder(holder: CollarViewHolder, position: Int) {
|
override fun onBindViewHolder(holder: CollarViewHolder, position: Int) {
|
||||||
holder.bindData(getItem(position), collarClickListener)
|
holder.bindData(getItem(position), collarClickListener)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getItemViewType(position: Int): Int = R.layout.collar_summary_layout
|
override fun getItemViewType(position: Int): Int =
|
||||||
|
R.layout.collar_summary_layout
|
||||||
|
|
||||||
override fun getItem(position: Int): CollarSummary = items[position]
|
override fun getItem(position: Int): CollarSummary = items[position]
|
||||||
|
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package com.danilafe.fencelessgrazing.ui.components
|
||||||
|
|
||||||
|
import android.view.View
|
||||||
|
import android.widget.TextView
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import com.danilafe.fencelessgrazing.R
|
||||||
|
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 = itemView.resources.getString(
|
||||||
|
R.string.collarSummaryLocation,
|
||||||
|
summary.pos.longitude.toDouble(), summary.pos.latitude.toDouble())
|
||||||
|
itemView.setOnClickListener {
|
||||||
|
collarClickListener.onCollarClick(summary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package com.danilafe.fencelessgrazing.ui.components
|
||||||
|
|
||||||
|
import android.graphics.Color
|
||||||
|
import org.osmdroid.views.MapView
|
||||||
|
import org.osmdroid.views.overlay.Polygon
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OpenStreetMaps polygon with some default visual settings for the Fenceless Grazing System.
|
||||||
|
*/
|
||||||
|
class GrazingPolygon(map: MapView) : Polygon(map) {
|
||||||
|
init {
|
||||||
|
fillPaint.color = Color.parseColor("#32a852")
|
||||||
|
fillPaint.alpha = 127
|
||||||
|
outlinePaint.color = Color.parseColor("#227539")
|
||||||
|
outlinePaint.strokeWidth = 0.0f
|
||||||
|
title = "Valid Grazing Area"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package com.danilafe.fencelessgrazing.ui.components
|
||||||
|
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import androidx.fragment.app.FragmentActivity
|
||||||
|
import androidx.viewpager2.adapter.FragmentStateAdapter
|
||||||
|
import com.danilafe.fencelessgrazing.ui.activities.DistanceTraveledGraph
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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()
|
||||||
|
}
|
||||||
46
app/src/main/res/layout/activity_boundary_editor.xml
Normal file
46
app/src/main/res/layout/activity_boundary_editor.xml
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
tools:context=".ui.activities.BoundaryEditorActivity">
|
||||||
|
|
||||||
|
<org.osmdroid.views.MapView
|
||||||
|
android:id="@+id/editorMap"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="0dp"
|
||||||
|
app:layout_constraintBottom_toTopOf="@id/saveButtonLayout"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="parent">
|
||||||
|
|
||||||
|
</org.osmdroid.views.MapView>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/saveButtonLayout"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent">
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:onClick="sendBoundary"
|
||||||
|
android:id="@+id/saveBoundaryButton"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:text="@string/saveBoundary" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:onClick="addMarker"
|
||||||
|
android:id="@+id/addVertexButton"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:text="@string/addVertex" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
xmlns:tools="http://schemas.android.com/tools"
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
tools:context=".CollarDetailActivity">
|
tools:context=".ui.activities.CollarDetailActivity">
|
||||||
<org.osmdroid.views.MapView
|
<org.osmdroid.views.MapView
|
||||||
android:id="@+id/detailMap"
|
android:id="@+id/detailMap"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
@@ -29,9 +29,8 @@
|
|||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginStart="10dp"
|
android:layout_marginStart="10dp"
|
||||||
android:layout_marginTop="10dp"
|
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
app:layout_constraintTop_toBottomOf="@+id/collarName" />
|
app:layout_constraintTop_toBottomOf="@+id/changeBoundaryButton" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/collarStimulus"
|
android:id="@+id/collarStimulus"
|
||||||
@@ -42,4 +41,14 @@
|
|||||||
app:layout_constraintStart_toStartOf="parent"
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
app:layout_constraintTop_toBottomOf="@+id/collarPos" />
|
app:layout_constraintTop_toBottomOf="@+id/collarPos" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/changeBoundaryButton"
|
||||||
|
style="@style/Widget.AppCompat.Button.Colored"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:onClick="changeGrazingBoundary"
|
||||||
|
android:text="@string/changeGrazingBoundary"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toBottomOf="@+id/collarName" />
|
||||||
|
|
||||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
xmlns:tools="http://schemas.android.com/tools"
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
tools:context=".CollarListActivity">
|
tools:context=".ui.activities.CollarListActivity">
|
||||||
|
|
||||||
<org.osmdroid.views.MapView
|
<org.osmdroid.views.MapView
|
||||||
android:id="@+id/map"
|
android:id="@+id/map"
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
xmlns:tools="http://schemas.android.com/tools"
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
tools:context=".MainActivity">
|
tools:context=".ui.activities.MainActivity">
|
||||||
|
|
||||||
<EditText
|
<EditText
|
||||||
android:id="@+id/username"
|
android:id="@+id/username"
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
xmlns:tools="http://schemas.android.com/tools"
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
tools:context=".StatisticsActivity">
|
tools:context=".ui.activities.StatisticsActivity">
|
||||||
|
|
||||||
<com.google.android.material.tabs.TabLayout
|
<com.google.android.material.tabs.TabLayout
|
||||||
android:id="@+id/statisticsTabs"
|
android:id="@+id/statisticsTabs"
|
||||||
|
|||||||
@@ -8,4 +8,7 @@
|
|||||||
<string name="collarSummaryStimulus">Required %1d stimuli in the last 24 hours</string>
|
<string name="collarSummaryStimulus">Required %1d stimuli in the last 24 hours</string>
|
||||||
<string name="distanceTraveled">Distance Traveled</string>
|
<string name="distanceTraveled">Distance Traveled</string>
|
||||||
<string name="viewStatistics">View Statistics</string>
|
<string name="viewStatistics">View Statistics</string>
|
||||||
|
<string name="saveBoundary">Save Boundary</string>
|
||||||
|
<string name="addVertex">Add Vertex</string>
|
||||||
|
<string name="changeGrazingBoundary">Change Grazing Boundary</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
3
gradle/wrapper/gradle-wrapper.properties
vendored
3
gradle/wrapper/gradle-wrapper.properties
vendored
@@ -1,6 +1,5 @@
|
|||||||
#Sat Feb 01 14:34:24 PST 2020
|
|
||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-6.4-all.zip
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
zipStorePath=wrapper/dists
|
zipStorePath=wrapper/dists
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-5.4.1-all.zip
|
|
||||||
|
|||||||
22
gradlew
vendored
22
gradlew
vendored
@@ -1,5 +1,21 @@
|
|||||||
#!/usr/bin/env sh
|
#!/usr/bin/env sh
|
||||||
|
|
||||||
|
#
|
||||||
|
# Copyright 2015 the original author or authors.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
#
|
||||||
|
|
||||||
##############################################################################
|
##############################################################################
|
||||||
##
|
##
|
||||||
## Gradle start up script for UN*X
|
## Gradle start up script for UN*X
|
||||||
@@ -28,7 +44,7 @@ APP_NAME="Gradle"
|
|||||||
APP_BASE_NAME=`basename "$0"`
|
APP_BASE_NAME=`basename "$0"`
|
||||||
|
|
||||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
DEFAULT_JVM_OPTS=""
|
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||||
|
|
||||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||||
MAX_FD="maximum"
|
MAX_FD="maximum"
|
||||||
@@ -109,8 +125,8 @@ if $darwin; then
|
|||||||
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
|
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# For Cygwin, switch paths to Windows format before running java
|
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||||
if $cygwin ; then
|
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
|
||||||
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
|
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
|
||||||
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
|
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
|
||||||
JAVACMD=`cygpath --unix "$JAVACMD"`
|
JAVACMD=`cygpath --unix "$JAVACMD"`
|
||||||
|
|||||||
18
gradlew.bat
vendored
18
gradlew.bat
vendored
@@ -1,3 +1,19 @@
|
|||||||
|
@rem
|
||||||
|
@rem Copyright 2015 the original author or authors.
|
||||||
|
@rem
|
||||||
|
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
@rem you may not use this file except in compliance with the License.
|
||||||
|
@rem You may obtain a copy of the License at
|
||||||
|
@rem
|
||||||
|
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
@rem
|
||||||
|
@rem Unless required by applicable law or agreed to in writing, software
|
||||||
|
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
@rem See the License for the specific language governing permissions and
|
||||||
|
@rem limitations under the License.
|
||||||
|
@rem
|
||||||
|
|
||||||
@if "%DEBUG%" == "" @echo off
|
@if "%DEBUG%" == "" @echo off
|
||||||
@rem ##########################################################################
|
@rem ##########################################################################
|
||||||
@rem
|
@rem
|
||||||
@@ -14,7 +30,7 @@ set APP_BASE_NAME=%~n0
|
|||||||
set APP_HOME=%DIRNAME%
|
set APP_HOME=%DIRNAME%
|
||||||
|
|
||||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
set DEFAULT_JVM_OPTS=
|
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||||
|
|
||||||
@rem Find java.exe
|
@rem Find java.exe
|
||||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||||
|
|||||||
Reference in New Issue
Block a user