Compare commits

...

19 Commits

Author SHA1 Message Date
31885f5a73 Add Google Doc link. 2020-05-15 21:58:10 -07:00
3bcf1ff545 Add code review remarks. 2020-05-15 21:35:53 -07:00
b0e8b7fe1c Fix documentation typo 2020-05-14 15:29:24 -07:00
a4f29c4325 Add more instructions. 2020-05-14 01:35:22 -07:00
d7b227958c Add README 2020-05-14 01:29:26 -07:00
006aac384f Update wrapper 2020-05-14 01:26:49 -07:00
33af724fbf Add some comments. 2020-05-14 01:20:10 -07:00
9a75af4045 Ensure consistency of longitude/latitude order and add a button to set boundary in collar details. 2020-05-14 00:43:32 -07:00
03c55f370e Add missing XML file. 2020-05-14 00:12:01 -07:00
efb3921dc2 Display grazing area from detail response. 2020-05-13 23:54:28 -07:00
48a6d1fff1 Get the boundary setting activity working. 2020-05-13 23:53:08 -07:00
7cd3706b7d Rename the authenticated request. 2020-05-13 23:52:00 -07:00
e13572ae67 Move stuff into more granular packages. 2020-05-13 19:14:32 -07:00
1cb22bf4a0 Adjust distance traveled graph. 2020-05-13 16:55:23 -07:00
9ab9377201 Document activities and fragments. 2020-05-13 16:47:16 -07:00
b48317c0c0 Document UI components that are not activities or fragments. 2020-05-13 15:58:17 -07:00
ce0096d94f Document the requests package. 2020-05-13 15:41:50 -07:00
5d0ec6738d Refactor request system to hide away some of the generics cruft. 2020-05-13 15:04:48 -07:00
b18890851c Start working on documentation. 2020-05-13 13:36:32 -07:00
49 changed files with 1260 additions and 443 deletions

27
README.md Normal file
View 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
View 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.|

View File

@@ -4,6 +4,17 @@ apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'
buildscript {
repositories {
jcenter()
}
dependencies {
classpath "org.jetbrains.dokka:dokka-gradle-plugin:0.10.1"
}
}
apply plugin: 'org.jetbrains.dokka'
android {
compileSdkVersion 29
buildToolsVersion "29.0.2"
@@ -23,6 +34,15 @@ android {
}
}
dokka {
outputFormat = 'html'
outputDirectory = "$buildDir/dokka"
configuration {
includeNonPublic = true
}
}
repositories {
mavenCentral()
google()

View File

@@ -13,10 +13,11 @@
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme">
<activity android:name=".StatisticsActivity"/>
<activity android:name=".CollarDetailActivity" />
<activity android:name=".CollarListActivity" />
<activity android:name=".MainActivity">
<activity android:name=".ui.activities.BoundaryEditorActivity"></activity>
<activity android:name=".ui.activities.StatisticsActivity" />
<activity android:name=".ui.activities.CollarDetailActivity" />
<activity android:name=".ui.activities.CollarListActivity" />
<activity android:name=".ui.activities.MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />

View File

@@ -1,7 +0,0 @@
package com.danilafe.fencelessgrazing
import com.danilafe.fencelessgrazing.model.CollarSummary
interface CollarClickListener {
fun onCollarClick(collar: CollarSummary?)
}

View File

@@ -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()
}
}

View File

@@ -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)
}
}
}

View File

@@ -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)
}
}

View File

@@ -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()
}

View File

@@ -1,3 +1,11 @@
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>)

View File

@@ -1,3 +1,10 @@
package com.danilafe.fencelessgrazing.model
/**
* Component of the information returned from the `/collars/stats/distance` AP endpoint.
*
* @param name the current designation of the collar to which this data point belongs.
* @param id the unique identifier of the collar in the system.
* @param distance the distance, in kilometers, traveled by this collar.
*/
data class CollarDistance(val name: String, val id: Int, val distance: Float)

View File

@@ -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())
}

View File

@@ -1,4 +1,10 @@
package com.danilafe.fencelessgrazing.model
data class CollarPos(val longitude: String, val latitude: String)
/**
* Minimal information about one of the collars returned by the `/collars` API endpoints.
*
* @param id the internal identifier of the collar in the system.
* @param name the collar's current designation.
* @param pos the collar's most recent location.
*/
data class CollarSummary(val id: Int, val name: String, val pos: CollarPos)

View File

@@ -1,3 +1,8 @@
package com.danilafe.fencelessgrazing.model
/**
* The result of a successful authentication via the `/login` endpoint.
*
* @param token the JWT authorization token to be used for future API requests.
*/
data class LoginResult(val token: String)

View File

@@ -1,3 +1,8 @@
package com.danilafe.fencelessgrazing.model
/**
* A general polygon returned by many of the system's API endpoints.
*
* @param dataPoints the vertices of the polygon, in order.
*/
data class Polygon(val dataPoints : List<CollarPos>)

