draw usage graphs

This commit is contained in:
2025-07-24 23:38:54 +05:00
parent 3baf959062
commit 51016adf8b
13 changed files with 1141 additions and 47 deletions

View File

@@ -45,3 +45,76 @@ data class ValidationError(
val param: String,
val location: String
)
// New models for customer subscriptions and usage
data class CustomerSubscription(
val id: Long,
val name: String,
val subscriptionId: Long,
val createdAt: String,
val deletedAt: String?,
val customerId: Long,
val subscription: SubscriptionDetails
)
data class SubscriptionDetails(
val id: Long,
val subscriptionNumber: String,
val status: String,
val serviceId: Int, // 1 = Electrical, 2 = Water
val hasSmartMeter: Boolean,
val category: ServiceCategory,
val customer: Customer,
val lastBill: LastBill?
)
data class ServiceCategory(
val id: Int,
val name: String, // "Domestic", "Business", etc.
val code: String,
val mainCategory: MainCategory
)
data class MainCategory(
val id: Int,
val name: String,
val code: String
)
data class Customer(
val id: Long,
val name: String,
val accountNumber: String,
val phone: String
)
data class LastBill(
val id: Long,
val billNumber: String,
val billAmount: String,
val billedUnits: String,
val dueDate: String,
val billDate: String,
val status: String,
val type: String
)
data class UsageReading(
val meter: MeterReading,
val currentReadingDate: String
)
data class MeterReading(
val currentUnits: String,
val currentReadingDate: String
)
// Outstanding bills data model
data class OutstandingBill(
val id: Long,
val billNumber: String,
val billAmount: String,
val dueDate: String,
val subscriptionId: Long,
val serviceId: Int
)

View File

