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 param: String,
val location: 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.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.logging.HttpLoggingInterceptor import okhttp3.logging.HttpLoggingInterceptor
import sh.sar.gridflow.data.CustomerSubscription
import sh.sar.gridflow.data.ErrorResponse import sh.sar.gridflow.data.ErrorResponse
import sh.sar.gridflow.data.ForgotPasswordRequest import sh.sar.gridflow.data.ForgotPasswordRequest
import sh.sar.gridflow.data.ForgotPasswordResponse import sh.sar.gridflow.data.ForgotPasswordResponse
import sh.sar.gridflow.data.LoginRequest import sh.sar.gridflow.data.LoginRequest
import sh.sar.gridflow.data.LoginResponse import sh.sar.gridflow.data.LoginResponse
import sh.sar.gridflow.data.OutstandingBill
import sh.sar.gridflow.data.SignupRequest import sh.sar.gridflow.data.SignupRequest
import sh.sar.gridflow.data.SignupResponse import sh.sar.gridflow.data.SignupResponse
import sh.sar.gridflow.data.UsageReading
import java.io.IOException import java.io.IOException
class FenakaApiService { 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() val request = Request.Builder()
.url("$BASE_URL/saiph/subscriptions/summaries/outstanding") .url("$BASE_URL/api/customer-subscriptions")
.get() .get()
.header("Authorization", "Bearer $BEARER_TOKEN") .header("Authorization", "Bearer $BEARER_TOKEN")
.header("Content-Type", "application/json") .header("Content-Type", "application/json")
@@ -237,12 +240,11 @@ class FenakaApiService {
when (response.code) { when (response.code) {
200 -> { 200 -> {
val responseBody = response.body?.string() ?: "[]" val responseBody = response.body?.string() ?: "[]"
// For now, we just need to know if it's empty or not val subscriptions = gson.fromJson(responseBody, Array<CustomerSubscription>::class.java).toList()
val bills = gson.fromJson(responseBody, Array<Any>::class.java).toList() ApiResult.Success(subscriptions, null)
ApiResult.Success(bills, null)
} }
else -> { else -> {
ApiResult.Error("Failed to fetch outstanding bills", response.code) ApiResult.Error("Failed to fetch customer subscriptions", response.code)
} }
} }
} catch (e: IOException) { } catch (e: IOException) {
@@ -251,6 +253,84 @@ class FenakaApiService {
ApiResult.Error("Unexpected error: ${e.message}", -1) 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> { sealed class ApiResult<T> {

View File

@@ -4,57 +4,159 @@ import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.TextView
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.recyclerview.widget.LinearLayoutManager
import sh.sar.gridflow.data.CustomerSubscription
import sh.sar.gridflow.databinding.FragmentHomeBinding import sh.sar.gridflow.databinding.FragmentHomeBinding
class HomeFragment : Fragment() { class HomeFragment : Fragment() {
private var _binding: FragmentHomeBinding? = null private var _binding: FragmentHomeBinding? = null
// This property is only valid between onCreateView and
// onDestroyView.
private val binding get() = _binding!! private val binding get() = _binding!!
private lateinit var homeViewModel: HomeViewModel
private lateinit var subscriptionsAdapter: SubscriptionsAdapter
private var selectedSubscriptionId: Long? = null
override fun onCreateView( override fun onCreateView(
inflater: LayoutInflater, inflater: LayoutInflater,
container: ViewGroup?, container: ViewGroup?,
savedInstanceState: Bundle? savedInstanceState: Bundle?
): View { ): View {
val homeViewModel =
ViewModelProvider(this, ViewModelProvider.AndroidViewModelFactory.getInstance(requireActivity().application))
.get(HomeViewModel::class.java)
_binding = FragmentHomeBinding.inflate(inflater, container, false) _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) { homeViewModel = ViewModelProvider(
welcomeTextView.text = it 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) { binding.recyclerSubscriptions.apply {
billsStatusTextView.text = it 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) { if (isLoading) {
progressBar.visibility = View.VISIBLE binding.textBillsStatus.text = "Checking bills status..."
billsStatusTextView.text = "Checking bills status..."
} else {
progressBar.visibility = View.GONE
} }
} }
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() { override fun onDestroyView() {
super.onDestroyView() super.onDestroyView()
_binding = null _binding = null
} }
} }

View File

@@ -7,9 +7,14 @@ import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.launch 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.ApiResult
import sh.sar.gridflow.network.FenakaApiService import sh.sar.gridflow.network.FenakaApiService
import sh.sar.gridflow.ui.widgets.ChartDataPoint
import sh.sar.gridflow.utils.SecureStorage import sh.sar.gridflow.utils.SecureStorage
import java.text.SimpleDateFormat
import java.util.*
class HomeViewModel(application: Application) : AndroidViewModel(application) { class HomeViewModel(application: Application) : AndroidViewModel(application) {
@@ -26,17 +31,47 @@ class HomeViewModel(application: Application) : AndroidViewModel(application) {
private val _billsStatus = MutableLiveData<String>() private val _billsStatus = MutableLiveData<String>()
val billsStatus: LiveData<String> = _billsStatus val billsStatus: LiveData<String> = _billsStatus
private val _isLoading = MutableLiveData<Boolean>() private val _isLoadingBills = MutableLiveData<Boolean>()
val isLoading: LiveData<Boolean> = _isLoading 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 { init {
Log.d(TAG, "HomeViewModel initialized") Log.d(TAG, "HomeViewModel initialized")
loadUserData() loadUserData()
checkOutstandingBills() checkOutstandingBills()
loadCustomerSubscriptions()
} }
private fun loadUserData() { private fun loadUserData() {
val userName = secureStorage.getUserName() ?: "User" val activeAccount = secureStorage.getActiveAccount()
val userName = activeAccount?.name ?: "User"
Log.d(TAG, "Loading user data: $userName") Log.d(TAG, "Loading user data: $userName")
_welcomeText.value = "Welcome\n$userName" _welcomeText.value = "Welcome\n$userName"
} }
@@ -52,14 +87,14 @@ class HomeViewModel(application: Application) : AndroidViewModel(application) {
return return
} }
_isLoading.value = true _isLoadingBills.value = true
viewModelScope.launch { viewModelScope.launch {
Log.d(TAG, "Making API call to check outstanding bills") Log.d(TAG, "Making API call to check outstanding bills")
when (val result = apiService.getOutstandingBills("connect.sid=$cookie")) { when (val result = apiService.getOutstandingBills("connect.sid=$cookie")) {
is ApiResult.Success -> { is ApiResult.Success -> {
Log.d(TAG, "Bills check successful: ${result.data.size} bills found") Log.d(TAG, "Bills check successful: ${result.data.size} bills found")
_isLoading.value = false _isLoadingBills.value = false
if (result.data.isEmpty()) { if (result.data.isEmpty()) {
_billsStatus.value = "You don't have any pending bills left" _billsStatus.value = "You don't have any pending bills left"
} else { } else {
@@ -68,10 +103,117 @@ class HomeViewModel(application: Application) : AndroidViewModel(application) {
} }
is ApiResult.Error -> { is ApiResult.Error -> {
Log.d(TAG, "Bills check failed: ${result.message}") Log.d(TAG, "Bills check failed: ${result.message}")
_isLoading.value = false _isLoadingBills.value = false
_billsStatus.value = "Unable to check bills status" _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_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="vertical" android:orientation="vertical"
android:padding="20dp"> android:padding="16dp">
<!-- Welcome Section --> <!-- Welcome Section -->
<TextView <TextView
@@ -64,16 +64,237 @@
</com.google.android.material.card.MaterialCardView> </com.google.android.material.card.MaterialCardView>
<!-- Placeholder for future features --> <!-- Subscriptions Overview Card -->
<TextView <com.google.android.material.card.MaterialCardView
android:id="@+id/card_subscriptions"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="More features coming soon..." android:layout_marginBottom="24dp"
android:textSize="14sp" app:cardCornerRadius="12dp"
android:textColor="?android:attr/textColorSecondary" app:cardElevation="2dp">
android:gravity="center"
android:layout_marginTop="32dp" /> <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> </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"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<!-- Material Design default colors -->
<color name="purple_200">#FFBB86FC</color> <color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color> <color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color> <color name="purple_700">#FF3700B3</color>
@@ -7,4 +8,10 @@
<color name="teal_700">#FF018786</color> <color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color> <color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</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>