package com.dmc.callcrm.ui import android.content.ClipData import android.content.ClipboardManager import android.content.Context import android.content.Intent import android.media.MediaPlayer import android.net.Uri import android.os.Bundle import android.provider.CallLog import android.view.LayoutInflater import android.widget.EditText import android.widget.TextView import android.widget.Toast import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.dmc.callcrm.ApiConfig import com.dmc.callcrm.R import com.dmc.callcrm.data.CallStats import com.dmc.callcrm.data.Prefs import com.dmc.callcrm.data.fetchWhatsappCallItems import com.dmc.callcrm.network.ApiClient import com.dmc.callcrm.network.CallNoteRequest import com.google.android.material.chip.Chip import kotlinx.coroutines.launch /** * "Tap a Summary card, see the calls behind that number" — opened from AnalyticsFragment's * Summary tab (Total Phone Calls, Incoming Calls, Outgoing Calls, Missed Calls, Rejected * Calls, Never Attended, Not Pickup by Client, Unique Calls, Connected Calls, Unique * Connected Calls). "Working Hours" is a span, not a call count, so it has no detail screen. * * Each metric's filter below mirrors CallStats.analytics()'s own classification EXACTLY, * so the list shown always matches the count on the card that opened it. If that * classification logic ever changes, update it in both places. */ class SummaryDetailActivity : AppCompatActivity() { companion object { const val METRIC = "metric" const val TITLE = "title" const val FROM = "from" const val TO = "to" const val EXCLUDE_DIGITS = "exclude_digits" const val DESELECTED_TYPES = "deselected_types" const val RANGE_LABEL = "range_label" // Metric keys — must match what AnalyticsFragment passes. const val METRIC_TOTAL = "total" const val METRIC_INCOMING = "incoming" const val METRIC_OUTGOING = "outgoing" const val METRIC_MISSED = "missed" const val METRIC_REJECTED = "rejected" const val METRIC_NEVER_ATTENDED = "never_attended" const val METRIC_NOT_PICKUP = "not_pickup" const val METRIC_UNIQUE = "unique" const val METRIC_CONNECTED = "connected" const val METRIC_UNIQUE_CONNECTED = "unique_connected" fun start( ctx: Context, metric: String, title: String, fromMs: Long, toMs: Long, excludeDigits: Set, deselectedTypes: Set, rangeLabel: String ) { ctx.startActivity(Intent(ctx, SummaryDetailActivity::class.java).apply { putExtra(METRIC, metric) putExtra(TITLE, title) putExtra(FROM, fromMs) putExtra(TO, toMs) putStringArrayListExtra(EXCLUDE_DIGITS, ArrayList(excludeDigits)) putStringArrayListExtra(DESELECTED_TYPES, ArrayList(deselectedTypes)) putExtra(RANGE_LABEL, rangeLabel) }) } } private lateinit var prefs: Prefs private lateinit var adapter: CallAdapter private var mediaPlayer: MediaPlayer? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_summary_detail) prefs = Prefs(this) val metric = intent.getStringExtra(METRIC) ?: METRIC_TOTAL val title = intent.getStringExtra(TITLE) ?: "Calls" val from = intent.getLongExtra(FROM, 0) val to = intent.getLongExtra(TO, System.currentTimeMillis()) val excludeDigits = (intent.getStringArrayListExtra(EXCLUDE_DIGITS) ?: arrayListOf()).toSet() val deselectedTypes = (intent.getStringArrayListExtra(DESELECTED_TYPES) ?: arrayListOf()).toSet() val rangeLabel = intent.getStringExtra(RANGE_LABEL).orEmpty() findViewById(R.id.tvTitle).text = title findViewById(R.id.tvRange).text = rangeLabel findViewById(R.id.btnBack).setOnClickListener { finish() } val phoneItems = CallStats(this).itemsInRange(from, to, excludeDigits, deselectedTypes) val list = findViewById(R.id.list) list.layoutManager = LinearLayoutManager(this) adapter = CallAdapter( emptyList(), noteFor = { item -> prefs.noteFor(prefs.noteKey(item.number, item.dateMs, item.durationSec)) }, onCall = { if (it.source == "phone") dial(it.number) else notAvailableForWhatsapp() }, onWhatsapp = { sendWhatsapp(it.number) }, onPlay = { if (it.source == "phone") playRecording(it) else notAvailableForWhatsapp() }, onCopy = { copyNumber(it) }, onMore = { item, _ -> showCallDetails(item) }, onNote = { openNoteDialog(it) } ) list.adapter = adapter // Phone results render immediately (local CallLog query); WhatsApp calls need a // network round trip, so re-render once they arrive — same pattern used by the // Analytics Summary/Analysis tabs that opened this screen, so the count that was // tapped (which already includes WhatsApp) matches what shows up here. fun renderList(items: List) { val filtered = filterFor(metric, items) adapter.submit(filtered) findViewById(R.id.tvEmpty).visibility = if (filtered.isEmpty()) android.view.View.VISIBLE else android.view.View.GONE } renderList(phoneItems) lifecycleScope.launch { val waItems = fetchWhatsappCallItems(prefs).filter { w -> w.dateMs in from..to && !deselectedTypes.contains(w.type.toString()) && run { val digits = w.number.filter { it.isDigit() } digits.isEmpty() || excludeDigits.isEmpty() || excludeDigits.none { digits.endsWith(it) || it.endsWith(digits) } } } renderList(phoneItems + waItems) } } /** Mirrors CallStats.analytics()'s per-item classification — keep in sync with it. */ private fun filterFor(metric: String, items: List): List { fun connected(i: CallStats.CallItem) = (i.type == CallLog.Calls.INCOMING_TYPE || i.type == CallLog.Calls.OUTGOING_TYPE) && i.durationSec > 0 return when (metric) { METRIC_INCOMING -> items.filter { it.type == CallLog.Calls.INCOMING_TYPE && it.durationSec > 0 } METRIC_OUTGOING -> items.filter { it.type == CallLog.Calls.OUTGOING_TYPE } METRIC_MISSED -> items.filter { it.type == CallLog.Calls.MISSED_TYPE } METRIC_REJECTED -> items.filter { it.type == CallLog.Calls.REJECTED_TYPE } METRIC_NEVER_ATTENDED -> items.filter { it.type == CallLog.Calls.INCOMING_TYPE && it.durationSec == 0 } METRIC_NOT_PICKUP -> items.filter { it.type == CallLog.Calls.OUTGOING_TYPE && it.durationSec == 0 } METRIC_UNIQUE -> items.distinctBy { it.number } // list is already date-desc; keeps most recent call per number METRIC_CONNECTED -> items.filter { connected(it) } METRIC_UNIQUE_CONNECTED -> items.filter { connected(it) }.distinctBy { it.number } else -> items // METRIC_TOTAL } } // ---- Row actions (same behavior as CallHistoryFragment's; kept independent so // changes to one screen never risk breaking the other) ---- private fun dial(number: String) { try { startActivity(Intent(Intent.ACTION_DIAL, Uri.parse("tel:$number"))) } catch (_: Exception) {} } private fun notAvailableForWhatsapp() { Toast.makeText(this, "Not available for WhatsApp calls", Toast.LENGTH_SHORT).show() } private fun copyNumber(item: CallStats.CallItem) { val cm = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager cm.setPrimaryClip(ClipData.newPlainText("Phone number", item.number)) Toast.makeText(this, "Number copied", Toast.LENGTH_SHORT).show() } private fun sendWhatsapp(number: String) { val digits = number.filter { it.isDigit() } if (digits.isEmpty()) { Toast.makeText(this, "No number to message", Toast.LENGTH_SHORT).show(); return } try { startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://wa.me/$digits"))) } catch (_: Exception) { Toast.makeText(this, "WhatsApp is not installed", Toast.LENGTH_SHORT).show() } } private fun showCallDetails(item: CallStats.CallItem) { val df = java.text.SimpleDateFormat("dd MMM yyyy, hh:mm a", java.util.Locale.getDefault()) AlertDialog.Builder(this) .setTitle(item.name ?: "Unknown") .setMessage( "Number: ${item.number}\n" + "Time: ${df.format(java.util.Date(item.dateMs))}\n" + "Duration: ${item.durationSec}s" ) .setPositiveButton("Close", null) .show() } // ---- Notes (same server sync as CallHistoryFragment) ---- private fun openNoteDialog(item: CallStats.CallItem) { val key = prefs.noteKey(item.number, item.dateMs, item.durationSec) val view = LayoutInflater.from(this).inflate(R.layout.dialog_note, null) val noteText = view.findViewById(R.id.noteText) val chipGroup = view.findViewById(R.id.chipGroup) noteText.setText(prefs.noteFor(key).orEmpty()) val dialog = AlertDialog.Builder(this) .setTitle("Call note") .setView(view) .setPositiveButton("Save", null) .setNegativeButton("Cancel", null) .create() dialog.setOnShowListener { dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener { val note = noteText.text.toString() prefs.saveNoteLocally(key, note) adapter.refreshNotes() saveNoteToServer(item, note) dialog.dismiss() } } dialog.show() if (!prefs.isRegistered) return lifecycleScope.launch { try { val resp = ApiClient.service.noteTemplates(prefs.bearer()) val templates = resp.body()?.templates.orEmpty() chipGroup.removeAllViews() templates.forEach { t -> val chip = Chip(this@SummaryDetailActivity) chip.text = t.title chip.isClickable = true chip.setOnClickListener { noteText.setText(t.body ?: t.title) } chipGroup.addView(chip) } } catch (_: Exception) { /* templates are a convenience; ignore failures */ } } } private fun saveNoteToServer(item: CallStats.CallItem, note: String) { if (!prefs.isRegistered) return lifecycleScope.launch { try { ApiClient.service.saveCallNote( prefs.bearer(), CallNoteRequest( number = item.number, name = item.name, type = item.type, date = item.dateMs, duration = item.durationSec, note = note, deviceCallId = item.id ) ) } catch (_: Exception) { Toast.makeText(this@SummaryDetailActivity, "Note saved on device; will sync when online", Toast.LENGTH_SHORT).show() } } } // ---- Recording playback (same download-then-play approach as CallHistoryFragment) ---- private fun playRecording(item: CallStats.CallItem) { if (!prefs.isRegistered) { Toast.makeText(this, "Register the app first to fetch recordings", Toast.LENGTH_SHORT).show() return } lifecycleScope.launch { val resp = try { ApiClient.service.findRecording(prefs.bearer(), item.number, item.dateMs, item.durationSec) } catch (_: Exception) { null } val body = resp?.body() if (body?.ok != true || body.found != true || body.recordingId == null) { Toast.makeText(this@SummaryDetailActivity, "No recording found for this call", Toast.LENGTH_SHORT).show() return@launch } showPlayerDialog(body.recordingId, prefs.apiToken.orEmpty()) } } private fun showPlayerDialog(recordingId: Long, token: String) { mediaPlayer?.release() val url = ApiConfig.recordingStreamUrl(recordingId, token) val dialog = AlertDialog.Builder(this) .setTitle("Loading recording…") .setMessage("Downloading…") .setNegativeButton("Stop") { d, _ -> mediaPlayer?.release(); mediaPlayer = null; d.dismiss() } .setCancelable(false) .create() dialog.show() lifecycleScope.launch { val localFile = try { kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { val client = okhttp3.OkHttpClient.Builder() .connectTimeout(15, java.util.concurrent.TimeUnit.SECONDS) .readTimeout(15, java.util.concurrent.TimeUnit.SECONDS) .writeTimeout(15, java.util.concurrent.TimeUnit.SECONDS) .retryOnConnectionFailure(false) .build() val request = okhttp3.Request.Builder().url(url).build() val bytes = client.newCall(request).execute().use { resp -> if (!resp.isSuccessful) throw java.io.IOException("HTTP ${resp.code}") resp.body?.bytes() ?: throw java.io.IOException("empty response") } val f = java.io.File(cacheDir, "play_$recordingId.m4a") f.writeBytes(bytes) f } } catch (e: Exception) { dialog.dismiss() Toast.makeText(this@SummaryDetailActivity, "Couldn't download the recording: ${e.message}", Toast.LENGTH_SHORT).show() return@launch } dialog.setMessage("Playing…") mediaPlayer = MediaPlayer().apply { setOnCompletionListener { dialog.setMessage("Finished") release(); mediaPlayer = null localFile.delete() } setOnErrorListener { _, _, _ -> dialog.dismiss() Toast.makeText(this@SummaryDetailActivity, "Couldn't play the recording", Toast.LENGTH_SHORT).show() localFile.delete() true } try { setDataSource(localFile.absolutePath) prepare() start() } catch (e: Exception) { dialog.dismiss() Toast.makeText(this@SummaryDetailActivity, "Couldn't play the recording", Toast.LENGTH_SHORT).show() localFile.delete() } } } } override fun onDestroy() { mediaPlayer?.release(); mediaPlayer = null super.onDestroy() } }