View File

@@ -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")
}
}

View File

@@ -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")
}
}

View File

@@ -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")
}
}

View File

@@ -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")
}
}

View File

@@ -3,15 +3,36 @@ package com.danilafe.fencelessgrazing.requests
import android.util.Log
import com.android.volley.Response
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import java.lang.reflect.Type
class GsonListener<T : Any>(private val targetType : Type, private val stringListener : Response.Listener<T>) : Response.Listener<String> {
private val gson = Gson()
/**
* It appears as though a type parameter, even on that's `reified`, is erased when an anonymous
* object is declared inside an `inline` function. This means that [Gson] serialization with
* a [TypeToken] created inside this anonymous object wil _still_ fail, even though the
* surrounding function is `inline` and with a `reified` type. To work around this,
* we create the type in the function itself, and pass it as an argument to this
* [GsonListener].
*
* @param listener the underlying listener expecting a value of type [T].
* @param type the type to which [Gson] should deserialize the expected string.
*/
class GsonListener<T>(val listener: Response.Listener<T>, private val type: Type) : Response.Listener<String> {
override fun onResponse(response: String?) {
if(response == null) { return stringListener.onResponse(null) }
stringListener.onResponse(gson.fromJson<T>(response, targetType))
val transformed = response?.let { Gson().fromJson<T>(it, type) }
listener.onResponse(transformed)
}
}
/**
* Converts a [Response.Listener] of an arbitrary type [T] to a listener of type [String].
* This is done by converting the input [String] into [T] via [Gson].
* This function must be reified to prevent type erasure.
*
* @receiver the listener that will be called when the [String] is converted into [T].
* @return the resulting [String]-based listener.
*/
inline fun <reified T> Response.Listener<T>.toGsonListener(): Response.Listener<String> {
return GsonListener(this, object : TypeToken<T>(){}.type)
}

View File

@@ -4,6 +4,16 @@ import com.android.volley.Response
import com.android.volley.toolbox.StringRequest
import com.danilafe.fencelessgrazing.model.LoginResult
/**
* A request to the `/login` endpoint of the API, intended to retrieve the JWT authentication
* token to be used for the remainder of the interaction with the API.
*
* @param baseUrl the base URL of the API.
* @param username the username with which to attempt to authenticate.
* @param password the password with which to attempt to authenticate.
* @param listener the listener that will be called if authentication succeeds.
* @param error the error listener for the request.
*/
class LoginRequest(
baseUrl: String,
private val username: String,
@@ -12,7 +22,7 @@ class LoginRequest(
error: Response.ErrorListener
) : StringRequest (
Method.POST, "${baseUrl}/login",
GsonListener(LoginResult::class.java, listener), error
listener.toGsonListener(), error
) {
override fun getParams(): MutableMap<String, String> {

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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
)

View File

@@ -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
)

View File

@@ -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
)

View File

@@ -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
)

View File

@@ -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
)

View File

@@ -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
}
}

View File

@@ -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()
}
}

View File

