package it.danieleverducci.lunatracker import android.app.DatePickerDialog import android.app.TimePickerDialog import android.content.DialogInterface import android.content.Intent import android.os.Bundle import android.os.Handler import android.util.Log import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.AdapterView import android.widget.ArrayAdapter import android.widget.EditText import android.widget.NumberPicker import android.widget.PopupWindow import android.widget.Spinner import android.widget.TextView import android.widget.Toast import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity import androidx.core.content.ContextCompat import androidx.core.view.children import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.google.android.material.progressindicator.LinearProgressIndicator import com.google.android.material.slider.Slider import com.thegrizzlylabs.sardineandroid.impl.SardineException import it.danieleverducci.lunatracker.adapters.LunaEventRecyclerAdapter import it.danieleverducci.lunatracker.entities.Logbook import it.danieleverducci.lunatracker.entities.LunaEvent import it.danieleverducci.lunatracker.repository.FileLogbookRepository import it.danieleverducci.lunatracker.repository.LocalSettingsRepository import it.danieleverducci.lunatracker.repository.LogbookListObtainedListener import it.danieleverducci.lunatracker.repository.LogbookLoadedListener import it.danieleverducci.lunatracker.repository.LogbookRepository import it.danieleverducci.lunatracker.repository.LogbookSavedListener import it.danieleverducci.lunatracker.repository.WebDAVLogbookRepository import kotlinx.coroutines.Runnable import okio.IOException import org.json.JSONException import utils.NumericUtils import java.text.DateFormat import java.util.Calendar import java.util.Date class MainActivity : AppCompatActivity() { companion object { val TAG = "MainActivity" val UPDATE_EVERY_SECS: Long = 30 val DEBUG_CHECK_LOGBOOK_CONSISTENCY = false } var logbook: Logbook? = null lateinit var adapter: LunaEventRecyclerAdapter lateinit var progressIndicator: LinearProgressIndicator lateinit var buttonsContainer: ViewGroup lateinit var recyclerView: RecyclerView lateinit var handler: Handler var savingEvent = false val updateListRunnable: Runnable = Runnable { if (logbook != null) loadLogbook(logbook!!.name) handler.postDelayed(updateListRunnable, 1000*60) } var logbookRepo: LogbookRepository? = null var showingOverflowPopupWindow = false override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) handler = Handler(mainLooper) adapter = LunaEventRecyclerAdapter(this) adapter.onItemClickListener = object: LunaEventRecyclerAdapter.OnItemClickListener{ override fun onItemClick(event: LunaEvent) { showEventDetailDialog(event) } } // Show view setContentView(R.layout.activity_main) progressIndicator = findViewById(R.id.progress_indicator) buttonsContainer = findViewById(R.id.buttons_container) recyclerView = findViewById(R.id.list_events) recyclerView.setLayoutManager(LinearLayoutManager(applicationContext)) recyclerView.adapter = adapter // Set listeners findViewById(R.id.logbooks_add_button).setOnClickListener { showAddLogbookDialog(true) } findViewById(R.id.button_bottle).setOnClickListener { askBabyBottleContent() } findViewById(R.id.button_food).setOnClickListener { askNotes(LunaEvent(LunaEvent.TYPE_FOOD)) } findViewById(R.id.button_nipple_left).setOnClickListener { logEvent( LunaEvent( LunaEvent.TYPE_BREASTFEEDING_LEFT_NIPPLE ) ) } findViewById(R.id.button_nipple_both).setOnClickListener { logEvent( LunaEvent( LunaEvent.TYPE_BREASTFEEDING_BOTH_NIPPLE ) ) } findViewById(R.id.button_nipple_right).setOnClickListener { logEvent( LunaEvent( LunaEvent.TYPE_BREASTFEEDING_RIGHT_NIPPLE ) ) } findViewById(R.id.button_change_poo).setOnClickListener { logEvent( LunaEvent( LunaEvent.TYPE_DIAPERCHANGE_POO ) ) } findViewById(R.id.button_change_pee).setOnClickListener { logEvent( LunaEvent( LunaEvent.TYPE_DIAPERCHANGE_PEE ) ) } val moreButton = findViewById(R.id.button_more) moreButton.setOnClickListener { showOverflowPopupWindow(moreButton) } findViewById(R.id.button_no_connection_settings).setOnClickListener({ showSettings() }) findViewById(R.id.button_settings).setOnClickListener({ showSettings() }) findViewById(R.id.button_no_connection_retry).setOnClickListener({ // This may happen at start, when logbook is still null: better ask the logbook list loadLogbookList() }) findViewById(R.id.button_sync).setOnClickListener({ loadLogbookList() }) } fun showSettings() { val i = Intent(this, SettingsActivity::class.java) startActivity(i) } fun showLogbook() { // Show logbook if (logbook == null) Log.w(TAG, "showLogbook(): logbook is null!") adapter.items.clear() adapter.items.addAll(logbook?.logs ?: listOf()) adapter.notifyDataSetChanged() } override fun onStart() { super.onStart() val settingsRepository = LocalSettingsRepository(this) if (settingsRepository.loadDataRepository() == LocalSettingsRepository.DATA_REPO.WEBDAV) { val webDavCredentials = settingsRepository.loadWebdavCredentials() if (webDavCredentials == null) { throw IllegalStateException("Corrupted local settings: repo is webdav, but no webdav login data saved") } logbookRepo = WebDAVLogbookRepository( webDavCredentials[0], webDavCredentials[1], webDavCredentials[2] ) } else { logbookRepo = FileLogbookRepository() } // Update list dates adapter.notifyDataSetChanged() if (logbook != null) { // Already running: reload data for currently selected logbook loadLogbook(logbook!!.name) } else { // First start: load logbook list loadLogbookList() } } override fun onStop() { handler.removeCallbacks(updateListRunnable) super.onStop() } fun askBabyBottleContent() { // Show number picker dialog val localSettings = LocalSettingsRepository(this) val d = AlertDialog.Builder(this) val dialogView = layoutInflater.inflate(R.layout.number_picker_dialog, null) d.setTitle(R.string.log_bottle_dialog_title) d.setMessage(R.string.log_bottle_dialog_description) d.setView(dialogView) val numberPicker = dialogView.findViewById(R.id.dialog_number_picker) numberPicker.minValue = 1 // "10" numberPicker.maxValue = 25 // "250 numberPicker.displayedValues = ((10..250 step 10).map { it.toString() }.toTypedArray()) numberPicker.wrapSelectorWheel = false numberPicker.value = localSettings.loadBabyBottleContent() d.setPositiveButton(android.R.string.ok) { dialogInterface, i -> logEvent(LunaEvent(LunaEvent.TYPE_BABY_BOTTLE, numberPicker.value * 10)) localSettings.saveBabyBottleContent(numberPicker.value) } d.setNegativeButton(android.R.string.cancel) { dialogInterface, i -> dialogInterface.dismiss() } val alertDialog = d.create() alertDialog.show() } fun askWeightValue() { // Show number picker dialog val d = AlertDialog.Builder(this) val dialogView = layoutInflater.inflate(R.layout.number_edit_dialog, null) d.setTitle(R.string.log_weight_dialog_title) d.setMessage(R.string.log_weight_dialog_description) d.setView(dialogView) val weightET = dialogView.findViewById(R.id.dialog_number_edittext) d.setPositiveButton(android.R.string.ok) { dialogInterface, i -> val weight = weightET.text.toString().toIntOrNull() if (weight != null) logEvent(LunaEvent(LunaEvent.TYPE_WEIGHT, weight)) else Toast.makeText(this, R.string.toast_integer_error, Toast.LENGTH_SHORT).show() } d.setNegativeButton(android.R.string.cancel) { dialogInterface, i -> dialogInterface.dismiss() } val alertDialog = d.create() alertDialog.show() } fun askTemperatureValue() { // Show number picker dialog val d = AlertDialog.Builder(this) val dialogView = layoutInflater.inflate(R.layout.temperature_dialog, null) d.setTitle(R.string.log_temperature_dialog_title) d.setMessage(R.string.log_temperature_dialog_description) d.setView(dialogView) val tempSlider = dialogView.findViewById(R.id.dialog_temperature_value) val range = NumericUtils(this).getValidEventQuantityRange(LunaEvent.TYPE_TEMPERATURE)!! tempSlider.valueFrom = range.first.toFloat() tempSlider.valueTo = range.second.toFloat() tempSlider.value = range.third.toFloat() val tempTextView = dialogView.findViewById(R.id.dialog_temperature_display) tempTextView.text = range.third.toString() tempSlider.addOnChangeListener({s, v, b -> tempTextView.text = v.toString()}) d.setPositiveButton(android.R.string.ok) { dialogInterface, i -> val temperature = (tempSlider.value * 10).toInt() // In tenth of a grade logEvent(LunaEvent(LunaEvent.TYPE_TEMPERATURE, temperature)) } d.setNegativeButton(android.R.string.cancel) { dialogInterface, i -> dialogInterface.dismiss() } val alertDialog = d.create() alertDialog.show() } fun askNotes(lunaEvent: LunaEvent) { val d = AlertDialog.Builder(this) val dialogView = layoutInflater.inflate(R.layout.dialog_notes, null) d.setTitle(lunaEvent.getTypeDescription(this)) d.setMessage(lunaEvent.getDialogMessage(this)) d.setView(dialogView) val notesET = dialogView.findViewById(R.id.notes_edittext) val qtyET = dialogView.findViewById(R.id.notes_qty_edittext) if (lunaEvent.type == LunaEvent.TYPE_NOTE || lunaEvent.type == LunaEvent.TYPE_CUSTOM) qtyET.visibility = View.GONE d.setPositiveButton(android.R.string.ok) { dialogInterface, i -> val qtyStr = qtyET.text.toString() if (qtyStr.isNotEmpty()) { val qty = qtyStr.toIntOrNull() if (qty == null) { Toast.makeText(this, R.string.toast_integer_error, Toast.LENGTH_SHORT).show() return@setPositiveButton } lunaEvent.quantity = qty } val notes = notesET.text.toString() lunaEvent.notes = notes logEvent(lunaEvent) } d.setNegativeButton(android.R.string.cancel) { dialogInterface, i -> dialogInterface.dismiss() } val alertDialog = d.create() alertDialog.show() } fun askToTrimLogbook() { val d = AlertDialog.Builder(this) d.setTitle(R.string.trim_logbook_dialog_title) d.setMessage( when (LocalSettingsRepository(this).loadDataRepository()) { LocalSettingsRepository.DATA_REPO.WEBDAV -> R.string.trim_logbook_dialog_message_dav else -> R.string.trim_logbook_dialog_message_local } ) d.setPositiveButton(R.string.trim_logbook_dialog_button_ok) { dialogInterface, i -> logbook?.trim() saveLogbook() } d.setNegativeButton(R.string.trim_logbook_dialog_button_cancel) { dialogInterface, i -> dialogInterface.dismiss() } val alertDialog = d.create() alertDialog.show() } fun showEventDetailDialog(event: LunaEvent) { val dateFormat = DateFormat.getDateTimeInstance(); val d = AlertDialog.Builder(this) d.setTitle(R.string.dialog_event_detail_title) val dialogView = layoutInflater.inflate(R.layout.dialog_event_detail, null) dialogView.findViewById(R.id.dialog_event_detail_type_emoji).setText(event.getTypeEmoji(this)) dialogView.findViewById(R.id.dialog_event_detail_type_description).setText(event.getTypeDescription(this)) dialogView.findViewById(R.id.dialog_event_detail_type_quantity).setText( NumericUtils(this).formatEventQuantity(event) ) dialogView.findViewById(R.id.dialog_event_detail_type_notes).setText(event.notes) val currentDateTime = Calendar.getInstance() currentDateTime.time = Date(event.time * 1000) val dateTextView = dialogView.findViewById(R.id.dialog_event_detail_type_date) dateTextView.text = String.format(getString(R.string.dialog_event_detail_datetime_icon), dateFormat.format(currentDateTime.time)) dateTextView.setOnClickListener({ // Show datetime picker val startYear = currentDateTime.get(Calendar.YEAR) val startMonth = currentDateTime.get(Calendar.MONTH) val startDay = currentDateTime.get(Calendar.DAY_OF_MONTH) val startHour = currentDateTime.get(Calendar.HOUR_OF_DAY) val startMinute = currentDateTime.get(Calendar.MINUTE) DatePickerDialog(this, DatePickerDialog.OnDateSetListener { _, year, month, day -> TimePickerDialog(this, TimePickerDialog.OnTimeSetListener { _, hour, minute -> val pickedDateTime = Calendar.getInstance() pickedDateTime.set(year, month, day, hour, minute) currentDateTime.time = pickedDateTime.time }, startHour, startMinute, false).show() }, startYear, startMonth, startDay).show() }) d.setView(dialogView) d.setPositiveButton(R.string.dialog_event_detail_save_button) { dialogInterface, i -> { // Save event event.time = currentDateTime.time.time / 1000 // Seconds since epoch // TODO: move event at the correct logbook position saveLogbook() } } d.setNegativeButton(R.string.dialog_event_detail_cancel_button) { dialogInterface, i -> dialogInterface.dismiss() } d.setNeutralButton(R.string.dialog_event_detail_delete_button) { dialogInterface, i -> deleteEvent(event) } val alertDialog = d.create() alertDialog.show() alertDialog.getButton(DialogInterface.BUTTON_NEUTRAL).setTextColor(ContextCompat.getColor(this, R.color.danger)) } fun showAddLogbookDialog(requestedByUser: Boolean) { val d = AlertDialog.Builder(this) d.setTitle(R.string.dialog_add_logbook_title) val dialogView = layoutInflater.inflate(R.layout.dialog_add_logbook, null) dialogView.findViewById(R.id.dialog_add_logbook_message).text = getString( if (requestedByUser) R.string.dialog_add_logbook_message else R.string.dialog_add_logbook_message_intro ) val logbookNameEditText = dialogView.findViewById(R.id.dialog_add_logbook_logbookname) d.setView(dialogView) d.setPositiveButton(android.R.string.ok) { dialogInterface, i -> addLogbook(logbookNameEditText.text.toString()) } if (requestedByUser) { d.setCancelable(true) d.setNegativeButton(android.R.string.cancel) { dialogInterface, i -> dialogInterface.dismiss() } } else { d.setCancelable(false) } val alertDialog = d.create() alertDialog.show() } fun loadLogbookList() { setLoading(true) logbookRepo?.listLogbooks(this, object: LogbookListObtainedListener { override fun onLogbookListObtained(logbooksNames: ArrayList) { runOnUiThread({ if (logbooksNames.isEmpty()) { // First run, no logbook: create one showAddLogbookDialog(false) return@runOnUiThread } // Show logbooks dropdown val spinner = findViewById(R.id.logbooks_spinner) val sAdapter = ArrayAdapter(this@MainActivity, android.R.layout.simple_spinner_item) sAdapter.setDropDownViewResource(R.layout.row_logbook_spinner) for (ln in logbooksNames) { sAdapter.add( if (ln.isEmpty()) getString(R.string.default_logbook_name) else ln ) } spinner.adapter = sAdapter spinner.onItemSelectedListener = object: AdapterView.OnItemSelectedListener { override fun onItemSelected( parent: AdapterView<*>?, view: View?, position: Int, id: Long ) { // Changed logbook: empty list adapter.items.clear() adapter.notifyDataSetChanged() // Load logbook loadLogbook(logbooksNames.get(position)) } override fun onNothingSelected(parent: AdapterView<*>?) {} } }) } override fun onIOError(error: IOException) { Log.e(TAG, "Unable to load logbooks list (IOError): $error") runOnUiThread({ setLoading(false) onRepoError(getString(R.string.settings_network_error) + error.toString()) }) } override fun onWebDAVError(error: SardineException) { Log.e(TAG, "Unable to load logbooks list (SardineException): $error") runOnUiThread({ setLoading(false) onRepoError( if(error.toString().contains("401")) { getString(R.string.settings_webdav_error_denied) } else if(error.toString().contains("503")) { getString(R.string.settings_webdav_error_server_offline) } else { getString(R.string.settings_webdav_error_generic) + error.toString() } ) }) } override fun onError(error: Exception) { Log.e(TAG, "Unable to load logbooks list: $error") runOnUiThread({ setLoading(false) onRepoError(getString(R.string.settings_generic_error) + error.toString()) }) } }) } fun addLogbook(logbookName: String) { val newLogbook = Logbook(logbookName) setLoading(true) logbookRepo?.saveLogbook(this, newLogbook, object: LogbookSavedListener{ override fun onLogbookSaved() { Log.d(TAG, "Logbook $logbookName created") runOnUiThread({ setLoading(false) loadLogbookList() Toast.makeText(this@MainActivity, getString(R.string.logbook_created) + logbookName, Toast.LENGTH_SHORT).show() }) } override fun onIOError(error: IOException) { runOnUiThread({ onRepoError(getString(R.string.settings_network_error) + error.toString()) }) } override fun onWebDAVError(error: SardineException) { runOnUiThread({ onRepoError( if(error.toString().contains("401")) { getString(R.string.settings_webdav_error_denied) } else if(error.toString().contains("503")) { getString(R.string.settings_webdav_error_server_offline) } else { getString(R.string.settings_webdav_error_generic) + error.toString() } ) }) } override fun onJSONError(error: JSONException) { runOnUiThread({ onRepoError(getString(R.string.settings_json_error) + error.toString()) }) } override fun onError(error: Exception) { runOnUiThread({ onRepoError(getString(R.string.settings_generic_error) + error.toString()) }) } }) } fun loadLogbook(name: String) { if (savingEvent) return // Reset time counter handler.removeCallbacks(updateListRunnable) handler.postDelayed(updateListRunnable, UPDATE_EVERY_SECS*1000) // Load data setLoading(true) logbookRepo?.loadLogbook(this, name, object: LogbookLoadedListener{ override fun onLogbookLoaded(lb: Logbook) { runOnUiThread({ setLoading(false) findViewById(R.id.no_connection_screen).visibility = View.GONE logbook = lb showLogbook() if (DEBUG_CHECK_LOGBOOK_CONSISTENCY) { for (e in logbook?.logs ?: listOf()) { val em = e.getTypeEmoji(this@MainActivity) if (em == getString(R.string.event_unknown_type)) { Log.e(TAG, "UNKNOWN: ${e.type}") } } } }) } override fun onIOError(error: IOException) { runOnUiThread({ setLoading(false) onRepoError(getString(R.string.settings_network_error) + error.toString()) }) } override fun onWebDAVError(error: SardineException) { runOnUiThread({ setLoading(false) onRepoError( if(error.toString().contains("401")) { getString(R.string.settings_webdav_error_denied) } else if(error.toString().contains("503")) { getString(R.string.settings_webdav_error_server_offline) } else { getString(R.string.settings_webdav_error_generic) + error.toString() } ) }) } override fun onJSONError(error: JSONException) { runOnUiThread({ setLoading(false) onRepoError(getString(R.string.settings_json_error) + error.toString()) }) } override fun onError(error: Exception) { runOnUiThread({ setLoading(false) onRepoError(getString(R.string.settings_generic_error) + error.toString()) }) } }) } fun onRepoError(message: String){ runOnUiThread({ setLoading(false) findViewById(R.id.no_connection_screen).visibility = View.VISIBLE findViewById(R.id.no_connection_screen_message).text = message }) } fun logEvent(event: LunaEvent) { savingEvent(true) adapter.items.add(0, event) adapter.notifyItemInserted(0) recyclerView.smoothScrollToPosition(0) setLoading(true) logbook?.logs?.add(0, event) saveLogbook(event) // Check logbook size to avoid OOM errors if (logbook?.isTooBig() == true) { askToTrimLogbook() } } fun deleteEvent(event: LunaEvent) { // Update view savingEvent(true) adapter.items.remove(event) adapter.notifyDataSetChanged() // Update data setLoading(true) logbook?.logs?.remove(event) saveLogbook() } /** * Saves the logbook. If saving while adding an event, please specify the event so in case * of error can be removed from the list. */ fun saveLogbook(lastEventAdded: LunaEvent? = null) { if (logbook == null) { Log.e(TAG, "Trying to save logbook, but logbook is null!") return } logbookRepo?.saveLogbook(this, logbook!!, object: LogbookSavedListener{ override fun onLogbookSaved() { Log.d(TAG, "Logbook saved") runOnUiThread({ setLoading(false) Toast.makeText( this@MainActivity, if (lastEventAdded != null) R.string.toast_event_added else R.string.toast_logbook_saved, Toast.LENGTH_SHORT ).show() savingEvent(false) }) } override fun onIOError(error: IOException) { runOnUiThread({ setLoading(false) onRepoError(getString(R.string.settings_network_error) + error.toString()) if (lastEventAdded != null) onAddError(lastEventAdded, error.toString()) }) } override fun onWebDAVError(error: SardineException) { runOnUiThread({ setLoading(false) onRepoError( if(error.toString().contains("401")) { getString(R.string.settings_webdav_error_denied) } else if(error.toString().contains("503")) { getString(R.string.settings_webdav_error_server_offline) } else { getString(R.string.settings_webdav_error_generic) + error.toString() } ) if (lastEventAdded != null) onAddError(lastEventAdded, error.toString()) }) } override fun onJSONError(error: JSONException) { runOnUiThread({ setLoading(false) onRepoError(getString(R.string.settings_json_error) + error.toString()) if (lastEventAdded != null) onAddError(lastEventAdded, error.toString()) }) } override fun onError(error: Exception) { runOnUiThread({ setLoading(false) onRepoError(getString(R.string.settings_generic_error) + error.toString()) if (lastEventAdded != null) onAddError(lastEventAdded, error.toString()) }) } }) } private fun onAddError(event: LunaEvent, error: String) { Log.e(TAG, "Logbook was NOT saved! $error") runOnUiThread({ setLoading(false) Toast.makeText(this@MainActivity, R.string.toast_event_add_error, Toast.LENGTH_SHORT).show() adapter.items.remove(event) adapter.notifyDataSetChanged() savingEvent(false) }) } private fun setLoading(loading: Boolean) { if (loading) { progressIndicator.visibility = View.VISIBLE } else { progressIndicator.visibility = View.INVISIBLE } } private fun savingEvent(saving: Boolean) { if (saving) { savingEvent = true buttonsContainer.alpha = 0.2f } else { savingEvent = false buttonsContainer.alpha = 1f } } private fun showOverflowPopupWindow(anchor: View) { if (showingOverflowPopupWindow) return PopupWindow(anchor.context).apply { isOutsideTouchable = true val inflater = LayoutInflater.from(anchor.context) contentView = inflater.inflate(R.layout.more_events_popup, null) contentView.findViewById(R.id.button_medicine).setOnClickListener({ askNotes(LunaEvent(LunaEvent.TYPE_MEDICINE)) dismiss() }) contentView.findViewById(R.id.button_enema).setOnClickListener({ logEvent(LunaEvent(LunaEvent.TYPE_ENEMA)) dismiss() }) contentView.findViewById(R.id.button_note).setOnClickListener({ askNotes(LunaEvent(LunaEvent.TYPE_NOTE)) dismiss() }) contentView.findViewById(R.id.button_temperature).setOnClickListener({ askTemperatureValue() dismiss() }) contentView.findViewById(R.id.button_colic).setOnClickListener({ logEvent( LunaEvent(LunaEvent.TYPE_COLIC) ) dismiss() }) contentView.findViewById(R.id.button_scale).setOnClickListener({ askWeightValue() dismiss() }) }.also { popupWindow -> popupWindow.setOnDismissListener({ Handler(mainLooper).postDelayed({ showingOverflowPopupWindow = false }, 500) }) popupWindow.showAsDropDown(anchor) showingOverflowPopupWindow = true } } }