/** * TrackerMap * Copyright (C) 2021-2022 Iván Ávalos , Henoch Ojeda * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ package mx.trackermap.TrackerMap.android.details.reports import android.app.Activity import android.app.DatePickerDialog import android.app.TimePickerDialog import android.content.Intent import android.os.Build import android.os.Bundle import android.os.Environment import android.provider.DocumentsContract import android.util.Log import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.* import androidx.activity.result.contract.ActivityResultContracts import androidx.core.view.setMargins import androidx.fragment.app.Fragment import androidx.fragment.app.commit import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.card.MaterialCardView import io.ktor.utils.io.* import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.datetime.LocalDateTime import mx.trackermap.TrackerMap.android.R import mx.trackermap.TrackerMap.android.databinding.UnitDetailsReportsBinding import mx.trackermap.TrackerMap.android.details.UnitDetailsAdapter import mx.trackermap.TrackerMap.android.map.MapWrapperFragment import mx.trackermap.TrackerMap.android.shared.FileCache import mx.trackermap.TrackerMap.android.shared.Utils import mx.trackermap.TrackerMap.client.models.EventInformation import mx.trackermap.TrackerMap.controllers.ReportController import mx.trackermap.TrackerMap.utils.Formatter import mx.trackermap.TrackerMap.utils.ReportDates import org.koin.androidx.viewmodel.ext.android.viewModel import java.io.* import java.util.* import kotlin.math.max import kotlin.time.ExperimentalTime @DelicateCoroutinesApi @ExperimentalTime class UnitReportsFragment : Fragment() { private var _binding: UnitDetailsReportsBinding? = null private val binding get() = _binding!! private val unitReportsViewModel: UnitReportsViewModel by viewModel() private lateinit var mapFragment: MapWrapperFragment private lateinit var bottomSheetBehavior: BottomSheetBehavior private var reportFile: ReportController.Report.XlsxReport? = null private var exportAction: UnitReportsViewModel.ExportAction? = null private lateinit var fromPickedDatetime: LocalDateTime private lateinit var fromDatePicker: DatePickerDialog private lateinit var fromTimePicker: TimePickerDialog private lateinit var toPickedDatetime: LocalDateTime private lateinit var toDatePicker: DatePickerDialog private lateinit var toTimePicker: TimePickerDialog private companion object { const val XLSX_MIME_TYPE = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" } override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { _binding = UnitDetailsReportsBinding.inflate(inflater, container, false) return binding.root } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) unitReportsViewModel.setDeviceId( arguments?.getInt(UnitDetailsAdapter.DEVICE_ID_ARG) ?: 0 ) bottomSheetBehavior = BottomSheetBehavior.from(binding.bottomSheet) bottomSheetBehavior.state = BottomSheetBehavior.STATE_EXPANDED setupEvents() initializeMap() } override fun onResume() { super.onResume() setupObservers() } override fun onPause() { super.onPause() removeObservers() if (unitReportsViewModel.report.value == null) { unitReportsViewModel.fetchReport() } } override fun onDestroyView() { super.onDestroyView() _binding = null } private fun initializeMap() { Log.d("UnitReportsFragment", "initializeMap()") mapFragment = MapWrapperFragment.newInstance( showLayerToggle = true ) childFragmentManager.commit { replace(R.id.reportsMapContainer, mapFragment) } } private fun setupEvents() { binding.reportType.setOnPositionChangedListener { position -> unitReportsViewModel.setReportType( when (position) { 0 -> ReportController.ReportType.POSITIONS 1 -> ReportController.ReportType.EVENTS else -> ReportController.ReportType.STOPS } ) } binding.periodButton.setOnClickListener { showPeriodPopUp(it) } binding.fromEditText.setOnClickListener { fromDatePicker.show() } binding.toEditText.setOnClickListener { toDatePicker.show() } binding.exportButton.setOnClickListener { exportAction = UnitReportsViewModel.ExportAction.ACTION_SAVE unitReportsViewModel.fetchReportXlsx() } binding.shareButton.setOnClickListener { exportAction = UnitReportsViewModel.ExportAction.ACTION_SHARE unitReportsViewModel.fetchReportXlsx() } binding.toggleSheet.setOnClickListener { if (bottomSheetBehavior.state == BottomSheetBehavior.STATE_EXPANDED) { bottomSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED } else { bottomSheetBehavior.state = BottomSheetBehavior.STATE_EXPANDED } } bottomSheetBehavior.addBottomSheetCallback(object: BottomSheetBehavior.BottomSheetCallback() { override fun onStateChanged(bottomSheet: View, newState: Int) { if (newState == BottomSheetBehavior.STATE_EXPANDED) { binding.toggleSheet.setImageResource(R.drawable.icon_down) binding.toggleSheet.contentDescription = getString(R.string.shared_slide_down) } else { binding.toggleSheet.setImageResource(R.drawable.icon_up) binding.toggleSheet.contentDescription = getString(R.string.shared_slide_up) } } override fun onSlide(bottomSheet: View, slideOffset: Float) {} }) unitReportsViewModel.setReportPeriod(ReportDates.ReportPeriod.Today()) unitReportsViewModel.setReportType(ReportController.ReportType.POSITIONS) } private fun setupObservers() { Log.d("UnitReportsFragment", "Adding observers") unitReportsViewModel.report.observe(viewLifecycleOwner) { report -> Log.d("UnitReportsFragment", "Report available: $report") when (report) { is ReportController.Report.PositionsReport -> { mapFragment.display(unitReportsViewModel.geofences.value!!) mapFragment.display( report.positions.toTypedArray(), isReport = true, center = true ) showMap(true) } is ReportController.Report.EventsReport -> { display(report.events.toTypedArray()) showMap(false) } is ReportController.Report.StopsReport -> { mapFragment.display(unitReportsViewModel.geofences.value!!) mapFragment.display(report.stops.toTypedArray()) showMap(true) } is ReportController.Report.XlsxReport -> { reportFile = report when (exportAction) { UnitReportsViewModel.ExportAction.ACTION_SHARE -> shareFile() UnitReportsViewModel.ExportAction.ACTION_SAVE -> saveFile() else -> {} } } is ReportController.Report.LoadingReport -> loading() } } unitReportsViewModel.reportPeriod.observe(viewLifecycleOwner) { period -> Log.d("UnitReportsFragment", "Period changed: $period") if (period == null) { return@observe } binding.periodButton.text = context?.getString( when (period) { is ReportDates.ReportPeriod.Today -> R.string.period_today is ReportDates.ReportPeriod.Last24 -> R.string.period_last_24 is ReportDates.ReportPeriod.Yesterday -> R.string.period_yesterday is ReportDates.ReportPeriod.ThisWeek -> R.string.period_this_week is ReportDates.ReportPeriod.Last7 -> R.string.period_last_7 is ReportDates.ReportPeriod.ThisMonth -> R.string.period_this_month is ReportDates.ReportPeriod.Last30 -> R.string.period_last_30 is ReportDates.ReportPeriod.Custom -> R.string.period_custom } ) binding.rangeLayout.visibility = if (period is ReportDates.ReportPeriod.Custom) View.VISIBLE else View.GONE (period as? ReportDates.ReportPeriod.Custom)?.let { val (from, to) = it.getStringDates() binding.fromEditText.setText(Formatter.formatDate(from)) binding.toEditText.setText(Formatter.formatDate(to)) } /** * Initialize date and time pickers */ val (from, to) = period.getObjectDates() fromDatePicker = DatePickerDialog(requireContext(), { p0, p1, p2, p3 -> fromPickedDatetime = LocalDateTime(p1, p2 + 1, p3, from.hour, from.minute) fromTimePicker.show() }, from.year, from.monthNumber, from.dayOfMonth) fromDatePicker.datePicker.maxDate = Calendar.getInstance().timeInMillis fromTimePicker = TimePickerDialog(requireContext(), { p0, p1, p2 -> fromPickedDatetime = LocalDateTime( fromPickedDatetime.year, fromPickedDatetime.monthNumber, fromPickedDatetime.dayOfMonth, p1, p2 ) (period as? ReportDates.ReportPeriod.Custom)?.let { unitReportsViewModel.setReportPeriod(it.withFrom(fromPickedDatetime)) } }, from.hour, from.minute, false) toDatePicker = DatePickerDialog(requireContext(), { p0, p1, p2, p3 -> toPickedDatetime = LocalDateTime(p1, p2 + 1, p3, from.hour, from.minute) toTimePicker.show() }, to.year, to.monthNumber, to.dayOfMonth) toDatePicker.datePicker.maxDate = Calendar.getInstance().timeInMillis toTimePicker = TimePickerDialog(requireContext(), { p0, p1, p2 -> toPickedDatetime = LocalDateTime( toPickedDatetime.year, toPickedDatetime.monthNumber, toPickedDatetime.dayOfMonth, p1, p2 ) (period as? ReportDates.ReportPeriod.Custom)?.let { unitReportsViewModel.setReportPeriod(it.withTo(toPickedDatetime)) } }, to.hour, to.minute, false) } unitReportsViewModel.geofences.observe(viewLifecycleOwner) { geofences -> mapFragment.display(geofences) } } private fun removeObservers() { Log.d("UnitReportsFragment", "Removing observers") unitReportsViewModel.report.removeObservers(viewLifecycleOwner) unitReportsViewModel.reportPeriod.removeObservers(viewLifecycleOwner) unitReportsViewModel.clearReport() } private fun showPeriodPopUp(view: View) { val popOver = PopupMenu(context, view) popOver.menuInflater.inflate(R.menu.report_period_options, popOver.menu) popOver.setOnMenuItemClickListener { item -> unitReportsViewModel.setReportPeriod( when (item.itemId) { R.id.optionToday -> ReportDates.ReportPeriod.Today() R.id.optionLast24 -> ReportDates.ReportPeriod.Last24() R.id.optionYesterday -> ReportDates.ReportPeriod.Yesterday() R.id.optionWeek -> ReportDates.ReportPeriod.ThisWeek() R.id.optionLast7 -> ReportDates.ReportPeriod.Last7() R.id.optionMonth -> ReportDates.ReportPeriod.ThisMonth() R.id.optionLast30 -> ReportDates.ReportPeriod.Last30() R.id.optionCustom -> ReportDates.ReportPeriod.Custom() else -> ReportDates.ReportPeriod.Today() } ) true } popOver.show() } private fun display(events: Array) { Log.d("UnitReportsFragment", "Displaying events: $events") binding.eventsScroll.visibility = View.VISIBLE binding.reportsMapContainer.visibility = View.GONE binding.eventsTable.removeViews(1, max(0, binding.eventsTable.childCount - 1)) val context = requireContext() events.forEach { event -> val layoutParams = TableRow.LayoutParams() layoutParams.setMargins( resources.getDimensionPixelSize(R.dimen.padding) ) val row = TableRow(context) val datetimeText = TextView(context) val eventText = TextView(context) val geofenceText = TextView(context) val addressText = TextView(context) datetimeText.layoutParams = layoutParams eventText.layoutParams = layoutParams geofenceText.layoutParams = layoutParams addressText.layoutParams = layoutParams row.addView(datetimeText) row.addView(eventText) row.addView(geofenceText) row.addView(addressText) binding.eventsTable.addView(row) event.event.eventTime?.let { it -> datetimeText.text = Formatter.formatDate(it) } event.event.type?.let { eventText.text = getString( when (EventInformation.stringToReportType(it)) { EventInformation.Type.DEVICE_ONLINE -> R.string.event_device_online EventInformation.Type.DEVICE_UNKNOWN -> R.string.event_device_unknown EventInformation.Type.DEVICE_OFFLINE -> R.string.event_device_offline EventInformation.Type.DEVICE_INACTIVE -> R.string.event_device_inactive EventInformation.Type.DEVICE_MOVING -> R.string.event_device_moving EventInformation.Type.DEVICE_STOPPED -> R.string.event_device_stopped EventInformation.Type.DEVICE_OVERSPEED -> R.string.event_device_overspeed EventInformation.Type.DEVICE_FUEL_DROP -> R.string.event_device_fuel_drop EventInformation.Type.COMMAND_RESULT -> R.string.event_command_result EventInformation.Type.GEOFENCE_ENTER -> R.string.event_geofence_enter EventInformation.Type.GEOFENCE_EXIT -> R.string.event_geofence_exit EventInformation.Type.ALARM -> R.string.event_alarm EventInformation.Type.IGNITION_ON -> R.string.event_ignition_on EventInformation.Type.IGNITION_OFF -> R.string.event_ignition_off EventInformation.Type.MAINTENANCE -> R.string.event_maintenance EventInformation.Type.TEXT_MESSAGE -> R.string.event_text_message EventInformation.Type.DRIVER_CHANGED -> R.string.event_driver_changed EventInformation.Type.UNKNOWN -> R.string.event_unknown else -> R.string.event_unknown } ) } event.geofence?.let { geofenceText.text = it.name } event.position?.let { addressText.text = it.address } } } private fun loading() { binding.reportLoading.root.visibility = View.VISIBLE binding.eventsScroll.visibility = View.GONE binding.reportsMapContainer.visibility = View.GONE childFragmentManager.commit { hide(mapFragment) } } private fun showMap(shouldShowMap: Boolean) { Log.d("UnitReportsFragment", "showMap($shouldShowMap)") binding.reportLoading.root.visibility = View.GONE binding.eventsScroll.visibility = if (shouldShowMap) View.GONE else View.VISIBLE binding.reportsMapContainer.visibility = if (shouldShowMap) View.VISIBLE else View.GONE childFragmentManager.commit { if (shouldShowMap) { show(mapFragment) } else { hide(mapFragment) } } } private fun saveFile(filename: String = "reports.xlsx") { val filesDir = requireContext().getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS) filesDir?.let { val intent = Intent(Intent.ACTION_CREATE_DOCUMENT).apply { addCategory(Intent.CATEGORY_OPENABLE) type = XLSX_MIME_TYPE putExtra(Intent.EXTRA_TITLE, filename) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { putExtra(DocumentsContract.EXTRA_INITIAL_URI, filesDir) } } createDocumentResult.launch(intent) } } private fun shareFile(filename: String = "reports.xlsx") { context?.let { context -> reportFile?.data?.let { data -> val cacheFile = Utils.saveReportToCache(context, data, filename) Utils.shareFile(context, cacheFile, XLSX_MIME_TYPE) } } } private val createDocumentResult = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { if (it.resultCode == Activity.RESULT_OK) { reportFile?.let { report -> it?.data?.data?.also { uri -> Log.d("UnitReportsFragment", "Downloading file into ${uri.path}") val outputStream = context?.contentResolver?.openOutputStream(uri) outputStream?.let { try { outputStream.write(report.data) outputStream.flush() outputStream.close() Log.d("UnitReportsFragment", "Wrote file into ${uri.path}") activity?.let { context -> // Copy file to cache so we can open it val fc = FileCache(context) fc.removeAll() fc.cacheThis(listOf(uri)) val dir = File(context.cacheDir, "reports_tmp") val files = dir.listFiles() if (files?.isNotEmpty() == true) { val intent = Utils.openFileIntent(context, files[0], XLSX_MIME_TYPE) openFileResult.launch(intent) } } } catch (e: IOException) { e.printStackTrace() } } } } } } private val openFileResult = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { if (it.resultCode == Activity.RESULT_OK) { Log.d("UnitReportsFragment", "Opening file!") } } }