@@ -1,10 +1,9 @@
package com.danilafe.fencelessgrazing
package com.danilafe.fencelessgrazing.ui.activities
import android.content.Intent
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.view.View
import android.widget.Button
import android.widget.Toast
import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.DividerItemDecoration
@@ -13,8 +12,11 @@ import androidx.recyclerview.widget.RecyclerView
import com.android.volley.RequestQueue
import com.android.volley.Response
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.requests.CollarRequest
import com.danilafe.fencelessgrazing.requests.authenticated.CollarRequest
import org.osmdroid.config.Configuration
import org.osmdroid.tileprovider.tilesource.TileSourceFactory
import org.osmdroid.util.GeoPoint
@@ -24,21 +26,54 @@ import java.util.*
import kotlin.concurrent.timerTask
class CollarListActivity : AppCompatActivity() {
// The list of collar summaries and its list adapter.
/**
* The list of collar summaries retrieved from the API.
*/
private val summaries : MutableList<CollarSummary> = mutableListOf()
/**
* The adapter used to display the [summaries] on the screen.
*/
private lateinit var summaryAdapter: CollarSummaryAdapter
/**
* The view of the summaries backed by the [summaryAdapter].
*/
private lateinit var collarList: RecyclerView
// The API token and request queue.
/**
* The API token and request queue.
*/
private lateinit var token: String
/**
* The Volley queue used for sending API requests.
*/
private lateinit var queue: RequestQueue
// The OpenStreetMap map.
/**
* Whether or not the map has been centered on the location of the collars.
* Rather than have the map constantly jump around following the collars as they move, we only
* move the map's center when this variable is `false`.
*/
private var centerSet: Boolean = false
/**
* The actual view used to display the OpenStreetMaps tiles and the locations of the collars.
*/
private lateinit var map: MapView
/**
* Mapping of collar identifiers to the marker that represents them, used
* to update markers without clearing the user's selection.
*/
private val collarOverlays: MutableMap<Int, Marker> = mutableMapOf()
// Timer used to schedule refresh
/**
* The timer used for scheduling repeated API calls. The timer is started
* in [onResume], and stopped in [onPause].
*/
private var refreshTimer = Timer()
override fun onCreate(savedInstanceState: Bundle?) {
@@ -72,12 +107,21 @@ class CollarListActivity : AppCompatActivity() {
refreshTimer.purge()
}
/**
* Method called by the "open statistics" button. Opens the statistics
* activity.
*/
fun openStatistics(view: View) {
startActivity(Intent(this, StatisticsActivity::class.java))
}
/**
* Sends the request to the API to retrieve an updated list of active collars.
*/
private fun triggerRefresh() {
val request = CollarRequest(getString(R.string.apiUrl), token,
val request =
CollarRequest(getString(R.string.apiUrl),
token,
Response.Listener {
summaries.clear()
summaries.addAll(it)
@@ -85,17 +129,28 @@ class CollarListActivity : AppCompatActivity() {
updateMap()
},
Response.ErrorListener {
Toast.makeText(this, "Failed to retrieve collar list!", Toast.LENGTH_SHORT).show()
Toast.makeText(this, "Failed to retrieve collar list!", Toast.LENGTH_SHORT)
.show()
}
)
queue.add(request)
}
/**
* Locates all the views in the activity, setting the respective
* `lateinit` properties.
*/
private fun findViews() {
collarList = findViewById(R.id.collarSummaryList)
map = findViewById(R.id.map)
}
/**
* Opens the activity [CollarDetailActivity] to give the user more information
* on a particular collar.
*
* @param collar the collar being viewed.
*/
private fun startCollarDetailActivity(collar: CollarSummary) {
val newIntent = Intent(this, CollarDetailActivity::class.java).apply {
putExtra("identifier", collar.id)
@@ -103,9 +158,17 @@ class CollarListActivity : AppCompatActivity() {
startActivity(newIntent)
}
/**
* Configures the [collarList] with the [summaryAdapter], allowing it to
* properly display and handle clicks on the items.
*/
private fun setupCollarList() {
val layoutManager = LinearLayoutManager(collarList.context)
summaryAdapter = CollarSummaryAdapter(summaries, object : CollarClickListener {
summaryAdapter =
CollarSummaryAdapter(
summaries,
object :
CollarClickListener {
override fun onCollarClick(collar: CollarSummary?) {
if (collar == null) return
startCollarDetailActivity(collar)
@@ -116,27 +179,36 @@ class CollarListActivity : AppCompatActivity() {
collarList.addItemDecoration(DividerItemDecoration(collarList.context, layoutManager.orientation))
}
/**
* Updates the map with new collar information.
*/
private fun updateMap() {
updateMapCenter()
updateMapOverlay()
}
/**
* Sets the map's center to the average location of the animals.
*/
private fun updateMapCenter() {
if(centerSet) return
centerSet = true
val averageLongitude = summaries.map { it.pos.longitude.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() {
val currentSet = mutableSetOf<Int>()
summaries.forEach {
// Create or update overlay
val overlay = collarOverlays[it.id] ?: Marker(map)
overlay.title = it.name
overlay.position = GeoPoint(it.pos.longitude.toDouble(), it.pos.latitude.toDouble())
overlay.position = it.pos.toGeoPoint()
// Store new / existing overlay.
if(!collarOverlays.containsKey(it.id)) map.overlays.add(overlay)

View File

@@ -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()
}
}

View File

@@ -1,4 +1,4 @@
package com.danilafe.fencelessgrazing
package com.danilafe.fencelessgrazing.ui.activities
import android.content.Intent
import androidx.appcompat.app.AppCompatActivity
@@ -8,15 +8,27 @@ import android.widget.TextView
import android.widget.Toast
import com.android.volley.Response
import com.android.volley.toolbox.Volley
import com.danilafe.fencelessgrazing.R
import com.danilafe.fencelessgrazing.requests.LoginRequest
/**
* First activity in the app. Prompts the user for their
* login information, and attempts authentication to the API.
*/
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
// If we've already logged in, no need to do it again.
val prefs = getSharedPreferences("FencelessGrazing", 0)
if(prefs.contains("token")) startCollarActivity()
}
/**
* Send the authentication request to the API.
*/
fun attemptLogin(view: View) {
val usernameField: TextView = findViewById(R.id.username)
val passwordField: TextView = findViewById(R.id.password)
@@ -30,12 +42,19 @@ class MainActivity : AppCompatActivity() {
editor.putString("token", it.token)
editor.apply()
val newIntent = Intent(this, CollarListActivity::class.java)
startActivity(newIntent)
startCollarActivity()
},
Response.ErrorListener {
Toast.makeText(this, "Failed to log in! $it", Toast.LENGTH_LONG).show()
})
requestQueue.add(loginRequest)
}
/**
* Once authentication is complete, moves on to the list of collars.
*/
private fun startCollarActivity() {
val newIntent = Intent(this, CollarListActivity::class.java)
startActivity(newIntent)
}
}

View File

@@ -1,19 +1,32 @@
package com.danilafe.fencelessgrazing
package com.danilafe.fencelessgrazing.ui.activities
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import androidx.fragment.app.FragmentActivity
import androidx.viewpager.widget.ViewPager
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.TabLayoutMediator
/**
* Activity containing various graphs created from collar data on the server.
*/
class StatisticsActivity : AppCompatActivity() {
/**
* The tab layout allowing users to switch between graphs.
*/
private lateinit var tabLayout: TabLayout
/**
* The view pager providing swiping functionality between views.
*/
private lateinit var viewPager: ViewPager2
companion object {
/**
* The list of tab names, in the order they appear in the [viewPager].
*/
val tabNames = arrayOf("Distance Traveled")
}
@@ -24,7 +37,10 @@ class StatisticsActivity : AppCompatActivity() {
tabLayout = findViewById(R.id.statisticsTabs)
viewPager = findViewById(R.id.statisticsPager)
viewPager.adapter = StatisticsGraphAdapter(this)
viewPager.adapter =
StatisticsGraphAdapter(
this
)
TabLayoutMediator(tabLayout, viewPager) { tab, position ->
tab.text = tabNames[position]
}.attach()

View File

@@ -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?)
}

View File

@@ -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.ViewGroup
import android.widget.Toast
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.DiffUtil
import com.android.volley.Response
import com.android.volley.toolbox.Volley
import com.danilafe.fencelessgrazing.R
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(
private val items: List<CollarSummary>,
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
= oldItem.id == newItem.id
@@ -26,14 +36,17 @@ class CollarSummaryAdapter(
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CollarViewHolder {
val layout = LayoutInflater.from(parent.context).inflate(viewType, parent, false)
return CollarViewHolder(layout)
return CollarViewHolder(
layout
)
}
override fun onBindViewHolder(holder: CollarViewHolder, position: Int) {
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]

View File

@@ -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)
}
}
}

View File

@@ -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"
}
}

View File

@@ -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()
}

View 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>

View File

@@ -4,7 +4,7 @@
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".CollarDetailActivity">
tools:context=".ui.activities.CollarDetailActivity">
<org.osmdroid.views.MapView
android:id="@+id/detailMap"
android:layout_width="match_parent"
@@ -29,9 +29,8 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="10dp"
android:layout_marginTop="10dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/collarName" />
app:layout_constraintTop_toBottomOf="@+id/changeBoundaryButton" />
<TextView
android:id="@+id/collarStimulus"
@@ -42,4 +41,14 @@
app:layout_constraintStart_toStartOf="parent"
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>

View File

@@ -4,7 +4,7 @@
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".CollarListActivity">
tools:context=".ui.activities.CollarListActivity">
<org.osmdroid.views.MapView
android:id="@+id/map"

View File

@@ -4,7 +4,7 @@
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
tools:context=".ui.activities.MainActivity">
<EditText
android:id="@+id/username"

View File

@@ -4,7 +4,7 @@
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".StatisticsActivity">
tools:context=".ui.activities.StatisticsActivity">
<com.google.android.material.tabs.TabLayout
android:id="@+id/statisticsTabs"

View File

@@ -8,4 +8,7 @@
<string name="collarSummaryStimulus">Required %1d stimuli in the last 24 hours</string>
<string name="distanceTraveled">Distance Traveled</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>

BIN
gradle/wrapper/gradle-wrapper.jar vendored Normal file

Binary file not shown.

View File

@@ -1,6 +1,5 @@
#Sat Feb 01 14:34:24 PST 2020
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-6.4-all.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-5.4.1-all.zip

22
gradlew vendored
View File

@@ -1,5 +1,21 @@
#!/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
@@ -28,7 +44,7 @@ APP_NAME="Gradle"
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.
DEFAULT_JVM_OPTS=""
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Use the maximum available, or set MAX_FD != -1 to use that value.
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\""
fi
# For Cygwin, switch paths to Windows format before running java
if $cygwin ; then
# For Cygwin or MSYS, switch paths to Windows format before running java
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
JAVACMD=`cygpath --unix "$JAVACMD"`

18
gradlew.bat vendored
View File

@@ -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
@rem ##########################################################################
@rem
@@ -14,7 +30,7 @@ set APP_BASE_NAME=%~n0
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.
set DEFAULT_JVM_OPTS=
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome