442 lines
19 KiB
Kotlin
442 lines
19 KiB
Kotlin
/*
|
|
* TrackFragment.kt
|
|
* Implements the TrackFragment fragment
|
|
* A TrackFragment displays a previously recorded track
|
|
*
|
|
* This file is part of
|
|
* TRACKBOOK - Movement Recorder for Android
|
|
*
|
|
* Copyright (c) 2016-22 - Y20K.org
|
|
* Licensed under the MIT-License
|
|
* http://opensource.org/licenses/MIT
|
|
*
|
|
* Trackbook uses osmdroid - OpenStreetMap-Tools for Android
|
|
* https://github.com/osmdroid/osmdroid
|
|
*/
|
|
|
|
package org.y20k.trackbook
|
|
|
|
import YesNoDialog
|
|
import android.app.Activity
|
|
import android.content.Context
|
|
import android.content.Intent
|
|
import android.net.Uri
|
|
import android.os.Bundle
|
|
import android.os.Handler
|
|
import android.os.Looper
|
|
import android.util.Log
|
|
import android.view.LayoutInflater
|
|
import android.view.View
|
|
import android.view.ViewGroup
|
|
import android.widget.DatePicker
|
|
import android.widget.ImageButton
|
|
import android.widget.TimePicker
|
|
import android.widget.Toast
|
|
import androidx.activity.result.ActivityResult
|
|
import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult
|
|
import androidx.constraintlayout.widget.Group
|
|
import androidx.core.widget.NestedScrollView
|
|
import androidx.fragment.app.Fragment
|
|
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
|
import com.google.android.material.floatingactionbutton.FloatingActionButton
|
|
import com.google.android.material.textview.MaterialTextView
|
|
import org.osmdroid.api.IGeoPoint
|
|
import org.osmdroid.api.IMapController
|
|
import org.osmdroid.events.MapListener
|
|
import org.osmdroid.events.ScrollEvent
|
|
import org.osmdroid.events.ZoomEvent
|
|
import org.osmdroid.tileprovider.tilesource.TileSourceFactory
|
|
import org.osmdroid.util.GeoPoint
|
|
import org.osmdroid.views.MapView
|
|
import org.osmdroid.views.overlay.ItemizedIconOverlay
|
|
import org.osmdroid.views.overlay.OverlayItem
|
|
import org.osmdroid.views.overlay.TilesOverlay
|
|
import org.osmdroid.views.overlay.simplefastpoint.SimpleFastPointOverlay
|
|
import org.y20k.trackbook.helpers.AppThemeHelper
|
|
import org.y20k.trackbook.helpers.DateTimeHelper
|
|
import org.y20k.trackbook.helpers.LengthUnitHelper
|
|
import org.y20k.trackbook.helpers.PreferencesHelper
|
|
import org.y20k.trackbook.helpers.createTrackOverlay
|
|
import org.y20k.trackbook.helpers.create_start_end_markers
|
|
import org.y20k.trackbook.helpers.iso8601_format
|
|
import java.text.SimpleDateFormat
|
|
import java.util.*
|
|
|
|
class TrackFragment : Fragment(), MapListener, YesNoDialog.YesNoDialogListener
|
|
{
|
|
lateinit var rootView: View
|
|
lateinit var save_track_button: ImageButton
|
|
lateinit var deleteButton: ImageButton
|
|
lateinit var zoom_in_button: FloatingActionButton
|
|
lateinit var zoom_out_button: FloatingActionButton
|
|
lateinit var trackNameView: MaterialTextView
|
|
lateinit var track_query_start_date: DatePicker
|
|
lateinit var track_query_start_time: TimePicker
|
|
lateinit var track_query_end_date: DatePicker
|
|
lateinit var track_query_end_time: TimePicker
|
|
var track_query_start_time_previous: Int = 0
|
|
var track_query_end_time_previous: Int = 0
|
|
private lateinit var mapView: MapView
|
|
private lateinit var controller: IMapController
|
|
private lateinit var statisticsSheetBehavior: BottomSheetBehavior<View>
|
|
private lateinit var statisticsSheet: NestedScrollView
|
|
private lateinit var statisticsView: View
|
|
private lateinit var distanceView: MaterialTextView
|
|
private lateinit var waypointsView: MaterialTextView
|
|
private lateinit var durationView: MaterialTextView
|
|
private lateinit var velocityView: MaterialTextView
|
|
private lateinit var recordingStartView: MaterialTextView
|
|
private lateinit var recordingStopView: MaterialTextView
|
|
private lateinit var recordingPausedView: MaterialTextView
|
|
private lateinit var recordingPausedLabelView: MaterialTextView
|
|
private lateinit var maxAltitudeView: MaterialTextView
|
|
private lateinit var minAltitudeView: MaterialTextView
|
|
private lateinit var positiveElevationView: MaterialTextView
|
|
private lateinit var negativeElevationView: MaterialTextView
|
|
private lateinit var elevationDataViews: Group
|
|
private lateinit var track: Track
|
|
private var useImperialUnits: Boolean = false
|
|
private val handler: Handler = Handler(Looper.getMainLooper())
|
|
private var special_points_overlay: ItemizedIconOverlay<OverlayItem>? = null
|
|
private var track_overlay: SimpleFastPointOverlay? = null
|
|
val RERENDER_DELAY: Long = 1000
|
|
|
|
/* Overrides onCreateView from Fragment */
|
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View
|
|
{
|
|
val database: Database = (requireActivity().applicationContext as Trackbook).database
|
|
track = Track(
|
|
database=database,
|
|
name=this.requireArguments().getString(Keys.ARG_TRACK_TITLE, ""),
|
|
device_id= this.requireArguments().getString(Keys.ARG_TRACK_DEVICE_ID, ""),
|
|
start_time= iso8601_format.parse(this.requireArguments().getString(Keys.ARG_TRACK_START_TIME)!!),
|
|
end_time=iso8601_format.parse(this.requireArguments().getString(Keys.ARG_TRACK_STOP_TIME)!!),
|
|
)
|
|
track.load_trkpts()
|
|
rootView = inflater.inflate(R.layout.fragment_track, container, false)
|
|
mapView = rootView.findViewById(R.id.map)
|
|
save_track_button = rootView.findViewById(R.id.save_button)
|
|
deleteButton = rootView.findViewById(R.id.delete_button)
|
|
zoom_in_button = rootView.findViewById(R.id.zoom_in_button)
|
|
zoom_out_button = rootView.findViewById(R.id.zoom_out_button)
|
|
trackNameView = rootView.findViewById(R.id.statistics_track_name_headline)
|
|
|
|
controller = mapView.controller
|
|
mapView.addMapListener(this)
|
|
mapView.isTilesScaledToDpi = true
|
|
mapView.setTileSource(TileSourceFactory.MAPNIK)
|
|
mapView.isVerticalMapRepetitionEnabled = false
|
|
mapView.setMultiTouchControls(true)
|
|
mapView.zoomController.setVisibility(org.osmdroid.views.CustomZoomButtonsController.Visibility.NEVER)
|
|
controller.setCenter(GeoPoint(track.view_latitude, track.view_longitude))
|
|
controller.setZoom(Keys.DEFAULT_ZOOM_LEVEL)
|
|
|
|
statisticsSheet = rootView.findViewById(R.id.statistics_sheet)
|
|
statisticsView = rootView.findViewById(R.id.statistics_view)
|
|
distanceView = rootView.findViewById(R.id.statistics_data_distance)
|
|
waypointsView = rootView.findViewById(R.id.statistics_data_waypoints)
|
|
durationView = rootView.findViewById(R.id.statistics_data_duration)
|
|
velocityView = rootView.findViewById(R.id.statistics_data_velocity)
|
|
recordingStartView = rootView.findViewById(R.id.statistics_data_recording_start)
|
|
recordingStopView = rootView.findViewById(R.id.statistics_data_recording_stop)
|
|
recordingPausedLabelView = rootView.findViewById(R.id.statistics_p_recording_paused)
|
|
recordingPausedView = rootView.findViewById(R.id.statistics_data_recording_paused)
|
|
maxAltitudeView = rootView.findViewById(R.id.statistics_data_max_altitude)
|
|
minAltitudeView = rootView.findViewById(R.id.statistics_data_min_altitude)
|
|
positiveElevationView = rootView.findViewById(R.id.statistics_data_positive_elevation)
|
|
negativeElevationView = rootView.findViewById(R.id.statistics_data_negative_elevation)
|
|
elevationDataViews = rootView.findViewById(R.id.elevation_data)
|
|
|
|
useImperialUnits = PreferencesHelper.loadUseImperialUnits()
|
|
|
|
if (AppThemeHelper.isDarkModeOn(context as Activity))
|
|
{
|
|
mapView.overlayManager.tilesOverlay.setColorFilter(TilesOverlay.INVERT_COLORS)
|
|
}
|
|
|
|
val actual_start_time: Date = if (track.trkpts.isEmpty()) track.start_time else Date(track.trkpts.first().time)
|
|
val actual_end_time: Date = if (track.trkpts.isEmpty()) track.end_time else Date(track.trkpts.last().time)
|
|
|
|
track_query_start_date = rootView.findViewById(R.id.track_query_start_date)
|
|
val start_cal = GregorianCalendar()
|
|
start_cal.time = actual_start_time
|
|
track_query_start_date.init(start_cal.get(Calendar.YEAR), start_cal.get(Calendar.MONTH), start_cal.get(Calendar.DAY_OF_MONTH), object: DatePicker.OnDateChangedListener {
|
|
override fun onDateChanged(p0: DatePicker?, p1: Int, p2: Int, p3: Int)
|
|
{
|
|
handler.removeCallbacks(requery_and_render)
|
|
handler.postDelayed(requery_and_render, RERENDER_DELAY)
|
|
}
|
|
})
|
|
|
|
track_query_start_time = rootView.findViewById(R.id.track_query_start_time)
|
|
track_query_start_time.setIs24HourView(true)
|
|
track_query_start_time.hour = actual_start_time.hours
|
|
track_query_start_time.minute = actual_start_time.minutes
|
|
track_query_start_time_previous = (actual_start_time.hours * 60) + actual_start_time.minutes
|
|
track_query_start_time.setOnTimeChangedListener(object : TimePicker.OnTimeChangedListener{
|
|
override fun onTimeChanged(p0: TimePicker?, p1: Int, p2: Int)
|
|
{
|
|
handler.removeCallbacks(requery_and_render)
|
|
val newminute = (p1 * 60) + p2
|
|
Log.i("VOUSSOIR", "End time changed $newminute")
|
|
if (newminute < track_query_start_time_previous && (track_query_start_time_previous - newminute > 60))
|
|
{
|
|
increment_datepicker(track_query_start_date)
|
|
}
|
|
else if (newminute > track_query_start_time_previous && (newminute - track_query_start_time_previous > 60))
|
|
{
|
|
decrement_datepicker(track_query_start_date)
|
|
}
|
|
track_query_start_time_previous = newminute
|
|
handler.postDelayed(requery_and_render, RERENDER_DELAY)
|
|
}
|
|
})
|
|
|
|
track_query_end_date = rootView.findViewById(R.id.track_query_end_date)
|
|
val end_cal = GregorianCalendar()
|
|
end_cal.time = actual_end_time
|
|
track_query_end_date.init(end_cal.get(Calendar.YEAR), end_cal.get(Calendar.MONTH), end_cal.get(Calendar.DAY_OF_MONTH), object: DatePicker.OnDateChangedListener {
|
|
override fun onDateChanged(p0: DatePicker?, p1: Int, p2: Int, p3: Int)
|
|
{
|
|
handler.removeCallbacks(requery_and_render)
|
|
handler.postDelayed(requery_and_render, RERENDER_DELAY)
|
|
}
|
|
})
|
|
|
|
track_query_end_time = rootView.findViewById(R.id.track_query_end_time)
|
|
track_query_end_time.setIs24HourView(true)
|
|
track_query_end_time.hour = actual_end_time.hours
|
|
track_query_end_time.minute = actual_end_time.minutes
|
|
track_query_end_time_previous = (actual_end_time.hours * 60) + actual_end_time.minutes
|
|
track_query_end_time.setOnTimeChangedListener(object : TimePicker.OnTimeChangedListener{
|
|
override fun onTimeChanged(p0: TimePicker?, p1: Int, p2: Int)
|
|
{
|
|
handler.removeCallbacks(requery_and_render)
|
|
val newminute = (p1 * 60) + p2
|
|
Log.i("VOUSSOIR", "End time changed $newminute")
|
|
if (newminute < track_query_end_time_previous && (track_query_end_time_previous - newminute > 60))
|
|
{
|
|
increment_datepicker(track_query_end_date)
|
|
}
|
|
else if (newminute > track_query_end_time_previous && (newminute - track_query_end_time_previous > 60))
|
|
{
|
|
decrement_datepicker(track_query_end_date)
|
|
}
|
|
track_query_end_time_previous = newminute
|
|
handler.postDelayed(requery_and_render, RERENDER_DELAY)
|
|
}
|
|
})
|
|
|
|
save_track_button.setOnClickListener {
|
|
openSaveGpxDialog()
|
|
}
|
|
|
|
deleteButton.setOnClickListener {
|
|
val dialogMessage = "${getString(R.string.dialog_yes_no_message_delete_recording)}\n\n${track.trkpts.size} trackpoints"
|
|
YesNoDialog(this@TrackFragment as YesNoDialog.YesNoDialogListener).show(
|
|
context = activity as Context,
|
|
type = Keys.DIALOG_DELETE_TRACK,
|
|
messageString = dialogMessage,
|
|
yesButton = R.string.dialog_yes_no_positive_button_delete_recording
|
|
)
|
|
}
|
|
|
|
zoom_in_button.setOnClickListener {
|
|
mapView.controller.zoomTo(mapView.zoomLevelDouble + 0.5, 0)
|
|
}
|
|
zoom_out_button.setOnClickListener {
|
|
mapView.controller.zoomTo(mapView.zoomLevelDouble - 0.5, 0)
|
|
}
|
|
|
|
statisticsSheetBehavior = BottomSheetBehavior.from<View>(statisticsSheet)
|
|
statisticsSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
|
|
|
|
render_track()
|
|
|
|
return rootView
|
|
}
|
|
|
|
/* Overrides onResume from Fragment */
|
|
override fun onResume()
|
|
{
|
|
super.onResume()
|
|
}
|
|
|
|
fun render_track()
|
|
{
|
|
Log.i("VOUSSOIR", "TrackFragment.render_track")
|
|
if (special_points_overlay != null)
|
|
{
|
|
mapView.overlays.remove(special_points_overlay)
|
|
}
|
|
if (track_overlay != null)
|
|
{
|
|
mapView.overlays.remove(track_overlay)
|
|
}
|
|
val geopoints: MutableList<IGeoPoint> = mutableListOf()
|
|
for (trkpt in track.trkpts)
|
|
{
|
|
geopoints.add(trkpt)
|
|
}
|
|
if (track.trkpts.isNotEmpty())
|
|
{
|
|
track_overlay = createTrackOverlay(requireContext(), mapView, geopoints, Keys.STATE_TRACKING_STOPPED)
|
|
special_points_overlay = create_start_end_markers(requireContext(), mapView, track.trkpts)
|
|
}
|
|
setupStatisticsViews()
|
|
}
|
|
|
|
fun get_datetime(datepicker: DatePicker, timepicker: TimePicker, seconds: Int): Date
|
|
{
|
|
val cal = GregorianCalendar.getInstance()
|
|
cal.set(datepicker.year, datepicker.month, datepicker.dayOfMonth, timepicker.hour, timepicker.minute, seconds)
|
|
Log.i("VOUSSOIR", cal.time.toString())
|
|
return cal.time
|
|
}
|
|
|
|
fun decrement_datepicker(picker: DatePicker)
|
|
{
|
|
val cal = GregorianCalendar.getInstance()
|
|
cal.set(picker.year, picker.month, picker.dayOfMonth)
|
|
cal.add(Calendar.DATE, -1)
|
|
picker.updateDate(cal.get(Calendar.YEAR), cal.get(Calendar.MONTH), cal.get(Calendar.DAY_OF_MONTH))
|
|
}
|
|
|
|
fun increment_datepicker(picker: DatePicker)
|
|
{
|
|
val cal = GregorianCalendar.getInstance()
|
|
cal.set(picker.year, picker.month, picker.dayOfMonth)
|
|
cal.add(Calendar.DATE, 1)
|
|
picker.updateDate(cal.get(Calendar.YEAR), cal.get(Calendar.MONTH), cal.get(Calendar.DAY_OF_MONTH))
|
|
}
|
|
|
|
private fun setupStatisticsViews()
|
|
{
|
|
val stats: TrackStatistics = track.statistics()
|
|
trackNameView.text = track.name
|
|
distanceView.text = LengthUnitHelper.convertDistanceToString(stats.distance, useImperialUnits)
|
|
waypointsView.text = track.trkpts.size.toString()
|
|
durationView.text = DateTimeHelper.convertToReadableTime(requireContext(), stats.duration)
|
|
velocityView.text = LengthUnitHelper.convertToVelocityString(stats.velocity, useImperialUnits)
|
|
recordingStartView.text = DateTimeHelper.convertToReadableDateAndTime(track.start_time)
|
|
recordingStopView.text = DateTimeHelper.convertToReadableDateAndTime(track.end_time)
|
|
maxAltitudeView.text = LengthUnitHelper.convertDistanceToString(stats.max_altitude, useImperialUnits)
|
|
minAltitudeView.text = LengthUnitHelper.convertDistanceToString(stats.min_altitude, useImperialUnits)
|
|
positiveElevationView.text = LengthUnitHelper.convertDistanceToString(stats.total_ascent, useImperialUnits)
|
|
negativeElevationView.text = LengthUnitHelper.convertDistanceToString(stats.total_descent, useImperialUnits)
|
|
|
|
// inform user about possible accuracy issues with altitude measurements
|
|
elevationDataViews.referencedIds.forEach { id ->
|
|
(rootView.findViewById(id) as View).setOnClickListener{
|
|
Toast.makeText(context, R.string.toast_message_elevation_info, Toast.LENGTH_LONG).show()
|
|
}
|
|
}
|
|
|
|
trackNameView.setOnClickListener {
|
|
toggleStatisticsSheetVisibility()
|
|
}
|
|
}
|
|
|
|
private fun toggleStatisticsSheetVisibility()
|
|
{
|
|
when (statisticsSheetBehavior.state) {
|
|
BottomSheetBehavior.STATE_EXPANDED -> statisticsSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
|
|
else -> statisticsSheetBehavior.state = BottomSheetBehavior.STATE_EXPANDED
|
|
}
|
|
}
|
|
|
|
/* Overrides onZoom from MapListener */
|
|
override fun onZoom(event: ZoomEvent?): Boolean
|
|
{
|
|
return (event != null)
|
|
}
|
|
|
|
/* Overrides onScroll from MapListener */
|
|
override fun onScroll(event: ScrollEvent?): Boolean
|
|
{
|
|
return (event != null)
|
|
}
|
|
|
|
private val requery_and_render: Runnable = object : Runnable {
|
|
override fun run()
|
|
{
|
|
Log.i("VOUSSOIR", "TrackFragment.requery_and_render")
|
|
track.start_time = get_datetime(track_query_start_date, track_query_start_time, seconds=0)
|
|
track.end_time = get_datetime(track_query_end_date, track_query_end_time, seconds=59)
|
|
track.load_trkpts()
|
|
Log.i("VOUSSOIR", "TrackFragment.requery_and_render: Reloaded ${track.trkpts.size} trkpts.")
|
|
render_track()
|
|
mapView.invalidate()
|
|
}
|
|
}
|
|
|
|
/* Register the ActivityResultLauncher for saving GPX */
|
|
private val requestSaveGpxLauncher = registerForActivityResult(StartActivityForResult(), this::requestSaveGpxResult)
|
|
|
|
private fun requestSaveGpxResult(result: ActivityResult)
|
|
{
|
|
if (result.resultCode != Activity.RESULT_OK || result.data == null)
|
|
{
|
|
return
|
|
}
|
|
|
|
val targetUri: Uri? = result.data?.data
|
|
if (targetUri == null)
|
|
{
|
|
return
|
|
}
|
|
|
|
val outputsuccess: Uri? = track.export_gpx(activity as Context, targetUri)
|
|
if (outputsuccess == null)
|
|
{
|
|
Toast.makeText(activity as Context, "failed to export for some reason", Toast.LENGTH_LONG).show()
|
|
}
|
|
}
|
|
|
|
/* Overrides onYesNoDialog from YesNoDialogListener */
|
|
override fun onYesNoDialog(type: Int, dialogResult: Boolean, payload: Int, payloadString: String)
|
|
{
|
|
when (type)
|
|
{
|
|
Keys.DIALOG_DELETE_TRACK -> {
|
|
when (dialogResult)
|
|
{
|
|
// user tapped remove track
|
|
true -> {
|
|
track.delete()
|
|
handler.removeCallbacks(requery_and_render)
|
|
handler.postDelayed(requery_and_render, RERENDER_DELAY)
|
|
// switch to TracklistFragment and remove track there
|
|
// val bundle: Bundle = bundleOf(Keys.ARG_TRACK_ID to layout.track.id)
|
|
// findNavController().navigate(R.id.tracklist_fragment, bundle)
|
|
}
|
|
else ->
|
|
{
|
|
;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/* Opens up a file picker to select the save location */
|
|
private fun openSaveGpxDialog()
|
|
{
|
|
val export_name: String = SimpleDateFormat("yyyy-MM-dd", Locale.US).format(track.start_time) + " " + track.device_id + Keys.GPX_FILE_EXTENSION
|
|
val intent = Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
|
|
addCategory(Intent.CATEGORY_OPENABLE)
|
|
type = Keys.MIME_TYPE_GPX
|
|
putExtra(Intent.EXTRA_TITLE, export_name)
|
|
}
|
|
// file gets saved in the ActivityResult
|
|
try
|
|
{
|
|
requestSaveGpxLauncher.launch(intent)
|
|
}
|
|
catch (e: Exception)
|
|
{
|
|
Log.e("VOUSSOIR", "Unable to save GPX.")
|
|
Toast.makeText(activity as Context, R.string.toast_message_install_file_helper, Toast.LENGTH_LONG).show()
|
|
}
|
|
}
|
|
}
|