@@ -8,13 +8,16 @@ import okhttp3.*
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.logging.HttpLoggingInterceptor
import sh.sar.gridflow.data.CustomerSubscription
import sh.sar.gridflow.data.ErrorResponse
import sh.sar.gridflow.data.ForgotPasswordRequest
import sh.sar.gridflow.data.ForgotPasswordResponse
import sh.sar.gridflow.data.LoginRequest
import sh.sar.gridflow.data.LoginResponse
import sh.sar.gridflow.data.OutstandingBill
import sh.sar.gridflow.data.SignupRequest
import sh.sar.gridflow.data.SignupResponse
import sh.sar.gridflow.data.UsageReading
import java.io.IOException
class FenakaApiService {
@@ -221,9 +224,9 @@ class FenakaApiService {
}
}
suspend fun getOutstandingBills(cookie: String): ApiResult<List<Any>> = withContext(Dispatchers.IO) {
suspend fun getCustomerSubscriptions(cookie: String): ApiResult<List<CustomerSubscription>> = withContext(Dispatchers.IO) {
val request = Request.Builder()
.url("$BASE_URL/saiph/subscriptions/summaries/outstanding")
.url("$BASE_URL/api/customer-subscriptions")
.get()
.header("Authorization", "Bearer $BEARER_TOKEN")
.header("Content-Type", "application/json")
@@ -237,12 +240,11 @@ class FenakaApiService {
when (response.code) {
200 -> {
val responseBody = response.body?.string() ?: "[]"
// For now, we just need to know if it's empty or not
val bills = gson.fromJson(responseBody, Array<Any>::class.java).toList()
ApiResult.Success(bills, null)
val subscriptions = gson.fromJson(responseBody, Array<CustomerSubscription>::class.java).toList()
ApiResult.Success(subscriptions, null)
}
else -> {
ApiResult.Error("Failed to fetch outstanding bills", response.code)
ApiResult.Error("Failed to fetch customer subscriptions", response.code)
}
}
} catch (e: IOException) {
@@ -251,6 +253,84 @@ class FenakaApiService {
ApiResult.Error("Unexpected error: ${e.message}", -1)
}
}
suspend fun getSubscriptionUsage(subscriptionId: Long, cookie: String): ApiResult<List<UsageReading>> = withContext(Dispatchers.IO) {
Log.d(TAG, "Fetching usage for subscription: $subscriptionId")
val request = Request.Builder()
.url("$BASE_URL/api/customer-subscriptions/$subscriptionId/billed-readings")
.get()
.header("Authorization", "Bearer $BEARER_TOKEN")
.header("Content-Type", "application/json")
.header("Cookie", cookie)
.header("Host", "api.fenaka.mv")
.header("User-Agent", "Dart/3.3 (dart:io)")
.build()
try {
val response = client.newCall(request).execute()
Log.d(TAG, "Usage response code: ${response.code}")
when (response.code) {
200 -> {
val responseBody = response.body?.string() ?: "[]"
Log.d(TAG, "Usage response body: $responseBody")
val usageReadings = gson.fromJson(responseBody, Array<UsageReading>::class.java).toList()
Log.d(TAG, "Parsed ${usageReadings.size} usage readings")
ApiResult.Success(usageReadings, null)
}
else -> {
Log.d(TAG, "Failed to fetch usage: ${response.code}")
ApiResult.Error("Failed to fetch subscription usage", response.code)
}
}
} catch (e: IOException) {
Log.e(TAG, "Network error during usage fetch", e)
ApiResult.Error("Network error: ${e.message}", -1)
} catch (e: Exception) {
Log.e(TAG, "Unexpected error during usage fetch", e)
ApiResult.Error("Unexpected error: ${e.message}", -1)
}
}
suspend fun getOutstandingBills(cookie: String): ApiResult<List<OutstandingBill>> = withContext(Dispatchers.IO) {
Log.d(TAG, "Fetching outstanding bills")
val request = Request.Builder()
.url("$BASE_URL/saiph/subscriptions/summaries/outstanding")
.get()
.header("Authorization", "Bearer $BEARER_TOKEN")
.header("Content-Type", "application/json")
.header("Cookie", cookie)
.header("Host", "api.fenaka.mv")
.header("User-Agent", "Dart/3.3 (dart:io)")
.build()
try {
val response = client.newCall(request).execute()
Log.d(TAG, "Outstanding bills response code: ${response.code}")
when (response.code) {
200 -> {
val responseBody = response.body?.string() ?: "[]"
Log.d(TAG, "Outstanding bills response: $responseBody")
val bills = gson.fromJson(responseBody, Array<OutstandingBill>::class.java).toList()
Log.d(TAG, "Found ${bills.size} outstanding bills")
ApiResult.Success(bills, null)
}
else -> {
Log.d(TAG, "Failed to fetch outstanding bills: ${response.code}")
ApiResult.Error("Failed to fetch outstanding bills", response.code)
}
}
} catch (e: IOException) {
Log.e(TAG, "Network error during bills fetch", e)
ApiResult.Error("Network error: ${e.message}", -1)
} catch (e: Exception) {
Log.e(TAG, "Unexpected error during bills fetch", e)
ApiResult.Error("Unexpected error: ${e.message}", -1)
}
}
}
sealed class ApiResult<T> {

View File

@@ -4,57 +4,159 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.fragment.app.Fragment
import androidx.lifecycle.ViewModelProvider
import androidx.recyclerview.widget.LinearLayoutManager
import sh.sar.gridflow.data.CustomerSubscription
import sh.sar.gridflow.databinding.FragmentHomeBinding
class HomeFragment : Fragment() {
private var _binding: FragmentHomeBinding? = null
// This property is only valid between onCreateView and
// onDestroyView.
private val binding get() = _binding!!
private lateinit var homeViewModel: HomeViewModel
private lateinit var subscriptionsAdapter: SubscriptionsAdapter
private var selectedSubscriptionId: Long? = null
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
val homeViewModel =
ViewModelProvider(this, ViewModelProvider.AndroidViewModelFactory.getInstance(requireActivity().application))
.get(HomeViewModel::class.java)
_binding = FragmentHomeBinding.inflate(inflater, container, false)
val root: View = binding.root
val welcomeTextView: TextView = binding.textHome
val billsStatusTextView: TextView = binding.textBillsStatus
val progressBar = binding.progressBills
homeViewModel.welcomeText.observe(viewLifecycleOwner) {
welcomeTextView.text = it
homeViewModel = ViewModelProvider(
this,
ViewModelProvider.AndroidViewModelFactory.getInstance(requireActivity().application)
).get(HomeViewModel::class.java)
setupViews()
setupObservers()
return binding.root
}
private fun setupViews() {
// Setup subscriptions RecyclerView
subscriptionsAdapter = SubscriptionsAdapter { subscription ->
// When subscription is clicked, load its usage chart
selectedSubscriptionId = subscription.id
subscriptionsAdapter.setSelectedSubscription(subscription.id)
homeViewModel.loadUsageChart(subscription.id)
updateSelectedSubscriptionInfo(subscription)
}
homeViewModel.billsStatus.observe(viewLifecycleOwner) {
billsStatusTextView.text = it
binding.recyclerSubscriptions.apply {
layoutManager = LinearLayoutManager(requireContext())
adapter = subscriptionsAdapter
}
}
private fun setupObservers() {
// Welcome text
homeViewModel.welcomeText.observe(viewLifecycleOwner) { text ->
binding.textHome.text = text
}
homeViewModel.isLoading.observe(viewLifecycleOwner) { isLoading ->
// Bills status
homeViewModel.billsStatus.observe(viewLifecycleOwner) { status ->
binding.textBillsStatus.text = status
}
homeViewModel.isLoadingBills.observe(viewLifecycleOwner) { isLoading ->
binding.progressBills.visibility = if (isLoading) View.VISIBLE else View.GONE
if (isLoading) {
progressBar.visibility = View.VISIBLE
billsStatusTextView.text = "Checking bills status..."
} else {
progressBar.visibility = View.GONE
binding.textBillsStatus.text = "Checking bills status..."
}
}
return root
// Subscriptions
homeViewModel.subscriptions.observe(viewLifecycleOwner) { subscriptions ->
subscriptionsAdapter.updateSubscriptions(subscriptions)
}
homeViewModel.isLoadingSubscriptions.observe(viewLifecycleOwner) { isLoading ->
binding.progressSubscriptions.visibility = if (isLoading) View.VISIBLE else View.GONE
binding.recyclerSubscriptions.visibility = if (isLoading) View.GONE else View.VISIBLE
}
homeViewModel.subscriptionsError.observe(viewLifecycleOwner) { error ->
if (error != null) {
binding.textSubscriptionsError.visibility = View.VISIBLE
binding.textSubscriptionsError.text = error
binding.recyclerSubscriptions.visibility = View.GONE
} else {
binding.textSubscriptionsError.visibility = View.GONE
binding.recyclerSubscriptions.visibility = View.VISIBLE
}
}
// Chart data
homeViewModel.chartData.observe(viewLifecycleOwner) { chartData ->
val unit = homeViewModel.chartUnit.value ?: "Units"
val serviceType = homeViewModel.chartServiceType.value ?: "Unknown"
binding.chartUsage.setData(chartData, unit, serviceType)
binding.chartUsage.visibility = View.VISIBLE
// Update chart title
binding.textChartTitle.text = "$serviceType Usage"
// Show chart legend
binding.layoutChartLegend.visibility = View.VISIBLE
binding.textChartUnit.text = unit
// Hide other states
binding.layoutChartEmpty.visibility = View.GONE
binding.layoutChartError.visibility = View.GONE
binding.layoutChartLoading.visibility = View.GONE
}
homeViewModel.isLoadingChart.observe(viewLifecycleOwner) { isLoading ->
if (isLoading) {
binding.layoutChartLoading.visibility = View.VISIBLE
binding.chartUsage.visibility = View.GONE
binding.layoutChartEmpty.visibility = View.GONE
binding.layoutChartError.visibility = View.GONE
binding.layoutChartLegend.visibility = View.GONE
}
}
homeViewModel.chartError.observe(viewLifecycleOwner) { error ->
if (error != null) {
binding.layoutChartError.visibility = View.VISIBLE
binding.textChartError.text = error
binding.chartUsage.visibility = View.GONE
binding.layoutChartEmpty.visibility = View.GONE
binding.layoutChartLoading.visibility = View.GONE
binding.layoutChartLegend.visibility = View.GONE
}
}
homeViewModel.chartUnit.observe(viewLifecycleOwner) { unit ->
binding.textChartUnit.text = unit
}
}
private fun updateSelectedSubscriptionInfo(subscription: CustomerSubscription) {
// Show the selected subscription info
binding.layoutSelectedSubscription.visibility = View.VISIBLE
// Set service icon
val icon = when (subscription.subscription.serviceId) {
1 -> "" // Electrical
2 -> "💧" // Water
else -> "🔌" // Unknown
}
binding.textSelectedIcon.text = icon
// Set subscription name (shortened)
binding.textSelectedName.text = subscription.name
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}
}

View File

@@ -7,9 +7,14 @@ import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.launch
import sh.sar.gridflow.data.CustomerSubscription
import sh.sar.gridflow.data.UsageReading
import sh.sar.gridflow.network.ApiResult
import sh.sar.gridflow.network.FenakaApiService
import sh.sar.gridflow.ui.widgets.ChartDataPoint
import sh.sar.gridflow.utils.SecureStorage
import java.text.SimpleDateFormat
import java.util.*
class HomeViewModel(application: Application) : AndroidViewModel(application) {
@@ -26,17 +31,47 @@ class HomeViewModel(application: Application) : AndroidViewModel(application) {
private val _billsStatus = MutableLiveData<String>()
val billsStatus: LiveData<String> = _billsStatus
private val _isLoading = MutableLiveData<Boolean>()
val isLoading: LiveData<Boolean> = _isLoading
private val _isLoadingBills = MutableLiveData<Boolean>()
val isLoadingBills: LiveData<Boolean> = _isLoadingBills
// Subscriptions
private val _subscriptions = MutableLiveData<List<CustomerSubscription>>()
val subscriptions: LiveData<List<CustomerSubscription>> = _subscriptions
private val _isLoadingSubscriptions = MutableLiveData<Boolean>()
val isLoadingSubscriptions: LiveData<Boolean> = _isLoadingSubscriptions
private val _subscriptionsError = MutableLiveData<String?>()
val subscriptionsError: LiveData<String?> = _subscriptionsError
// Usage Chart
private val _chartData = MutableLiveData<List<ChartDataPoint>>()
val chartData: LiveData<List<ChartDataPoint>> = _chartData
private val _chartUnit = MutableLiveData<String>()
val chartUnit: LiveData<String> = _chartUnit
private val _chartServiceType = MutableLiveData<String>()
val chartServiceType: LiveData<String> = _chartServiceType
private val _isLoadingChart = MutableLiveData<Boolean>()
val isLoadingChart: LiveData<Boolean> = _isLoadingChart
private val _chartError = MutableLiveData<String?>()
val chartError: LiveData<String?> = _chartError
private val dateFormatter = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault())
init {
Log.d(TAG, "HomeViewModel initialized")
loadUserData()
checkOutstandingBills()
loadCustomerSubscriptions()
}
private fun loadUserData() {
val userName = secureStorage.getUserName() ?: "User"
val activeAccount = secureStorage.getActiveAccount()
val userName = activeAccount?.name ?: "User"
Log.d(TAG, "Loading user data: $userName")
_welcomeText.value = "Welcome\n$userName"
}
@@ -52,14 +87,14 @@ class HomeViewModel(application: Application) : AndroidViewModel(application) {
return
}
_isLoading.value = true
_isLoadingBills.value = true
viewModelScope.launch {
Log.d(TAG, "Making API call to check outstanding bills")
when (val result = apiService.getOutstandingBills("connect.sid=$cookie")) {
is ApiResult.Success -> {
Log.d(TAG, "Bills check successful: ${result.data.size} bills found")
_isLoading.value = false
_isLoadingBills.value = false
if (result.data.isEmpty()) {
_billsStatus.value = "You don't have any pending bills left"
} else {
@@ -68,10 +103,117 @@ class HomeViewModel(application: Application) : AndroidViewModel(application) {
}
is ApiResult.Error -> {
Log.d(TAG, "Bills check failed: ${result.message}")
_isLoading.value = false
_isLoadingBills.value = false
_billsStatus.value = "Unable to check bills status"
}
}
}
}
}
private fun loadCustomerSubscriptions() {
val cookie = secureStorage.getCookie()
if (cookie == null) {
Log.d(TAG, "No cookie found, cannot load subscriptions")
_subscriptionsError.value = "Authentication required"
return
}
_isLoadingSubscriptions.value = true
_subscriptionsError.value = null
viewModelScope.launch {
Log.d(TAG, "Loading customer subscriptions")
when (val result = apiService.getCustomerSubscriptions("connect.sid=$cookie")) {
is ApiResult.Success -> {
Log.d(TAG, "Subscriptions loaded successfully: ${result.data.size} found")
_isLoadingSubscriptions.value = false
_subscriptions.value = result.data
} is ApiResult.Error -> {
Log.d(TAG, "Failed to load subscriptions: ${result.message}")
_isLoadingSubscriptions.value = false
_subscriptionsError.value = result.message
}
}
}
}
fun loadUsageChart(subscriptionId: Long) {
val cookie = secureStorage.getCookie()
if (cookie == null) {
Log.d(TAG, "No cookie found, cannot load usage data")
_chartError.value = "Authentication required"
return
}
// Find the subscription to get service type
val subscription = _subscriptions.value?.find { it.id == subscriptionId }
if (subscription == null) {
Log.d(TAG, "Subscription not found: $subscriptionId")
_chartError.value = "Subscription not found"
return
}
val serviceType = when (subscription.subscription.serviceId) {
1 -> "Electrical"
2 -> "Water"
else -> "Unknown"
}
val unit = when (subscription.subscription.serviceId) {
1 -> "kWh"
2 -> "Ltr"
else -> "Units"
}
_isLoadingChart.value = true
_chartError.value = null
_chartServiceType.value = serviceType
_chartUnit.value = unit
viewModelScope.launch {
Log.d(TAG, "Loading usage data for subscription: $subscriptionId")
when (val result = apiService.getSubscriptionUsage(subscriptionId, "connect.sid=$cookie")) {
is ApiResult.Success -> {
Log.d(TAG, "Usage data loaded successfully: ${result.data.size} readings")
_isLoadingChart.value = false
// Convert usage readings to chart data points
val chartPoints = result.data.mapNotNull { reading ->
try {
val date = dateFormatter.parse(reading.meter.currentReadingDate.substring(0, 10))
val value = reading.meter.currentUnits.toFloat()
val calendar = Calendar.getInstance().apply { time = date }
val monthYear = "${calendar.getDisplayName(Calendar.MONTH, Calendar.SHORT, Locale.getDefault())} ${calendar.get(Calendar.YEAR)}"
date?.let {
ChartDataPoint(
date = it,
value = value,
label = monthYear
)
}
} catch (e: Exception) {
Log.e(TAG, "Error parsing usage reading", e)
null
}
}.sortedBy { it.date } // Sort by date ascending
_chartData.value = chartPoints
} is ApiResult.Error -> {
Log.d(TAG, "Failed to load usage data: ${result.message}")
_isLoadingChart.value = false
_chartError.value = result.message
}
}
}
}
fun refreshData() {
Log.d(TAG, "Refreshing all data")
loadUserData()
checkOutstandingBills()
loadCustomerSubscriptions()
}
}

View File

@@ -0,0 +1,10 @@
package sh.sar.gridflow.ui.home
data class SubscriptionDropdownItem(
val id: Long,
val displayName: String,
val serviceType: String,
val serviceId: Int
) {
override fun toString(): String = displayName
}

View File

@@ -0,0 +1,124 @@
package sh.sar.gridflow.ui.home
import android.graphics.Color
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import sh.sar.gridflow.data.CustomerSubscription
import sh.sar.gridflow.databinding.ItemSubscriptionBinding
import java.text.NumberFormat
import java.util.*
class SubscriptionsAdapter(
private var subscriptions: List<CustomerSubscription> = emptyList(),
private var selectedSubscriptionId: Long? = null,
private val onSubscriptionClick: (CustomerSubscription) -> Unit = {}
) : RecyclerView.Adapter<SubscriptionsAdapter.SubscriptionViewHolder>() {
fun updateSubscriptions(newSubscriptions: List<CustomerSubscription>) {
subscriptions = newSubscriptions
notifyDataSetChanged()
}
fun setSelectedSubscription(subscriptionId: Long?) {
val oldSelectedIndex = subscriptions.indexOfFirst { it.id == selectedSubscriptionId }
val newSelectedIndex = subscriptions.indexOfFirst { it.id == subscriptionId }
selectedSubscriptionId = subscriptionId
// Update old selection
if (oldSelectedIndex != -1) {
notifyItemChanged(oldSelectedIndex)
}
// Update new selection
if (newSelectedIndex != -1) {
notifyItemChanged(newSelectedIndex)
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SubscriptionViewHolder {
val binding = ItemSubscriptionBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
return SubscriptionViewHolder(binding)
}
override fun onBindViewHolder(holder: SubscriptionViewHolder, position: Int) {
holder.bind(subscriptions[position])
}
override fun getItemCount(): Int = subscriptions.size
inner class SubscriptionViewHolder(
private val binding: ItemSubscriptionBinding
) : RecyclerView.ViewHolder(binding.root) {
fun bind(subscription: CustomerSubscription) {
with(binding) {
// Check if this item is selected
val isSelected = subscription.id == selectedSubscriptionId
// Show/hide selection indicator
viewSelectionIndicator.visibility = if (isSelected) View.VISIBLE else View.GONE
// Set subscription name
textSubscriptionName.text = subscription.name
// Set subscription number
textSubscriptionNumber.text = "Sub: ${subscription.subscription.subscriptionNumber}"
// Set service type and icon
val serviceType = when (subscription.subscription.serviceId) {
1 -> "Electrical"
2 -> "Water"
else -> "Unknown"
}
textServiceType.text = serviceType
// Set service icon
val (icon, iconColor) = when (subscription.subscription.serviceId) {
1 -> "" to Color.parseColor("#FF9800") // Orange for electrical
2 -> "💧" to Color.parseColor("#2196F3") // Blue for water
else -> "🔌" to Color.parseColor("#4CAF50") // Green for unknown
}
textServiceIcon.text = icon
// Set last bill info
val lastBill = subscription.subscription.lastBill
if (lastBill != null) {
val amount = try {
lastBill.billAmount.toDouble()
} catch (e: NumberFormatException) {
0.0
}
val formatter = NumberFormat.getCurrencyInstance(Locale("en", "MV"))
formatter.currency = Currency.getInstance("MVR")
textBillAmount.text = formatter.format(amount)
// Set bill status with appropriate color
val (statusText, statusColor) = when (lastBill.status.lowercase()) {
"paid" -> "Paid" to Color.parseColor("#4CAF50")
"pending" -> "Pending" to Color.parseColor("#FF9800")
"overdue" -> "Overdue" to Color.parseColor("#F44336")
else -> lastBill.status to Color.parseColor("#666666")
}
textBillStatus.text = statusText
textBillStatus.setTextColor(statusColor)
} else {
textBillAmount.text = "No bill"
textBillStatus.text = ""
}
// Set click listener
root.setOnClickListener {
onSubscriptionClick(subscription)
}
}
}
}
}

View File

@@ -0,0 +1,195 @@
package sh.sar.gridflow.ui.widgets
import android.content.Context
import android.graphics.*
import android.util.AttributeSet
import android.view.View
import java.text.SimpleDateFormat
import java.util.*
import kotlin.math.max
import kotlin.math.roundToInt
data class ChartDataPoint(
val date: Date,
val value: Float,
val label: String
)
class BarChartView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {
private val barPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.parseColor("#2196F3")
style = Paint.Style.FILL
}
private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.parseColor("#666666")
textSize = 28f
textAlign = Paint.Align.CENTER
}
private val axisTextPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.parseColor("#999999")
textSize = 24f
textAlign = Paint.Align.CENTER
}
private val axisPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.parseColor("#EEEEEE")
strokeWidth = 2f
}
private var dataPoints: List<ChartDataPoint> = emptyList()
private var unit: String = ""
private var serviceType: String = ""
private val padding = 60f // Increased left padding for y-axis labels
private val bottomPadding = 120f
private val topPadding = 80f
private val monthFormatter = SimpleDateFormat("MMM", Locale.getDefault())
fun setData(data: List<ChartDataPoint>, unit: String, serviceType: String) {
this.dataPoints = data.takeLast(10).reversed() // Show last 10 months, most recent first
this.unit = unit
this.serviceType = serviceType
// Update colors based on service type
when (serviceType.lowercase()) {
"electrical" -> {
barPaint.color = Color.parseColor("#FF9800") // Orange for electrical
}
"water" -> {
barPaint.color = Color.parseColor("#2196F3") // Blue for water
}
else -> {
barPaint.color = Color.parseColor("#4CAF50") // Green default
}
}
invalidate()
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
if (dataPoints.isEmpty()) {
drawEmptyState(canvas)
return
}
val chartWidth = width - (padding * 2)
val chartHeight = height - bottomPadding - topPadding
// Find max value for scaling
val maxValue = dataPoints.maxOfOrNull { it.value } ?: 1f
val niceMaxValue = getNiceMaxValue(maxValue)
// Draw y-axis labels and grid lines
drawYAxisAndGrid(canvas, niceMaxValue, chartHeight)
// Calculate bar dimensions
val barWidth = chartWidth / dataPoints.size * 0.8f
val barSpacing = chartWidth / dataPoints.size * 0.2f
// Draw bars and x-axis labels
dataPoints.forEachIndexed { index, dataPoint ->
val barHeight = (dataPoint.value / niceMaxValue) * chartHeight
val x = padding + (index * (barWidth + barSpacing)) + barSpacing / 2
val y = topPadding + chartHeight - barHeight
// Draw bar
val rect = RectF(x, y, x + barWidth, topPadding + chartHeight)
canvas.drawRoundRect(rect, 8f, 8f, barPaint)
// Draw value on top of bar
if (barHeight > 40f) { // Only show if bar is tall enough
val valueText = formatValue(dataPoint.value)
canvas.drawText(
valueText,
x + barWidth / 2,
y - 8f,
textPaint
)
}
// Draw month label
val monthText = monthFormatter.format(dataPoint.date)
canvas.drawText(
monthText,
x + barWidth / 2,
height - 60f,
axisTextPaint
)
// Draw year if it's January or first/last item
val calendar = Calendar.getInstance().apply { time = dataPoint.date }
if (calendar.get(Calendar.MONTH) == Calendar.JANUARY || index == 0 || index == dataPoints.size - 1) {
val yearText = calendar.get(Calendar.YEAR).toString()
canvas.drawText(
yearText,
x + barWidth / 2,
height - 32f,
axisTextPaint
)
}
}
}
private fun drawEmptyState(canvas: Canvas) {
val text = "No usage data available"
val x = width / 2f
val y = height / 2f
textPaint.textAlign = Paint.Align.CENTER
textPaint.color = Color.parseColor("#CCCCCC")
canvas.drawText(text, x, y, textPaint)
}
private fun drawYAxisAndGrid(canvas: Canvas, maxValue: Float, chartHeight: Float) {
val steps = 5
val stepValue = maxValue / steps
for (i in 0..steps) {
val value = i * stepValue
val y = topPadding + chartHeight - (value / maxValue * chartHeight)
// Draw grid line
canvas.drawLine(padding, y, width - padding, y, axisPaint)
// Draw y-axis label
val labelText = formatValue(value)
axisTextPaint.textAlign = Paint.Align.RIGHT
canvas.drawText(labelText, padding - 8f, y + 8f, axisTextPaint)
}
}
private fun getNiceMaxValue(maxValue: Float): Float {
if (maxValue <= 0) return 100f
val magnitude = Math.pow(10.0, Math.floor(Math.log10(maxValue.toDouble()))).toFloat()
val normalized = maxValue / magnitude
val niceNormalized = when {
normalized <= 1f -> 1f
normalized <= 2f -> 2f
normalized <= 5f -> 5f
else -> 10f
}
return niceNormalized * magnitude
}
private fun formatValue(value: Float): String {
return when {
value >= 1000f -> "${(value / 1000f).roundToInt()}k"
value >= 100f -> value.roundToInt().toString()
value >= 10f -> "%.1f".format(value)
else -> "%.2f".format(value)
}
}
}

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="@color/icon_circle_background" />
<stroke android:width="1dp" android:color="@color/icon_circle_border" />
</shape>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/selected_subscription_background" />
<stroke android:width="1dp" android:color="@color/selected_subscription_border" />
<corners android:radius="16dp" />
</shape>

View File

@@ -12,7 +12,7 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="20dp">
android:padding="16dp">
<!-- Welcome Section -->
<TextView
@@ -64,16 +64,237 @@
</com.google.android.material.card.MaterialCardView>
<!-- Placeholder for future features -->
<TextView
<!-- Subscriptions Overview Card -->
<com.google.android.material.card.MaterialCardView
android:id="@+id/card_subscriptions"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="More features coming soon..."
android:textSize="14sp"
android:textColor="?android:attr/textColorSecondary"
android:gravity="center"
android:layout_marginTop="32dp" />
android:layout_marginBottom="24dp"
app:cardCornerRadius="12dp"
app:cardElevation="2dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Your Subscriptions"
android:textSize="18sp"
android:textStyle="bold"
android:textColor="?android:attr/textColorPrimary"
android:layout_marginBottom="12dp" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler_subscriptions"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
tools:listitem="@layout/item_subscription" />
<ProgressBar
android:id="@+id/progress_subscriptions"
android:layout_width="32dp"
android:layout_height="32dp"
android:layout_gravity="center"
android:visibility="gone" />
<TextView
android:id="@+id/text_subscriptions_error"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Unable to load subscriptions"
android:textColor="?android:attr/textColorSecondary"
android:gravity="center"
android:visibility="gone" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<!-- Usage Chart Card -->
<com.google.android.material.card.MaterialCardView
android:id="@+id/card_usage_chart"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="24dp"
app:cardCornerRadius="12dp"
app:cardElevation="2dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<!-- Chart Header -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginBottom="16dp">
<TextView
android:id="@+id/text_chart_title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Usage Chart"
android:textSize="18sp"
android:textStyle="bold"
android:textColor="?android:attr/textColorPrimary" />
<!-- Selected Subscription Info -->
<LinearLayout
android:id="@+id/layout_selected_subscription"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:visibility="gone"
android:padding="8dp"
android:background="@drawable/selected_subscription_background">
<TextView
android:id="@+id/text_selected_icon"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginEnd="8dp"
android:gravity="center"
android:textSize="14sp"
android:text="⚡" />
<TextView
android:id="@+id/text_selected_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="14sp"
android:textColor="?android:attr/textColorPrimary"
android:textStyle="bold"
android:maxLines="1"
android:ellipsize="end"
android:maxWidth="120dp"
android:text="Home" />
</LinearLayout>
</LinearLayout>
<!-- Chart Container -->
<FrameLayout
android:layout_width="match_parent"
android:layout_height="300dp">
<!-- Chart View -->
<sh.sar.gridflow.ui.widgets.BarChartView
android:id="@+id/chart_usage"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="gone" />
<!-- Loading State -->
<LinearLayout
android:id="@+id/layout_chart_loading"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:gravity="center"
android:visibility="gone">
<ProgressBar
android:layout_width="32dp"
android:layout_height="32dp"
android:layout_marginBottom="8dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Loading usage data..."
android:textColor="?android:attr/textColorSecondary" />
</LinearLayout>
<!-- Empty State -->
<LinearLayout
android:id="@+id/layout_chart_empty"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:gravity="center">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="📊"
android:textSize="48sp"
android:layout_marginBottom="8dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Select a subscription to view usage chart"
android:textColor="?android:attr/textColorSecondary"
android:textAlignment="center" />
</LinearLayout>
<!-- Error State -->
<LinearLayout
android:id="@+id/layout_chart_error"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:gravity="center"
android:visibility="gone">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="⚠️"
android:textSize="48sp"
android:layout_marginBottom="8dp" />
<TextView
android:id="@+id/text_chart_error"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Unable to load usage data"
android:textColor="?android:attr/textColorSecondary"
android:textAlignment="center" />
</LinearLayout>
</FrameLayout>
<!-- Chart Legend -->
<LinearLayout
android:id="@+id/layout_chart_legend"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center"
android:layout_marginTop="12dp"
android:visibility="gone">
<TextView
android:id="@+id/text_chart_unit"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="kWh"
android:textSize="12sp"
android:textColor="?android:attr/textColorSecondary"
android:textStyle="bold" />
</LinearLayout>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
</LinearLayout>
</ScrollView>
</ScrollView>

View File

@@ -0,0 +1,110 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="12dp"
android:gravity="center_vertical"
android:background="?android:attr/selectableItemBackground"
android:id="@+id/layout_subscription_item">
<!-- Service Icon -->
<TextView
android:id="@+id/text_service_icon"
android:layout_width="32dp"
android:layout_height="32dp"
android:layout_marginEnd="12dp"
android:background="@drawable/circle_background"
android:gravity="center"
android:textSize="16sp"
tools:text="⚡" />
<!-- Subscription Details -->
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/text_subscription_name"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="16sp"
android:textStyle="bold"
android:textColor="?android:attr/textColorPrimary"
android:maxLines="1"
android:ellipsize="end"
tools:text="Thithilee, Hirundhu Magu, Kulhudhuffushi Electricity" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginTop="2dp">
<TextView
android:id="@+id/text_subscription_number"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="12sp"
android:textColor="?android:attr/textColorSecondary"
tools:text="Sub: 8584" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text=" • "
android:textSize="12sp"
android:textColor="?android:attr/textColorSecondary" />
<TextView
android:id="@+id/text_service_type"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="12sp"
android:textColor="?android:attr/textColorSecondary"
tools:text="Electrical" />
</LinearLayout>
</LinearLayout>
<!-- Last Bill Amount -->
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
android:gravity="end">
<TextView
android:id="@+id/text_bill_amount"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="16sp"
android:textStyle="bold"
android:textColor="?android:attr/textColorPrimary"
tools:text="MVR 320.83" />
<TextView
android:id="@+id/text_bill_status"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="12sp"
android:layout_marginTop="2dp"
tools:text="Paid"
tools:textColor="#4CAF50" />
</LinearLayout>
<!-- Selection Indicator -->
<View
android:id="@+id/view_selection_indicator"
android:layout_width="4dp"
android:layout_height="40dp"
android:layout_marginStart="8dp"
android:background="#2196F3"
android:visibility="gone" />
</LinearLayout>

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Material Design default colors for dark theme -->
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
<!-- Dark theme colors -->
<color name="icon_circle_background">#2C2C2C</color>
<color name="icon_circle_border">#555555</color>
<color name="selected_subscription_background">#1E3A8A</color>
<color name="selected_subscription_border">#3B82F6</color>
</resources>

View File

@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Material Design default colors -->
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
@@ -7,4 +8,10 @@
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>
<!-- Light theme colors -->
<color name="icon_circle_background">#E3F2FD</color>
<color name="icon_circle_border">#BBDEFB</color>
<color name="selected_subscription_background">#E3F2FD</color>
<color name="selected_subscription_border">#90CAF9</color>
</resources>