package com.dmc.callcrm.ui import android.app.DatePickerDialog import android.os.Bundle import android.provider.CallLog import android.view.LayoutInflater import android.widget.PopupMenu import android.view.View import android.view.ViewGroup import android.widget.GridLayout import android.widget.TextView import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope import com.dmc.callcrm.R import com.dmc.callcrm.data.CallStats import com.dmc.callcrm.data.Prefs import com.dmc.callcrm.network.ApiClient import com.google.android.material.tabs.TabLayout import kotlinx.coroutines.launch import java.text.SimpleDateFormat import java.util.Calendar import java.util.Locale // RecordHolderDetailActivity, AvgDurationDetailActivity, Top10ListActivity are in this // same package (com.dmc.callcrm.ui) so no explicit import is needed for them. class AnalyticsFragment : Fragment() { private lateinit var prefs: Prefs private var from: Long = 0 private var to: Long = 0 private var rangeLabel = "This Month" private var showAnalysis = false // "" = Phone & WhatsApp, "phone" = Phone only, "whatsapp" = WhatsApp only private var callMethodFilter = "" private val typeOptions = listOf( "Incoming" to CallLog.Calls.INCOMING_TYPE, "Outgoing" to CallLog.Calls.OUTGOING_TYPE, "Missed" to CallLog.Calls.MISSED_TYPE, "Rejected" to CallLog.Calls.REJECTED_TYPE ) override fun onCreateView(i: LayoutInflater, c: ViewGroup?, s: Bundle?): View = i.inflate(R.layout.fragment_analytics, c, false) override fun onViewCreated(v: View, s: Bundle?) { prefs = Prefs(requireContext()) setThisMonthRange() val tabs = v.findViewById(R.id.tabs) tabs.addTab(tabs.newTab().setText("Summary")) tabs.addTab(tabs.newTab().setText("Analysis")) tabs.addOnTabSelectedListener(object : TabLayout.OnTabSelectedListener { override fun onTabSelected(t: TabLayout.Tab) { showAnalysis = t.position == 1; render(v) } override fun onTabUnselected(t: TabLayout.Tab) {} override fun onTabReselected(t: TabLayout.Tab) {} }) v.findViewById(R.id.btnFilter).setOnClickListener { showFilterDialog(v) } v.findViewById(R.id.btnRange).setOnClickListener { showRangeMenu(v, it) } v.findViewById(R.id.btnExNo).setOnClickListener { showExNoMenu(v, it) } v.findViewById(R.id.btnCallMethod).setOnClickListener { showCallMethodMenu(v, it) } refreshExcludedNumbersIfStale() render(v) } private fun setThisMonthRange() { val cal = Calendar.getInstance() cal.set(Calendar.DAY_OF_MONTH, 1); cal.set(Calendar.HOUR_OF_DAY, 0) cal.set(Calendar.MINUTE, 0); cal.set(Calendar.SECOND, 0) from = cal.timeInMillis to = System.currentTimeMillis() } private fun showFilterDialog(v: View) { val checked = typeOptions.map { !prefs.analyticsDeselectedTypes.contains(it.second.toString()) }.toBooleanArray() val labels = typeOptions.map { it.first }.toTypedArray() androidx.appcompat.app.AlertDialog.Builder(requireContext()) .setTitle("Filter call types") .setMultiChoiceItems(labels, checked) { _, which, isChecked -> checked[which] = isChecked } .setPositiveButton("Apply") { _, _ -> val deselected = typeOptions.filterIndexed { i, _ -> !checked[i] }.map { it.second.toString() }.toSet() prefs.analyticsDeselectedTypes = deselected updateFilterBadge(v) render(v) } .setNegativeButton("Cancel", null) .show() } private fun updateFilterBadge(v: View) { val n = prefs.analyticsDeselectedTypes.size + if (prefs.analyticsExcludeExcluded) 1 else 0 v.findViewById(R.id.btnFilter).text = if (n > 0) "⏷ Filter ($n)" else "⏷ Filter" } private fun showRangeMenu(v: View, anchor: View) { val menu = PopupMenu(requireContext(), anchor) listOf("Today", "Yesterday", "This Week", "Last Week", "This Month", "Last Month", "Custom Range").forEach { menu.menu.add(it) } menu.setOnMenuItemClickListener { mi -> when (mi.title) { "Custom Range" -> pickCustomRange(v) else -> { applyPreset(mi.title.toString()); render(v) } } true } menu.show() } private fun applyPreset(name: String) { val cal = Calendar.getInstance() rangeLabel = name when (name) { "Today" -> { cal.set(Calendar.HOUR_OF_DAY, 0); cal.set(Calendar.MINUTE, 0); cal.set(Calendar.SECOND, 0) from = cal.timeInMillis; to = System.currentTimeMillis() } "Yesterday" -> { cal.add(Calendar.DAY_OF_MONTH, -1) cal.set(Calendar.HOUR_OF_DAY, 0); cal.set(Calendar.MINUTE, 0); cal.set(Calendar.SECOND, 0) from = cal.timeInMillis cal.set(Calendar.HOUR_OF_DAY, 23); cal.set(Calendar.MINUTE, 59); cal.set(Calendar.SECOND, 59) to = cal.timeInMillis } "This Week" -> { cal.set(Calendar.DAY_OF_WEEK, cal.firstDayOfWeek) cal.set(Calendar.HOUR_OF_DAY, 0); cal.set(Calendar.MINUTE, 0); cal.set(Calendar.SECOND, 0) from = cal.timeInMillis; to = System.currentTimeMillis() } "Last Week" -> { cal.add(Calendar.WEEK_OF_YEAR, -1) cal.set(Calendar.DAY_OF_WEEK, cal.firstDayOfWeek) cal.set(Calendar.HOUR_OF_DAY, 0); cal.set(Calendar.MINUTE, 0); cal.set(Calendar.SECOND, 0) from = cal.timeInMillis cal.add(Calendar.DAY_OF_MONTH, 6) cal.set(Calendar.HOUR_OF_DAY, 23); cal.set(Calendar.MINUTE, 59); cal.set(Calendar.SECOND, 59) to = cal.timeInMillis } "Last Month" -> { cal.add(Calendar.MONTH, -1) cal.set(Calendar.DAY_OF_MONTH, 1) cal.set(Calendar.HOUR_OF_DAY, 0); cal.set(Calendar.MINUTE, 0); cal.set(Calendar.SECOND, 0) from = cal.timeInMillis cal.set(Calendar.DAY_OF_MONTH, cal.getActualMaximum(Calendar.DAY_OF_MONTH)) cal.set(Calendar.HOUR_OF_DAY, 23); cal.set(Calendar.MINUTE, 59); cal.set(Calendar.SECOND, 59) to = cal.timeInMillis } else -> setThisMonthRange() // "This Month" } } private fun pickCustomRange(v: View) { val cal = Calendar.getInstance() DatePickerDialog(requireContext(), { _, y1, m1, d1 -> val fromCal = Calendar.getInstance().apply { set(y1, m1, d1, 0, 0, 0) } DatePickerDialog(requireContext(), { _, y2, m2, d2 -> val toCal = Calendar.getInstance().apply { set(y2, m2, d2, 23, 59, 59) } from = fromCal.timeInMillis; to = toCal.timeInMillis rangeLabel = "Custom" render(v) }, cal.get(Calendar.YEAR), cal.get(Calendar.MONTH), cal.get(Calendar.DAY_OF_MONTH)).show() }, cal.get(Calendar.YEAR), cal.get(Calendar.MONTH), cal.get(Calendar.DAY_OF_MONTH)).show() } private fun showExNoMenu(v: View, anchor: View) { val menu = PopupMenu(requireContext(), anchor) menu.menu.add("No — include excluded numbers") menu.menu.add("Yes — leave excluded numbers out") menu.setOnMenuItemClickListener { mi -> val exclude = mi.title.toString().startsWith("Yes") prefs.analyticsExcludeExcluded = exclude v.findViewById(R.id.btnExNo).text = "Ex. No.: ${if (exclude) "Yes" else "No"} ⌄" updateFilterBadge(v) render(v) true } menu.show() } private fun showCallMethodMenu(v: View, anchor: View) { val menu = PopupMenu(requireContext(), anchor) menu.menu.add("Phone & WhatsApp") menu.menu.add("Phone only") menu.menu.add("WhatsApp only") menu.setOnMenuItemClickListener { mi -> callMethodFilter = when (mi.title.toString()) { "Phone only" -> "phone" "WhatsApp only" -> "whatsapp" else -> "" } render(v) true } menu.show() } private fun refreshExcludedNumbersIfStale() { if (!prefs.isRegistered) return val staleAfterMs = 24 * 60 * 60 * 1000L if (System.currentTimeMillis() - prefs.excludedLastFetch < staleAfterMs) return viewLifecycleOwner.lifecycleScope.launch { try { val resp = ApiClient.service.excludedNumbers(prefs.bearer()) val nums = resp.body()?.numbers if (resp.isSuccessful && nums != null) { prefs.excludedNumberDigits = nums.toSet() prefs.excludedLastFetch = System.currentTimeMillis() view?.let { render(it) } } } catch (_: Exception) { /* keep using the last cached list */ } } } private fun render(v: View) { val df = SimpleDateFormat("dd-MMM-yyyy", Locale.getDefault()) v.findViewById(R.id.tvRange).text = "${df.format(from)} – ${df.format(to)} ($rangeLabel)" v.findViewById(R.id.btnRange).text = "$rangeLabel ⌄" updateFilterBadge(v) v.findViewById(R.id.btnCallMethod).text = when (callMethodFilter) { "phone" -> "Phone Only ⌄" "whatsapp" -> "WhatsApp Only ⌄" else -> "Call Method ⌄" } val excludeDigits = if (prefs.analyticsExcludeExcluded) prefs.excludedNumberDigits else emptySet() val stats = CallStats(requireContext()) val grid = v.findViewById(R.id.grid) val table = v.findViewById(R.id.analysisTable) table.visibility = View.GONE // superseded by the Analysis card grid below grid.visibility = View.VISIBLE if (callMethodFilter == "phone") { // Pure phone data — no network round trip needed, render immediately. if (!showAnalysis) { renderSummary(grid, stats.analytics(from, to, excludeDigits, prefs.analyticsDeselectedTypes)) } else { renderAnalysisCards(v, grid, excludeDigits) } return } // "" (both) or "whatsapp" — Summary needs WhatsApp data merged in; the Analysis // tab's record-holder/top-10 cards are phone-only for now (same scope as the // day-wise breakdown they replaced), so they don't need the network round trip. if (showAnalysis) { renderAnalysisCards(v, grid, excludeDigits) return } viewLifecycleOwner.lifecycleScope.launch { val waAnalytics = fetchWhatsappAnalytics(excludeDigits) val merged = if (callMethodFilter == "whatsapp") waAnalytics else mergeAnalytics(stats.analytics(from, to, excludeDigits, prefs.analyticsDeselectedTypes), waAnalytics) renderSummary(grid, merged) } } /** Reads this employee's WhatsApp calls from the server and reduces them into * the same Analytics shape as phone calls, using the same incoming/outgoing + * answered/unanswered mapping used on the admin panel (0-duration incoming = * never attended, 0-duration outgoing = not picked up by client). */ private suspend fun fetchWhatsappAnalytics(excludeDigits: Set): CallStats.Analytics { if (!prefs.isRegistered) return CallStats.Analytics() val calls = try { ApiClient.service.whatsappCallsList(prefs.bearer()).body()?.calls.orEmpty() } catch (_: Exception) { emptyList() } var total = 0; var td = 0L var inc = 0; var incD = 0L; var out = 0; var outD = 0L var never = 0; var notPick = 0 var connected = 0; var connectedD = 0L var minDate = Long.MAX_VALUE; var maxDate = Long.MIN_VALUE val allNumbers = HashSet() val connectedNumbers = HashSet() val df = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()) for (w in calls) { val startedMs = try { df.parse(w.startedAt)?.time } catch (_: Exception) { null } ?: continue if (startedMs < from || startedMs > to) continue val digits = w.contactName.filter { it.isDigit() } if (excludeDigits.isNotEmpty() && digits.isNotEmpty() && excludeDigits.any { digits.endsWith(it) || it.endsWith(digits) }) continue val d = w.durationSec total++; td += d if (startedMs < minDate) minDate = startedMs if (startedMs > maxDate) maxDate = startedMs if (w.contactName.isNotEmpty()) allNumbers.add(w.contactName) when (w.callType) { "incoming" -> { if (d > 0) { inc++; incD += d; connected++; connectedD += d if (w.contactName.isNotEmpty()) connectedNumbers.add(w.contactName) } else never++ } else -> { // "outgoing" or "unknown" — treat unknown as outgoing, same default as the admin panel out++; outD += d if (d > 0) { connected++; connectedD += d if (w.contactName.isNotEmpty()) connectedNumbers.add(w.contactName) } else notPick++ } } } val workingSec = if (total > 0) maxOf(0L, (maxDate - minDate) / 1000) else 0L return CallStats.Analytics( total = total, totalDur = td, incoming = inc, incomingDur = incD, outgoing = out, outgoingDur = outD, missed = 0, rejected = 0, neverAttended = never, notPickup = notPick, uniqueCalls = allNumbers.size, connectedCalls = connected, connectedDur = connectedD, uniqueConnectedCalls = connectedNumbers.size, workingHoursSec = workingSec ) } /** Combines phone + WhatsApp analytics. NOTE: uniqueCalls/uniqueConnectedCalls are * a simple sum, not deduplicated across sources — a client who called both by * phone and WhatsApp would count twice. workingHoursSec takes whichever span is * larger rather than a true combined earliest-to-latest — both are reasonable * approximations, not exact figures. */ private fun mergeAnalytics(a: CallStats.Analytics, b: CallStats.Analytics): CallStats.Analytics = CallStats.Analytics( total = a.total + b.total, totalDur = a.totalDur + b.totalDur, incoming = a.incoming + b.incoming, incomingDur = a.incomingDur + b.incomingDur, outgoing = a.outgoing + b.outgoing, outgoingDur = a.outgoingDur + b.outgoingDur, missed = a.missed + b.missed, rejected = a.rejected + b.rejected, neverAttended = a.neverAttended + b.neverAttended, notPickup = a.notPickup + b.notPickup, uniqueCalls = a.uniqueCalls + b.uniqueCalls, connectedCalls = a.connectedCalls + b.connectedCalls, connectedDur = a.connectedDur + b.connectedDur, uniqueConnectedCalls = a.uniqueConnectedCalls + b.uniqueConnectedCalls, workingHoursSec = maxOf(a.workingHoursSec, b.workingHoursSec) ) private fun renderSummary(grid: GridLayout, a: CallStats.Analytics) { grid.removeAllViews() // count == -1 is a sentinel: "duration-only" card (no count number shown, // just the formatted time) — used for Working Hours, matching the reference. data class Card(val label: String, val count: Int, val dur: Long?) val cards = listOf( Card("Total Phone Calls", a.total, a.totalDur), Card("Incoming Calls", a.incoming, a.incomingDur), Card("Outgoing Calls", a.outgoing, a.outgoingDur), Card("Missed Calls", a.missed, null), Card("Rejected Calls", a.rejected, null), Card("Never Attended", a.neverAttended, null), Card("Not Pickup by Client", a.notPickup, null), Card("Unique Calls", a.uniqueCalls, null), Card("Connected Calls", a.connectedCalls, a.connectedDur), Card("Unique Connected Calls", a.uniqueConnectedCalls, null), Card("Working Hours", -1, a.workingHoursSec) ) val inflater = LayoutInflater.from(context) val colW = resources.displayMetrics.widthPixels / 2 - dp(24) for (card in cards) { val cv = inflater.inflate(R.layout.item_stat_card, grid, false) cv.findViewById(R.id.label).text = card.label val countTv = cv.findViewById(R.id.count) val durTv = cv.findViewById(R.id.dur) if (card.count == -1 && card.dur != null) { countTv.text = fmt(card.dur) durTv.visibility = View.GONE } else { countTv.text = card.count.toString() if (card.dur != null) durTv.text = fmt(card.dur) else durTv.visibility = View.GONE } val lp = GridLayout.LayoutParams().apply { width = colW } grid.addView(cv, lp) } } private fun renderAnalysisCards(v: View, grid: GridLayout, excludeDigits: Set) { grid.removeAllViews() val inflater = LayoutInflater.from(context) val colW = resources.displayMetrics.widthPixels / 2 - dp(24) val deselected = prefs.analyticsDeselectedTypes val filterLabel = v.findViewById(R.id.btnFilter).text.toString() val rangeLabel = v.findViewById(R.id.btnRange).text.toString() val exNoLabel = v.findViewById(R.id.btnExNo).text.toString() val statsForPreview = CallStats(requireContext()) data class ACard(val label: String, val subtitle: String, val launch: () -> Unit) val cards = listOf( ACard("Top Caller", statsForPreview.topCallerDetail(from, to, excludeDigits, deselected)?.let { it.name ?: it.number } ?: "—") { startRecordHolder("top_caller", excludeDigits, deselected, filterLabel, rangeLabel, exNoLabel) }, ACard("Longest Call", statsForPreview.longestCallDetail(from, to, excludeDigits, deselected)?.let { it.name ?: it.number } ?: "—") { startRecordHolder("longest_call", excludeDigits, deselected, filterLabel, rangeLabel, exNoLabel) }, ACard("Highest Total Call Duration", statsForPreview.highestTotalDurationDetail(from, to, excludeDigits, deselected)?.let { it.name ?: it.number } ?: "—") { startRecordHolder("highest_duration", excludeDigits, deselected, filterLabel, rangeLabel, exNoLabel) }, ACard("Average Call Duration of Connected Calls", "Per Call & Per Day") { val i = android.content.Intent(requireContext(), AvgDurationDetailActivity::class.java) fillCommonExtras(i, excludeDigits, deselected, filterLabel, rangeLabel, exNoLabel) startActivity(i) }, ACard("Top 10 Frequently Talks", "Incoming & Outgoing") { startTop10("frequency", excludeDigits, deselected, filterLabel, rangeLabel, exNoLabel) }, ACard("Top 10 Call Duration", "Incoming & Outgoing") { startTop10("duration", excludeDigits, deselected, filterLabel, rangeLabel, exNoLabel) } ) for (card in cards) { val cv = inflater.inflate(R.layout.item_stat_card, grid, false) cv.findViewById(R.id.label).text = card.label val countTv = cv.findViewById(R.id.count) countTv.text = card.subtitle cv.findViewById(R.id.dur).visibility = View.GONE cv.isClickable = true cv.isFocusable = true cv.setOnClickListener { card.launch() } val lp = GridLayout.LayoutParams().apply { width = colW } grid.addView(cv, lp) } } private fun fillCommonExtras( i: android.content.Intent, excludeDigits: Set, deselectedTypes: Set, filterLabel: String, rangeLabel: String, exNoLabel: String ) { i.putExtra("from", from) i.putExtra("to", to) i.putStringArrayListExtra("exclude_digits", ArrayList(excludeDigits)) i.putStringArrayListExtra("deselected_types", ArrayList(deselectedTypes)) i.putExtra("filter_label", filterLabel) i.putExtra("range_label", rangeLabel) i.putExtra("exno_label", exNoLabel) } private fun startRecordHolder( mode: String, excludeDigits: Set, deselectedTypes: Set, filterLabel: String, rangeLabel: String, exNoLabel: String ) { val i = android.content.Intent(requireContext(), RecordHolderDetailActivity::class.java) i.putExtra("mode", mode) fillCommonExtras(i, excludeDigits, deselectedTypes, filterLabel, rangeLabel, exNoLabel) startActivity(i) } private fun startTop10( mode: String, excludeDigits: Set, deselectedTypes: Set, filterLabel: String, rangeLabel: String, exNoLabel: String ) { val i = android.content.Intent(requireContext(), Top10ListActivity::class.java) i.putExtra("mode", mode) fillCommonExtras(i, excludeDigits, deselectedTypes, filterLabel, rangeLabel, exNoLabel) startActivity(i) } private fun fmt(s: Long): String { val h = s / 3600; val m = (s % 3600) / 60; val sec = s % 60 return "%dh %dm %ds".format(h, m, sec) } private fun dp(x: Int) = (x * resources.displayMetrics.density).toInt() }