diff --git a/app/src/main/java/sh/sar/gridflow/data/Models.kt b/app/src/main/java/sh/sar/gridflow/data/Models.kt index b0b3560..b348260 100644 --- a/app/src/main/java/sh/sar/gridflow/data/Models.kt +++ b/app/src/main/java/sh/sar/gridflow/data/Models.kt @@ -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 +) diff --git a/app/src/main/java/sh/sar/gridflow/network/FenakaApiService.kt b/app/src/main/java/sh/sar/gridflow/network/FenakaApiService.kt index ae0eae7..a13737c 100644 --- a/app/src/main/java/sh/sar/gridflow/network/FenakaApiService.kt +++ b/app/src/main/java/sh/sar/gridflow/network/FenakaApiService.kt @@ -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> = withContext(Dispatchers.IO) { + suspend fun getCustomerSubscriptions(cookie: String): ApiResult> = 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::class.java).toList() - ApiResult.Success(bills, null) + val subscriptions = gson.fromJson(responseBody, Array::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> = 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::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> = 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::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 { diff --git a/app/src/main/java/sh/sar/gridflow/ui/home/HomeFragment.kt b/app/src/main/java/sh/sar/gridflow/ui/home/HomeFragment.kt index 1ae36a5..3b295ca 100644 --- a/app/src/main/java/sh/sar/gridflow/ui/home/HomeFragment.kt +++ b/app/src/main/java/sh/sar/gridflow/ui/home/HomeFragment.kt @@ -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 } -} \ No newline at end of file +} diff --git a/app/src/main/java/sh/sar/gridflow/ui/home/HomeViewModel.kt b/app/src/main/java/sh/sar/gridflow/ui/home/HomeViewModel.kt index 1fd1e51..f64dcc4 100644 --- a/app/src/main/java/sh/sar/gridflow/ui/home/HomeViewModel.kt +++ b/app/src/main/java/sh/sar/gridflow/ui/home/HomeViewModel.kt @@ -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() val billsStatus: LiveData = _billsStatus - private val _isLoading = MutableLiveData() - val isLoading: LiveData = _isLoading + private val _isLoadingBills = MutableLiveData() + val isLoadingBills: LiveData = _isLoadingBills + + // Subscriptions + private val _subscriptions = MutableLiveData>() + val subscriptions: LiveData> = _subscriptions + + private val _isLoadingSubscriptions = MutableLiveData() + val isLoadingSubscriptions: LiveData = _isLoadingSubscriptions + + private val _subscriptionsError = MutableLiveData() + val subscriptionsError: LiveData = _subscriptionsError + + // Usage Chart + private val _chartData = MutableLiveData>() + val chartData: LiveData> = _chartData + + private val _chartUnit = MutableLiveData() + val chartUnit: LiveData = _chartUnit + + private val _chartServiceType = MutableLiveData() + val chartServiceType: LiveData = _chartServiceType + + private val _isLoadingChart = MutableLiveData() + val isLoadingChart: LiveData = _isLoadingChart + + private val _chartError = MutableLiveData() + val chartError: LiveData = _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" } } } } -} \ No newline at end of file + + 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() + } +} diff --git a/app/src/main/java/sh/sar/gridflow/ui/home/SubscriptionDropdownItem.kt b/app/src/main/java/sh/sar/gridflow/ui/home/SubscriptionDropdownItem.kt new file mode 100644 index 0000000..286d468 --- /dev/null +++ b/app/src/main/java/sh/sar/gridflow/ui/home/SubscriptionDropdownItem.kt @@ -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 +} diff --git a/app/src/main/java/sh/sar/gridflow/ui/home/SubscriptionsAdapter.kt b/app/src/main/java/sh/sar/gridflow/ui/home/SubscriptionsAdapter.kt new file mode 100644 index 0000000..0de8cba --- /dev/null +++ b/app/src/main/java/sh/sar/gridflow/ui/home/SubscriptionsAdapter.kt @@ -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 = emptyList(), + private var selectedSubscriptionId: Long? = null, + private val onSubscriptionClick: (CustomerSubscription) -> Unit = {} +) : RecyclerView.Adapter() { + + fun updateSubscriptions(newSubscriptions: List) { + 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) + } + } + } + } +} diff --git a/app/src/main/java/sh/sar/gridflow/ui/widgets/BarChartView.kt b/app/src/main/java/sh/sar/gridflow/ui/widgets/BarChartView.kt new file mode 100644 index 0000000..bd498af --- /dev/null +++ b/app/src/main/java/sh/sar/gridflow/ui/widgets/BarChartView.kt @@ -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 = 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, 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) + } + } +} diff --git a/app/src/main/res/drawable/circle_background.xml b/app/src/main/res/drawable/circle_background.xml new file mode 100644 index 0000000..5bb8d20 --- /dev/null +++ b/app/src/main/res/drawable/circle_background.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/app/src/main/res/drawable/selected_subscription_background.xml b/app/src/main/res/drawable/selected_subscription_background.xml new file mode 100644 index 0000000..5fbf584 --- /dev/null +++ b/app/src/main/res/drawable/selected_subscription_background.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/app/src/main/res/layout/fragment_home.xml b/app/src/main/res/layout/fragment_home.xml index d5ff263..9843e68 100644 --- a/app/src/main/res/layout/fragment_home.xml +++ b/app/src/main/res/layout/fragment_home.xml @@ -12,7 +12,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" - android:padding="20dp"> + android:padding="16dp"> - - + + android:layout_marginBottom="24dp" + app:cardCornerRadius="12dp" + app:cardElevation="2dp"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - \ No newline at end of file + diff --git a/app/src/main/res/layout/item_subscription.xml b/app/src/main/res/layout/item_subscription.xml new file mode 100644 index 0000000..5fe2974 --- /dev/null +++ b/app/src/main/res/layout/item_subscription.xml @@ -0,0 +1,110 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values-night/colors.xml b/app/src/main/res/values-night/colors.xml new file mode 100644 index 0000000..be9e92d --- /dev/null +++ b/app/src/main/res/values-night/colors.xml @@ -0,0 +1,17 @@ + + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + + + #2C2C2C + #555555 + #1E3A8A + #3B82F6 + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index f8c6127..8bb344a 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -1,5 +1,6 @@ + #FFBB86FC #FF6200EE #FF3700B3 @@ -7,4 +8,10 @@ #FF018786 #FF000000 #FFFFFFFF - \ No newline at end of file + + + #E3F2FD + #BBDEFB + #E3F2FD + #90CAF9 +