Compare commits

...

13 Commits

33 changed files with 552 additions and 122 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

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

View File

@@ -6,5 +6,6 @@ package com.danilafe.fencelessgrazing.model
* @param id the collar's internal unique identifier. * @param id the collar's internal unique identifier.
* @param name the collar's current designation in the system. * @param name the collar's current designation in the system.
* @param stimulus the number of stimulus activation reports in the last 24 hours. * @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) data class CollarDetails(val id: Int, val name: String, val stimulus: Int, val boundary: List<CollarPos>)

View File

@@ -1,9 +1,17 @@
package com.danilafe.fencelessgrazing.model 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. * GPS coordinate returned by many of the project's API endpoints.
* *
* @param longitude the longitude of the GPS point. * @param longitude the longitude of the GPS point.
* @param latitude the latitude of the GPS point. * @param latitude the latitude of the GPS point.
*/ */
data class CollarPos(val longitude: String, val latitude: String) @Parcelize
data class CollarPos(val longitude: String, val latitude: String) : Parcelable {
fun toGeoPoint(): GeoPoint = GeoPoint(latitude.toDouble(), longitude.toDouble())
}

View File

@@ -1,4 +1,4 @@
package com.danilafe.fencelessgrazing.requests package com.danilafe.fencelessgrazing.requests.authenticated
import com.android.volley.Response import com.android.volley.Response
import com.android.volley.toolbox.StringRequest import com.android.volley.toolbox.StringRequest
@@ -7,7 +7,7 @@ import com.android.volley.toolbox.StringRequest
* General request aimed at a protected endpoint of the API. The token can be retrieved * 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. * via a [LoginRequest], or, if you've made it past the login screen, via Shared Preferences.
* *
* The [AuthenticatedRequest] expects to receive a string from the API endpoint; how this string * 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 * is handled is determined by the [Response.Listener] object. One may use
* [Response.Listener.toGsonListener] to create a JSON deserializer that expects a [String]. * [Response.Listener.toGsonListener] to create a JSON deserializer that expects a [String].
* *
@@ -17,10 +17,10 @@ import com.android.volley.toolbox.StringRequest
* @param listener the listener object called if a valid response is received. * @param listener the listener object called if a valid response is received.
* @param error the error listener for the request. * @param error the error listener for the request.
*/ */
open class AuthenticatedRequest( open class AuthenticatedGetRequest(
baseUrl: String, baseUrl: String,
apiEndpoint: String, apiEndpoint: String,
private val token: String, val token: String,
listener: Response.Listener<String>, listener: Response.Listener<String>,
error: Response.ErrorListener error: Response.ErrorListener
) : StringRequest( ) : StringRequest(

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

@@ -1,11 +1,8 @@
package com.danilafe.fencelessgrazing.requests package com.danilafe.fencelessgrazing.requests.authenticated
import com.android.volley.Response import com.android.volley.Response
import com.android.volley.toolbox.StringRequest
import com.danilafe.fencelessgrazing.model.CollarDetails import com.danilafe.fencelessgrazing.model.CollarDetails
import com.danilafe.fencelessgrazing.model.CollarPos import com.danilafe.fencelessgrazing.requests.toGsonListener
import com.danilafe.fencelessgrazing.model.CollarSummary
import com.google.gson.reflect.TypeToken
/** /**
* Request to the `/collars/<id>/details` API endpoint. Retrieves detailed information * Request to the `/collars/<id>/details` API endpoint. Retrieves detailed information
@@ -23,7 +20,7 @@ class CollarDetailRequest(
token : String, token : String,
listener: Response.Listener<CollarDetails>, listener: Response.Listener<CollarDetails>,
error: Response.ErrorListener error: Response.ErrorListener
) : AuthenticatedRequest( ) : AuthenticatedGetRequest(
baseUrl, "/collars/$collarId/details", token, baseUrl, "/collars/$collarId/details", token,
listener.toGsonListener(), error listener.toGsonListener(), error
) )

View File

@@ -1,10 +1,8 @@
package com.danilafe.fencelessgrazing.requests package com.danilafe.fencelessgrazing.requests.authenticated
import com.android.volley.Response import com.android.volley.Response
import com.android.volley.toolbox.StringRequest
import com.danilafe.fencelessgrazing.model.CollarPos import com.danilafe.fencelessgrazing.model.CollarPos
import com.danilafe.fencelessgrazing.model.CollarSummary import com.danilafe.fencelessgrazing.requests.toGsonListener
import com.google.gson.reflect.TypeToken
/** /**
* A request to the `/collars/<id>/history` API endpoint. Retrieves the entire list * A request to the `/collars/<id>/history` API endpoint. Retrieves the entire list
@@ -23,7 +21,7 @@ class CollarHistoryRequest(
token : String, token : String,
listener: Response.Listener<List<CollarPos>>, listener: Response.Listener<List<CollarPos>>,
error: Response.ErrorListener error: Response.ErrorListener
) : AuthenticatedRequest( ) : AuthenticatedGetRequest(
baseUrl, "/collars/$collarId/history", token, baseUrl, "/collars/$collarId/history", token,
listener.toGsonListener(), error listener.toGsonListener(), error
) )

View File

@@ -1,9 +1,8 @@
package com.danilafe.fencelessgrazing.requests package com.danilafe.fencelessgrazing.requests.authenticated
import com.android.volley.Response import com.android.volley.Response
import com.android.volley.toolbox.StringRequest
import com.danilafe.fencelessgrazing.model.CollarSummary import com.danilafe.fencelessgrazing.model.CollarSummary
import com.google.gson.reflect.TypeToken import com.danilafe.fencelessgrazing.requests.toGsonListener
/** /**
* A request to the `/collars` API endpoint. Retrieves a list of collar summaries, represented * A request to the `/collars` API endpoint. Retrieves a list of collar summaries, represented
@@ -19,7 +18,7 @@ class CollarRequest(
token : String, token : String,
listener: Response.Listener<List<CollarSummary>>, listener: Response.Listener<List<CollarSummary>>,
error: Response.ErrorListener error: Response.ErrorListener
) : AuthenticatedRequest( ) : AuthenticatedGetRequest(
baseUrl, "/collars", token, baseUrl, "/collars", token,
listener.toGsonListener(), error listener.toGsonListener(), error
) )

View File

@@ -1,9 +1,8 @@
package com.danilafe.fencelessgrazing.requests package com.danilafe.fencelessgrazing.requests.authenticated
import com.danilafe.fencelessgrazing.model.CollarDistance import com.danilafe.fencelessgrazing.model.CollarDistance
import com.android.volley.Response import com.android.volley.Response
import com.android.volley.toolbox.StringRequest import com.danilafe.fencelessgrazing.requests.toGsonListener
import com.google.gson.reflect.TypeToken
/** /**
* A request to the `/collars/stats/distance` API endpoint. Retrieves * A request to the `/collars/stats/distance` API endpoint. Retrieves
@@ -20,7 +19,7 @@ class DistanceTraveledRequest(
token : String, token : String,
listener: Response.Listener<List<CollarDistance>>, listener: Response.Listener<List<CollarDistance>>,
error: Response.ErrorListener error: Response.ErrorListener
) : AuthenticatedRequest( ) : AuthenticatedGetRequest(
baseUrl, "/collars/stats/distance", token, baseUrl, "/collars/stats/distance", token,
listener.toGsonListener(), error 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

@@ -1,16 +1,22 @@
package com.danilafe.fencelessgrazing package com.danilafe.fencelessgrazing.ui.activities
import android.content.Intent
import android.graphics.Color import android.graphics.Color
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle import android.os.Bundle
import android.util.Log
import android.view.View
import android.widget.TextView import android.widget.TextView
import android.widget.Toast import android.widget.Toast
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.R
import com.danilafe.fencelessgrazing.model.CollarPos
import com.danilafe.fencelessgrazing.model.Polygon import com.danilafe.fencelessgrazing.model.Polygon
import com.danilafe.fencelessgrazing.requests.CollarDetailRequest import com.danilafe.fencelessgrazing.requests.authenticated.CollarDetailRequest
import com.danilafe.fencelessgrazing.requests.CollarHistoryRequest import com.danilafe.fencelessgrazing.requests.authenticated.CollarHistoryRequest
import com.danilafe.fencelessgrazing.ui.components.GrazingPolygon
import org.osmdroid.util.GeoPoint import org.osmdroid.util.GeoPoint
import org.osmdroid.views.MapView import org.osmdroid.views.MapView
import org.osmdroid.views.overlay.Marker import org.osmdroid.views.overlay.Marker
@@ -80,7 +86,7 @@ class CollarDetailActivity : AppCompatActivity() {
* to display the valid grazing area for the collar; its vertices * to display the valid grazing area for the collar; its vertices
* are updated whenever the [boundingBox] property is changed. * are updated whenever the [boundingBox] property is changed.
*/ */
private lateinit var mapPolygon: org.osmdroid.views.overlay.Polygon private lateinit var mapPolygon: GrazingPolygon
/** /**
* The [Polygon] that represents the collar's valid grazing area. * The [Polygon] that represents the collar's valid grazing area.
@@ -132,10 +138,9 @@ class CollarDetailActivity : AppCompatActivity() {
map = findViewById(R.id.detailMap) map = findViewById(R.id.detailMap)
map.controller.setZoom(9.5) map.controller.setZoom(9.5)
mapPolygon = org.osmdroid.views.overlay.Polygon(map) mapPolygon = GrazingPolygon(map)
mapMarker = Marker(map) mapMarker = Marker(map)
mapPolyline = Polyline(map) mapPolyline = Polyline(map)
configureMapPolygon()
} }
override fun onPause() { override fun onPause() {
@@ -166,36 +171,48 @@ class CollarDetailActivity : AppCompatActivity() {
* Sends API requests that retrieve updated information about the collar. * Sends API requests that retrieve updated information about the collar.
*/ */
private fun triggerRefresh() { private fun triggerRefresh() {
val historyRequest = CollarHistoryRequest(getString(R.string.apiUrl), collarId, token, val historyRequest =
Response.Listener { CollarHistoryRequest(getString(R.string.apiUrl),
dataPoints = it.map { p -> GeoPoint(p.longitude.toDouble(), p.latitude.toDouble()) } collarId,
}, token,
Response.ErrorListener { Response.Listener {
Toast.makeText(this, "Failed to retrieve history of collar", Toast.LENGTH_SHORT).show() dataPoints =
} it.map { p -> p.toGeoPoint() }
) },
val detailRequest = CollarDetailRequest(getString(R.string.apiUrl), collarId, token, Response.ErrorListener {
Response.Listener { Toast.makeText(this, "Failed to retrieve history of collar", Toast.LENGTH_SHORT)
collarName.text = it.name .show()
collarStimulus.text = getString(R.string.collarSummaryStimulus, it.stimulus) }
}, )
Response.ErrorListener { val detailRequest =
Toast.makeText(this, "Failed to retrieve details of collar", Toast.LENGTH_SHORT).show() 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(historyRequest)
queue.add(detailRequest) queue.add(detailRequest)
} }
/** /**
* Sets [mapPolygon]'s color, borders, and other relevant settings. * Open the [BoundaryEditorActivity] to edit the current collar's grazing boundary.
*/ */
private fun configureMapPolygon() { fun changeGrazingBoundary(v : View) {
mapPolygon.fillPaint.color = Color.parseColor("#32a852") startActivity(Intent(this, BoundaryEditorActivity::class.java).apply {
mapPolygon.fillPaint.alpha = 127 val center = dataPoints.lastOrNull()?.let {
mapPolygon.outlinePaint.color = Color.parseColor("#227539") CollarPos(it.longitude.toString(), it.latitude.toString())
mapPolygon.outlinePaint.strokeWidth = 0.0f } ?: CollarPos("0", "0")
mapPolygon.title = "Valid Grazing Area" putExtra("center", center)
putExtra("identifier", collarId)
})
} }
/** /**
@@ -234,7 +251,7 @@ class CollarDetailActivity : AppCompatActivity() {
*/ */
private fun updateBoundingBox(oldValue: Polygon?, polygon: Polygon) { private fun updateBoundingBox(oldValue: Polygon?, polygon: Polygon) {
if(oldValue == null) map.overlays.add(mapPolygon) if(oldValue == null) map.overlays.add(mapPolygon)
val points = polygon.dataPoints.map { GeoPoint(it.longitude.toDouble(), it.latitude.toDouble()) } val points = polygon.dataPoints.map { it.toGeoPoint() }
val polygonPoints = points.toMutableList() val polygonPoints = points.toMutableList()
polygonPoints.add(polygonPoints[0]) polygonPoints.add(polygonPoints[0])
mapPolygon.points = polygonPoints mapPolygon.points = polygonPoints
@@ -246,6 +263,7 @@ class CollarDetailActivity : AppCompatActivity() {
* the [mapPolygon] from the [map]. * the [mapPolygon] from the [map].
*/ */
private fun clearBoundingBox() { private fun clearBoundingBox() {
Log.d("FencelessGrazing", "Clearing...")
map.overlays.remove(mapPolygon) map.overlays.remove(mapPolygon)
map.invalidate() map.invalidate()
} }

View File

@@ -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
@@ -12,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
@@ -116,17 +119,20 @@ class CollarListActivity : AppCompatActivity() {
* Sends the request to the API to retrieve an updated list of active collars. * 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)
} }
@@ -158,12 +164,16 @@ class CollarListActivity : AppCompatActivity() {
*/ */
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))
@@ -186,7 +196,7 @@ class CollarListActivity : AppCompatActivity() {
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))
} }
/** /**
@@ -198,7 +208,7 @@ class CollarListActivity : AppCompatActivity() {
// 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)

View File

@@ -1,4 +1,4 @@
package com.danilafe.fencelessgrazing package com.danilafe.fencelessgrazing.ui.activities
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
@@ -9,8 +9,9 @@ import androidx.fragment.app.Fragment
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.R
import com.danilafe.fencelessgrazing.model.CollarDistance import com.danilafe.fencelessgrazing.model.CollarDistance
import com.danilafe.fencelessgrazing.requests.DistanceTraveledRequest import com.danilafe.fencelessgrazing.requests.authenticated.DistanceTraveledRequest
import com.github.mikephil.charting.charts.BarChart import com.github.mikephil.charting.charts.BarChart
import com.github.mikephil.charting.components.XAxis import com.github.mikephil.charting.components.XAxis
import com.github.mikephil.charting.data.BarData import com.github.mikephil.charting.data.BarData
@@ -61,14 +62,20 @@ class DistanceTraveledGraph() : Fragment() {
val activity = requireActivity() val activity = requireActivity()
val sharedPrefs = activity.getSharedPreferences("FencelessGrazing", 0) val sharedPrefs = activity.getSharedPreferences("FencelessGrazing", 0)
val token = sharedPrefs.getString("token", null)!! val token = sharedPrefs.getString("token", null)!!
val request = DistanceTraveledRequest(activity.getString(R.string.apiUrl), token, val request =
Response.Listener { DistanceTraveledRequest(activity.getString(R.string.apiUrl),
updateChartData(it) token,
}, Response.Listener {
Response.ErrorListener { updateChartData(it)
Toast.makeText(activity, "Failed to retrieve distance traveled!", Toast.LENGTH_SHORT).show() },
} Response.ErrorListener {
) Toast.makeText(
activity,
"Failed to retrieve distance traveled!",
Toast.LENGTH_SHORT
).show()
}
)
queue.add(request) queue.add(request)
} }

View File

@@ -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,6 +8,7 @@ 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
/** /**

View File

@@ -1,10 +1,10 @@
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
@@ -37,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()

View File

@@ -1,4 +1,4 @@
package com.danilafe.fencelessgrazing package com.danilafe.fencelessgrazing.ui.components
import com.danilafe.fencelessgrazing.model.CollarSummary import com.danilafe.fencelessgrazing.model.CollarSummary

View File

@@ -1,15 +1,12 @@
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 * A [ListAdapter] subclass to display and manage a list of [CollarSummary] items
@@ -21,7 +18,9 @@ import com.danilafe.fencelessgrazing.requests.CollarRequest
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()
) {
/** /**
* [DiffUtil.ItemCallback] used for the [ListAdapter]. Compares items using * [DiffUtil.ItemCallback] used for the [ListAdapter]. Compares items using
@@ -37,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]

View File

@@ -1,8 +1,9 @@
package com.danilafe.fencelessgrazing package com.danilafe.fencelessgrazing.ui.components
import android.view.View import android.view.View
import android.widget.TextView import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.danilafe.fencelessgrazing.R
import com.danilafe.fencelessgrazing.model.CollarSummary import com.danilafe.fencelessgrazing.model.CollarSummary
/** /**
@@ -28,7 +29,8 @@ class CollarViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
*/ */
fun bindData(summary: CollarSummary, collarClickListener: CollarClickListener) { fun bindData(summary: CollarSummary, collarClickListener: CollarClickListener) {
nameView.text = summary.name nameView.text = summary.name
positionView.text = itemView.resources.getString(R.string.collarSummaryLocation, positionView.text = itemView.resources.getString(
R.string.collarSummaryLocation,
summary.pos.longitude.toDouble(), summary.pos.latitude.toDouble()) summary.pos.longitude.toDouble(), summary.pos.latitude.toDouble())
itemView.setOnClickListener { itemView.setOnClickListener {
collarClickListener.onCollarClick(summary) 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

@@ -1,13 +1,15 @@
package com.danilafe.fencelessgrazing package com.danilafe.fencelessgrazing.ui.components
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity import androidx.fragment.app.FragmentActivity
import androidx.viewpager2.adapter.FragmentStateAdapter import androidx.viewpager2.adapter.FragmentStateAdapter
import com.danilafe.fencelessgrazing.ui.activities.DistanceTraveledGraph
/** /**
* Adapter for the [ViewPager2][androidx.viewpager2.widget.ViewPager2] class. * Adapter for the [ViewPager2][androidx.viewpager2.widget.ViewPager2] class.
*/ */
class StatisticsGraphAdapter(activity : FragmentActivity) : FragmentStateAdapter(activity) { class StatisticsGraphAdapter(activity : FragmentActivity) : FragmentStateAdapter(activity) {
override fun getItemCount(): Int = 1 override fun getItemCount(): Int = 1
override fun createFragment(position: Int): Fragment = DistanceTraveledGraph() 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" 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>

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

View File

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

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