diff --git a/.gitignore b/.gitignore index 4c2a586..c331fbd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,41 +1,31 @@ -# Gradle -.gradle/ -build/ +# Android Studio / IntelliJ +# Gradle +# Keys / services # Local config -local.properties - +# Logs & profiling +# NDK / C++ +# OS # Output +# Sesiones PBL (Learning/Aprendizaje) +docs/ *.aab *.apk -output-metadata.json - -# Android Studio / IntelliJ -*.iml -.idea/ - -# NDK / C++ +build/ captures/ -.externalNativeBuild/ .cxx/ - -# Keys / services +.DS_Store +.externalNativeBuild/ +google-services.json +.gradle/ +*.hprof +.idea/ +*.iml *.jks *.keystore keystore.properties -google-services.json - -# Logs & profiling +local.properties *.log -*.hprof - -# OS -.DS_Store -Thumbs.db - -*.keystore - -# Sesiones PBL (Learning/Aprendizaje) markdown/ - - +output-metadata.json +Thumbs.db diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0397d2d..6e52f9b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -21,8 +21,8 @@ android { applicationId = "com.d4vram.cbdcounter" minSdk = 26 targetSdk = 35 - versionCode = 6 - versionName = "1.1.0" + versionCode = 7 + versionName = "1.4.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } diff --git a/app/release/baselineProfiles/0/app-release.dm b/app/release/baselineProfiles/0/app-release.dm index f2b7d68..0475c27 100644 Binary files a/app/release/baselineProfiles/0/app-release.dm and b/app/release/baselineProfiles/0/app-release.dm differ diff --git a/app/release/baselineProfiles/1/app-release.dm b/app/release/baselineProfiles/1/app-release.dm index ada2b05..7b11b50 100644 Binary files a/app/release/baselineProfiles/1/app-release.dm and b/app/release/baselineProfiles/1/app-release.dm differ diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 585d1d1..b22893a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -17,7 +17,16 @@ tools:targetApi="31"> + + + + + android:parentActivityName=".CalendarActivity"> + android:value=".CalendarActivity" /> prefsJson.put(k, v) } + backupJson.put("prefs", prefsJson) + + val emojiJson = JSONObject() + emojiPrefs.forEach { (k, v) -> emojiJson.put(k, v) } + backupJson.put("emoji_prefs", emojiJson) + + // 2. Crear archivo temporal para el JSON + val tempDir = File(context.cacheDir, "backup_temp") + if (tempDir.exists()) tempDir.deleteRecursively() + tempDir.mkdirs() + + val jsonFile = File(tempDir, JSON_FILE_NAME) + jsonFile.writeText(backupJson.toString(2)) + + // 3. Crear archivo ZIP de destino + val timeStamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date()) + val zipFileName = "cbd_backup_$timeStamp.zip" + val zipFile = File(context.getExternalFilesDir(null), "backups/$zipFileName") + zipFile.parentFile?.mkdirs() + + // 4. Escribir ZIP (incluyendo JSON y carpeta de audios) + ZipOutputStream(BufferedOutputStream(FileOutputStream(zipFile))).use { zos -> + // Agregar JSON + addToZip(zos, jsonFile, JSON_FILE_NAME) + + // Agregar Audios + val audioDir = File(context.filesDir, "audios") + if (audioDir.exists() && audioDir.isDirectory) { + audioDir.listFiles()?.forEach { audioFile -> + addToZip(zos, audioFile, "audios/${audioFile.name}") + } + } + } + + // Limpiar + tempDir.deleteRecursively() + + return zipFile + + } catch (e: Exception) { + Log.e(TAG, "Error creating backup", e) + return null + } + } + + private fun addToZip(zos: ZipOutputStream, file: File, entryName: String) { + if (!file.exists()) return + val entry = ZipEntry(entryName) + zos.putNextEntry(entry) + file.inputStream().use { it.copyTo(zos) } + zos.closeEntry() + } + + fun restoreBackup(context: Context, uri: Uri): Boolean { + val tempDir = File(context.cacheDir, "restore_temp") + if (tempDir.exists()) tempDir.deleteRecursively() + tempDir.mkdirs() + + try { + // 1. Descomprimir ZIP + context.contentResolver.openInputStream(uri)?.use { inputStream -> + java.util.zip.ZipInputStream(inputStream).use { zis -> + var entry = zis.nextEntry + while (entry != null) { + val file = File(tempDir, entry.name) + if (entry.isDirectory) { + file.mkdirs() + } else { + file.parentFile?.mkdirs() + file.outputStream().use { fos -> zis.copyTo(fos) } + } + entry = zis.nextEntry + } + } + } + + // 2. Leer y validar JSON + val jsonFile = File(tempDir, JSON_FILE_NAME) + if (!jsonFile.exists()) return false + + val jsonContent = jsonFile.readText() + val backupJson = JSONObject(jsonContent) + + // 3. Restaurar Prefs (Main) + val prefsJson = backupJson.optJSONObject("prefs") + if (prefsJson != null) { + val editor = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE).edit() + editor.clear() // Opcional: limpiar antes de restaurar + for (key in prefsJson.keys()) { + val value = prefsJson.get(key) + when (value) { + is Boolean -> editor.putBoolean(key, value) + is Int -> editor.putInt(key, value) + is Long -> editor.putLong(key, value) + is Float -> editor.putFloat(key, value.toFloat()) + is String -> editor.putString(key, value) + } + } + editor.apply() + } + + // 4. Restaurar Prefs (Emojis) + val emojiJson = backupJson.optJSONObject("emoji_prefs") + if (emojiJson != null) { + val editor = context.getSharedPreferences(EMOJI_PREFS_NAME, Context.MODE_PRIVATE).edit() + editor.clear() + for (key in emojiJson.keys()) { + val value = emojiJson.get(key) + if (value is String) editor.putString(key, value) + } + editor.apply() + } + + // 5. Restaurar Audios + val audiosDir = File(tempDir, "audios") + if (audiosDir.exists() && audiosDir.isDirectory) { + val targetDir = File(context.filesDir, "audios") + targetDir.mkdirs() + audiosDir.listFiles()?.forEach { audioFile -> + audioFile.copyTo(File(targetDir, audioFile.name), overwrite = true) + } + } + + return true + + } catch (e: Exception) { + Log.e(TAG, "Error restoring backup", e) + return false + } finally { + tempDir.deleteRecursively() + } + } +} diff --git a/app/src/main/java/com/d4vram/cbdcounter/CBDWidgetProvider.kt b/app/src/main/java/com/d4vram/cbdcounter/CBDWidgetProvider.kt index 175ba57..fb1d8c4 100644 --- a/app/src/main/java/com/d4vram/cbdcounter/CBDWidgetProvider.kt +++ b/app/src/main/java/com/d4vram/cbdcounter/CBDWidgetProvider.kt @@ -205,35 +205,33 @@ class CBDWidgetProvider : AppWidgetProvider() { appWidgetManager.updateAppWidget(appWidgetId, views) } - private fun incrementCounter(context: Context) { + private fun incrementActiveCounter(context: Context) { val today = getCurrentDateKey() - val sharedPrefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) - val currentCount = sharedPrefs.getInt("$KEY_COUNT_PREFIX$today", 0) - - sharedPrefs.edit() - .putInt("$KEY_COUNT_PREFIX$today", currentCount + 1) - .apply() + Prefs.incrementActiveCount(context, today) } private fun addStandardCBD(context: Context) { - incrementCounter(context) - val entry = "🔹 ${getCurrentTimestamp()}" + incrementActiveCounter(context) + val isThc = Prefs.getSubstanceType(context) == "THC" + val entry = if (isThc) "🟢 ${getCurrentTimestamp()}" else "🔹 ${getCurrentTimestamp()}" appendNote(context, entry) } private fun resetCBD(context: Context) { val today = getCurrentDateKey() - val sharedPrefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) - - sharedPrefs.edit() - .putInt("$KEY_COUNT_PREFIX$today", 0) - .apply() + // Reset solo el contador del modo activo + val isThc = Prefs.getSubstanceType(context) == "THC" + if (isThc) { + Prefs.setThcCount(context, today, 0) + } else { + Prefs.setCbdCount(context, today, 0) + } } private fun getCurrentCount(context: Context): Int { val today = getCurrentDateKey() - val sharedPrefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) - return sharedPrefs.getInt("$KEY_COUNT_PREFIX$today", 0) + // Devolver el contador del modo activo + return Prefs.getActiveCount(context, today) } private fun getCurrentDateKey(): String { @@ -247,17 +245,25 @@ class CBDWidgetProvider : AppWidgetProvider() { } private fun addWeed(context: Context) { - incrementCounter(context) + // Weed SIEMPRE suma a THC + incrementThcCounter(context) val entry = "🌿 ${getCurrentTimestamp()} (aliñado con weed)" appendNote(context, entry) } private fun addPolem(context: Context) { - incrementCounter(context) + // Polen SIEMPRE suma a THC + incrementThcCounter(context) val entry = "🍫 ${getCurrentTimestamp()} (aliñado con polen)" appendNote(context, entry) } + private fun incrementThcCounter(context: Context) { + val today = getCurrentDateKey() + val currentThc = Prefs.getThcCount(context, today) + Prefs.setThcCount(context, today, currentThc + 1) + } + private fun appendNote(context: Context, entry: String) { val today = getCurrentDateKey() val currentNote = Prefs.getNote(context, today) diff --git a/app/src/main/java/com/d4vram/cbdcounter/StatsActivity.kt b/app/src/main/java/com/d4vram/cbdcounter/CalendarActivity.kt similarity index 97% rename from app/src/main/java/com/d4vram/cbdcounter/StatsActivity.kt rename to app/src/main/java/com/d4vram/cbdcounter/CalendarActivity.kt index a58e963..a63f8d1 100644 --- a/app/src/main/java/com/d4vram/cbdcounter/StatsActivity.kt +++ b/app/src/main/java/com/d4vram/cbdcounter/CalendarActivity.kt @@ -17,7 +17,7 @@ import java.util.Calendar import java.util.Date import java.util.Locale -class StatsActivity : AppCompatActivity(), NoteBottomSheet.Listener { +class CalendarActivity : AppCompatActivity(), NoteBottomSheet.Listener { private lateinit var toolbar: MaterialToolbar private lateinit var monthLabel: TextView @@ -51,6 +51,8 @@ class StatsActivity : AppCompatActivity(), NoteBottomSheet.Listener { super.onCreate(savedInstanceState) setContentView(R.layout.activity_stats) + window.statusBarColor = getColor(R.color.gradient_start) + toolbar = findViewById(R.id.statsToolbar) monthLabel = findViewById(R.id.monthLabel) prevMonthButton = findViewById(R.id.prevMonthButton) @@ -159,9 +161,8 @@ class StatsActivity : AppCompatActivity(), NoteBottomSheet.Listener { for (day in 1..daysInMonth) { workingCalendar.set(Calendar.DAY_OF_MONTH, day) val dateKey = dateKeyFormat.format(workingCalendar.time) - val prefKey = "count_$dateKey" - val hasData = sharedPrefs.contains(prefKey) - val count = sharedPrefs.getInt(prefKey, 0) + val count = Prefs.getTotalCount(this, dateKey) + val hasData = count > 0 val emoji = if (hasData) EmojiUtils.emojiForCount(count, this) else "" val isToday = dateKey == todayKey && todayCalendar.get(Calendar.MONTH) == currentMonth && diff --git a/app/src/main/java/com/d4vram/cbdcounter/DashboardActivity.kt b/app/src/main/java/com/d4vram/cbdcounter/DashboardActivity.kt new file mode 100644 index 0000000..ab58409 --- /dev/null +++ b/app/src/main/java/com/d4vram/cbdcounter/DashboardActivity.kt @@ -0,0 +1,285 @@ +package com.d4vram.cbdcounter + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.SharedPreferences +import android.os.Bundle +import android.view.Menu +import android.view.MenuItem +import android.widget.TextView +import androidx.appcompat.app.AppCompatActivity +import com.google.android.material.appbar.MaterialToolbar +import com.google.android.material.button.MaterialButton +import com.google.android.material.chip.ChipGroup +import java.text.SimpleDateFormat +import java.util.* + +class DashboardActivity : AppCompatActivity() { + + private lateinit var sharedPrefs: SharedPreferences + private lateinit var dataChangeReceiver: android.content.BroadcastReceiver + private lateinit var lineChart: LineChartView + private lateinit var tvToday: TextView + private lateinit var tvWeek: TextView + private lateinit var tvAvg: TextView + private lateinit var tvStreak: TextView + private lateinit var tvBusiestDay: TextView + private lateinit var tvBestDay: TextView + + private val dateKeyFormat = SimpleDateFormat("dd/MM/yyyy", Locale.getDefault()) + private val labelFormat = SimpleDateFormat("dd/MM", Locale.getDefault()) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_dashboard) + + window.statusBarColor = getColor(R.color.gradient_start) + + sharedPrefs = getSharedPreferences("CBDCounter", Context.MODE_PRIVATE) + + // Initialize broadcast receiver for data changes (e.g., after CSV import) + dataChangeReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + if (intent?.action == MainActivity.ACTION_DATA_CHANGED) { + // Refresh stats when data changes + calculateStats() + // Determine current selected chip range + val rangeChipGroup = findViewById(R.id.rangeChipGroup) + val days = when (rangeChipGroup.checkedChipId) { + R.id.chip14Days -> 14 + R.id.chip30Days -> 30 + else -> 7 + } + loadChartData(days) + } + } + } + + val toolbar = findViewById(R.id.dashboardToolbar) + setSupportActionBar(toolbar) + toolbar.setNavigationOnClickListener { finish() } + + // Initialize Views + tvToday = findViewById(R.id.tvTodayCount) + tvWeek = findViewById(R.id.tvWeekCount) + tvAvg = findViewById(R.id.tvAvgCount) + tvStreak = findViewById(R.id.tvStreakCount) + tvBusiestDay = findViewById(R.id.tvBusiestDay) + tvBestDay = findViewById(R.id.tvBestDay) + lineChart = findViewById(R.id.lineChart) + + val btnViewCalendar = findViewById(R.id.btnViewCalendar) + btnViewCalendar.setOnClickListener { + startActivity(Intent(this, CalendarActivity::class.java)) + } + + val rangeChipGroup = findViewById(R.id.rangeChipGroup) + rangeChipGroup.setOnCheckedChangeListener { _, checkedId -> + val days = when (checkedId) { + R.id.chip7Days -> 7 + R.id.chip14Days -> 14 + R.id.chip30Days -> 30 + else -> 7 + } + loadChartData(days) + } + + // Load Data + calculateStats() + loadChartData(7) + } + + override fun onResume() { + super.onResume() + // Register receiver for data changes + registerReceiver(dataChangeReceiver, IntentFilter(MainActivity.ACTION_DATA_CHANGED), Context.RECEIVER_NOT_EXPORTED) + + // Refresh data on resume (in case settings changed or user returned from calendar) + calculateStats() + // Determine current selected chip range + val rangeChipGroup = findViewById(R.id.rangeChipGroup) + val days = when (rangeChipGroup.checkedChipId) { + R.id.chip14Days -> 14 + R.id.chip30Days -> 30 + else -> 7 + } + loadChartData(days) + } + + override fun onPause() { + super.onPause() + // Unregister receiver to avoid memory leaks + try { + unregisterReceiver(dataChangeReceiver) + } catch (e: IllegalArgumentException) { + // Receiver already unregistered, ignore + } + } + + override fun onCreateOptionsMenu(menu: Menu?): Boolean { + menuInflater.inflate(R.menu.dashboard_menu, menu) + return true + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + return when (item.itemId) { + R.id.action_settings -> { + startActivity(Intent(this, EmojiSettingsActivity::class.java)) + true + } + else -> super.onOptionsItemSelected(item) + } + } + + private fun calculateStats() { + // 1. Today + val todayKey = dateKeyFormat.format(Date()) + val todayCount = Prefs.getTotalCount(this, todayKey) + tvToday.text = todayCount.toString() + + // 2. Week Total & Average + val calendar = Calendar.getInstance() + var weekTotal = 0 + val counts = mutableListOf() + + // Go back 7 days (including today? usually "last 7 days" includes today) + // Let's do last 7 days excluding today if we want completed days? + // No, user expects "this week" or "rolling 7 days". Lets do rolling 7 days including today. + val tempCal = Calendar.getInstance() + for (i in 0 until 7) { + val key = dateKeyFormat.format(tempCal.time) + val c = Prefs.getTotalCount(this, key) + weekTotal += c + counts.add(c) + tempCal.add(Calendar.DAY_OF_YEAR, -1) // go back + } + tvWeek.text = weekTotal.toString() + + // Average (last 30 days for better accuracy?) + val avgCal = Calendar.getInstance() + var total30 = 0 + var daysWithData = 0 + for (i in 0 until 30) { + val key = dateKeyFormat.format(avgCal.time) + val dayTotal = Prefs.getTotalCount(this, key) + if (dayTotal > 0) { + total30 += dayTotal + daysWithData++ + } + avgCal.add(Calendar.DAY_OF_YEAR, -1) + } + val avg = if (daysWithData > 0) total30.toFloat() / daysWithData else 0f + tvAvg.text = String.format("%.1f", avg) + + // 3. Streak + tvStreak.text = "${calculateCleanStreak()} días" + + // 4. Patterns + calculatePatterns() + } + + private fun calculateCleanStreak(): Int { + var streak = 0 + val calendar = Calendar.getInstance() + + // Check backwards from today + // If today has 0, streak starts from yesterday? + // Logic: "Clean Streak" usually means days WITHOUT using (count == 0). + // Let's assume standard logic: 0 means clean. + + // Check if today is clean so far? + // If currentCount > 0, streak is currently 0. + // If currentCount == 0, count today + previous days. + + // However, if the user hasn't finished the day, is it fair to count today? + // Let's just count consecutive days with 0. + + // Start from today descending + var checkingDate = Calendar.getInstance() + + // Safety Break after 365 days + for(i in 0 until 365) { + val key = dateKeyFormat.format(checkingDate.time) + // If we don't have data for a day, do we assume 0 (clean)? + // Usually yes if it's in the past. + val count = Prefs.getTotalCount(this, key) + if (count == 0) { + streak++ + } else { + break + } + checkingDate.add(Calendar.DAY_OF_YEAR, -1) + } + return streak + } + + private fun calculatePatterns() { + val allDates = Prefs.getAllDatesWithData(this) + val dayCounts = IntArray(7) { 0 } // Sun=0, Mon=1... + val dayOccurrences = IntArray(7) { 0 } + + var maxCount = 0 + var bestDate = "" + + allDates.forEach { dateStr -> + try { + val date = dateKeyFormat.parse(dateStr) + if (date != null) { + val totalCount = Prefs.getTotalCount(this, dateStr) + if (totalCount > 0) { + val cal = Calendar.getInstance() + cal.time = date + val dayOfWeek = cal.get(Calendar.DAY_OF_WEEK) - 1 // 0-indexed + dayCounts[dayOfWeek] += totalCount + dayOccurrences[dayOfWeek]++ + + if (totalCount > maxCount) { + maxCount = totalCount + bestDate = dateStr + } + } + } + } catch (_: Exception) {} + } + + // Busiest Day (Highest Average) + var maxAvg = 0f + var busiestDayIndex = -1 + + for (i in 0 until 7) { + if (dayOccurrences[i] > 0) { + val avg = dayCounts[i].toFloat() / dayOccurrences[i] + if (avg > maxAvg) { + maxAvg = avg + busiestDayIndex = i + } + } + } + + val daysOfWeek = arrayOf("Domingo", "Lunes", "Martes", "Miércoles", "Jueves", "Viernes", "Sábado") + + val busiestDayStr = if (busiestDayIndex != -1) "${daysOfWeek[busiestDayIndex]} (${String.format("%.1f", maxAvg)})" else "Sin datos" + tvBusiestDay.text = getString(R.string.pattern_busiest_day, busiestDayStr) + tvBestDay.text = getString(R.string.pattern_best_day, if (bestDate.isNotEmpty()) bestDate else "-", maxCount) + } + + private fun loadChartData(days: Int) { + val dataPoints = mutableListOf>() + val calendar = Calendar.getInstance() + + // We want 'days' points ending today + // Start date = Today - (days - 1) + calendar.add(Calendar.DAY_OF_YEAR, -(days - 1)) + + for (i in 0 until days) { + val dateKey = dateKeyFormat.format(calendar.time) + val label = labelFormat.format(calendar.time) + val count = Prefs.getTotalCount(this, dateKey) + dataPoints.add(Pair(label, count)) + calendar.add(Calendar.DAY_OF_YEAR, 1) + } + lineChart.setData(dataPoints) + } +} diff --git a/app/src/main/java/com/d4vram/cbdcounter/EdgeToEdgeUtils.kt b/app/src/main/java/com/d4vram/cbdcounter/EdgeToEdgeUtils.kt new file mode 100644 index 0000000..d4055ff --- /dev/null +++ b/app/src/main/java/com/d4vram/cbdcounter/EdgeToEdgeUtils.kt @@ -0,0 +1,64 @@ +package com.d4vram.cbdcounter + +import android.os.Build +import android.view.View +import android.view.WindowInsetsController +import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat + +/** + * Utilidades para manejar edge-to-edge de forma consistente en toda la app + */ +object EdgeToEdgeUtils { + + /** + * Habilita edge-to-edge en una Activity. + * Usa esto cuando el layout ya tiene fitsSystemWindows="true" en el AppBarLayout/Toolbar. + */ + fun enableEdgeToEdge(activity: AppCompatActivity, lightStatusBar: Boolean = false) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + activity.window.setDecorFitsSystemWindows(false) + activity.window.insetsController?.setSystemBarsAppearance( + if (lightStatusBar) WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS else 0, + WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS + ) + } else { + @Suppress("DEPRECATION") + activity.window.decorView.systemUiVisibility = ( + View.SYSTEM_UI_FLAG_LAYOUT_STABLE + or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN + ) + } + } + + /** + * Configura edge-to-edge en una Activity aplicando padding programáticamente. + * Usa esto cuando el layout NO tiene fitsSystemWindows. + * + * @param activity La activity a configurar + * @param rootView La vista raíz del layout + * @param topView La vista que debe recibir padding top (AppBarLayout o Toolbar) + * @param lightStatusBar true para iconos oscuros en status bar + */ + fun setup( + activity: AppCompatActivity, + rootView: View, + topView: View, + lightStatusBar: Boolean = false + ) { + enableEdgeToEdge(activity, lightStatusBar) + + // Aplicar insets al topView + ViewCompat.setOnApplyWindowInsetsListener(topView) { view, windowInsets -> + val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) + view.setPadding( + view.paddingLeft, + insets.top, + view.paddingRight, + view.paddingBottom + ) + WindowInsetsCompat.CONSUMED + } + } +} diff --git a/app/src/main/java/com/d4vram/cbdcounter/EmojiSettingsActivity.kt b/app/src/main/java/com/d4vram/cbdcounter/EmojiSettingsActivity.kt index 99154b1..c7e934f 100644 --- a/app/src/main/java/com/d4vram/cbdcounter/EmojiSettingsActivity.kt +++ b/app/src/main/java/com/d4vram/cbdcounter/EmojiSettingsActivity.kt @@ -22,6 +22,23 @@ class EmojiSettingsActivity : AppCompatActivity() { private lateinit var adapter: EmojiRangeAdapter private lateinit var resetButton: MaterialButton + private val restoreBackupLauncher = registerForActivityResult(androidx.activity.result.contract.ActivityResultContracts.OpenDocument()) { uri -> + if (uri != null) { + try { + if (BackupManager.restoreBackup(this, uri)) { + android.widget.Toast.makeText(this, "Backup restaurado correctamente", android.widget.Toast.LENGTH_LONG).show() + // Recargar datos visuales si es necesario o reiniciar app + setResult(RESULT_OK) + finish() // Cerrar para obligar a recargar MainActivity al volver + } else { + android.widget.Toast.makeText(this, "Error al restaurar backup (formato inválido)", android.widget.Toast.LENGTH_LONG).show() + } + } catch (e: Exception) { + android.widget.Toast.makeText(this, "Error: ${e.message}", android.widget.Toast.LENGTH_LONG).show() + } + } + } + // Lista de rangos con sus emojis por defecto private val emojiRanges = listOf( EmojiRange(0, "😌", R.color.green_safe, "0"), @@ -41,6 +58,9 @@ class EmojiSettingsActivity : AppCompatActivity() { super.onCreate(savedInstanceState) setContentView(R.layout.activity_emoji_settings) + // Configurar color de la barra de estado + window.statusBarColor = ContextCompat.getColor(this, R.color.gradient_start) + // Configurar toolbar val toolbar = findViewById(R.id.settingsToolbar) toolbar.setNavigationOnClickListener { finish() } @@ -57,11 +77,63 @@ class EmojiSettingsActivity : AppCompatActivity() { } recyclerView.adapter = adapter + // Configurar Toggle Sintonía (CBD/THC) + val substanceToggle = findViewById(R.id.substanceToggleGroup) + val currentSubstance = Prefs.getSubstanceType(this) + + if (currentSubstance == "THC") { + substanceToggle.check(R.id.btnThc) + } else { + substanceToggle.check(R.id.btnCbd) + } + + substanceToggle.addOnButtonCheckedListener { _, checkedId, isChecked -> + if (isChecked) { + val type = if (checkedId == R.id.btnThc) "THC" else "CBD" + Prefs.setSubstanceType(this, type) + // TODO: Apply Color Theme changes if necessary immediately or show toast + } + } + // Configurar botón de reset resetButton = findViewById(R.id.resetButton) resetButton.setOnClickListener { showResetConfirmationDialog() } + + // ---- BACKUP UI ---- + val btnCreateBackup = findViewById(R.id.btnCreateBackup) + val btnRestoreBackup = findViewById(R.id.btnRestoreBackup) + val checkEncrypt = findViewById(R.id.checkBackupEncrypt) + + btnCreateBackup.setOnClickListener { + // TODO: Handle encryption if checkEncrypt.isChecked + val backupFile = BackupManager.createBackup(this) + if (backupFile != null) { + shareBackupFile(backupFile) + } else { + android.widget.Toast.makeText(this, "Error al crear backup", android.widget.Toast.LENGTH_SHORT).show() + } + } + + btnRestoreBackup.setOnClickListener { + android.widget.Toast.makeText(this, "Restauración pendiente de implementar", android.widget.Toast.LENGTH_SHORT).show() + // TODO: Pick file intent -> BackupManager.restoreBackup + } + } + + private fun shareBackupFile(file: java.io.File) { + val uri = androidx.core.content.FileProvider.getUriForFile( + this, + "$packageName.fileprovider", + file + ) + val shareIntent = android.content.Intent(android.content.Intent.ACTION_SEND).apply { + type = "application/zip" + putExtra(android.content.Intent.EXTRA_STREAM, uri) + addFlags(android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + startActivity(android.content.Intent.createChooser(shareIntent, "Compartir Backup")) } /** diff --git a/app/src/main/java/com/d4vram/cbdcounter/EvolutionActivity.kt b/app/src/main/java/com/d4vram/cbdcounter/EvolutionActivity.kt index 4f115ed..298ec6d 100644 --- a/app/src/main/java/com/d4vram/cbdcounter/EvolutionActivity.kt +++ b/app/src/main/java/com/d4vram/cbdcounter/EvolutionActivity.kt @@ -26,6 +26,8 @@ class EvolutionActivity : AppCompatActivity() { super.onCreate(savedInstanceState) setContentView(R.layout.activity_evolution) + window.statusBarColor = getColor(R.color.gradient_start) + val toolbar = findViewById(R.id.evolutionToolbar) toolbar.setNavigationOnClickListener { finish() } @@ -64,7 +66,6 @@ class EvolutionActivity : AppCompatActivity() { private fun loadData(days: Int, offset: Int) { evolutionTitle.text = "Últimos $days días" - val sharedPrefs = getSharedPreferences("CBDCounter", Context.MODE_PRIVATE) val dataPoints = mutableListOf>() val calendar = Calendar.getInstance() // Move back 'offset' days from today @@ -75,8 +76,7 @@ class EvolutionActivity : AppCompatActivity() { for (i in 0 until days) { val dateKey = dateKeyFormat.format(calendar.time) val label = labelFormat.format(calendar.time) - val prefKey = "count_$dateKey" - val count = sharedPrefs.getInt(prefKey, 0) + val count = Prefs.getTotalCount(this, dateKey) dataPoints.add(Pair(label, count)) calendar.add(Calendar.DAY_OF_YEAR, 1) } diff --git a/app/src/main/java/com/d4vram/cbdcounter/MainActivity.kt b/app/src/main/java/com/d4vram/cbdcounter/MainActivity.kt index bbd3c9f..ddad134 100644 --- a/app/src/main/java/com/d4vram/cbdcounter/MainActivity.kt +++ b/app/src/main/java/com/d4vram/cbdcounter/MainActivity.kt @@ -24,6 +24,7 @@ import androidx.appcompat.app.AppCompatDelegate import androidx.annotation.StringRes import androidx.core.content.ContextCompat import androidx.core.content.FileProvider +import android.widget.LinearLayout import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.google.android.material.tabs.TabLayout @@ -40,46 +41,51 @@ import kotlin.collections.ArrayList class MainActivity : AppCompatActivity() { - // Views principales + private lateinit var sharedPrefs: SharedPreferences + + companion object { + const val ACTION_DATA_CHANGED = "com.d4vram.cbdcounter.ACTION_DATA_CHANGED" + } + private var cbdCount = 0 + private var thcCount = 0 + private val currentCount: Int get() = cbdCount + thcCount // Total para emoji y compatibilidad + private val allHistoryData = ArrayList() + private val displayedHistoryData = ArrayList() + private var currentViewMode = ViewMode.WEEK + + private val importMimeTypes = arrayOf( + "text/csv", + "text/comma-separated-values", + "application/csv", + "application/vnd.ms-excel", + "text/plain" + ) + + // View variables private lateinit var counterText: TextView + private lateinit var cbdCountText: TextView + private lateinit var thcCountText: TextView + private lateinit var cbdContainer: View + private lateinit var thcContainer: View private lateinit var dateText: TextView private lateinit var emojiText: TextView - private lateinit var addButton: Button + private lateinit var addButton: MaterialButton private lateinit var addInfusedButton: MaterialButton - private lateinit var statsButton: Chip - private lateinit var subtractButton: Button - private lateinit var resetButton: Button + private lateinit var statsButton: Chip // Changed from MaterialButton to Chip + private lateinit var subtractButton: MaterialButton + private lateinit var resetButton: MaterialButton private lateinit var exportButton: ImageButton private lateinit var importButton: ImageButton private lateinit var settingsButton: ImageButton - - // Botón switch para cambiar el tema private lateinit var themeSwitch: SwitchMaterial - - // Views del historial mejorado private lateinit var historyRecyclerView: RecyclerView - private lateinit var historyAdapter: ImprovedHistoryAdapter private lateinit var tabLayout: TabLayout - private lateinit var statsContainer: View + private lateinit var statsContainer: LinearLayout private lateinit var avgText: TextView private lateinit var totalText: TextView private lateinit var streakText: TextView private lateinit var searchButton: ImageButton - - // Data - private lateinit var sharedPrefs: SharedPreferences - private var currentCount = 0 - private val allHistoryData = ArrayList() - private val displayedHistoryData = ArrayList() - private var currentViewMode = ViewMode.WEEK - - private val importMimeTypes = arrayOf( - "text/csv", - "text/comma-separated-values", - "application/csv", - "application/vnd.ms-excel", - "text/plain" - ) + private lateinit var historyAdapter: ImprovedHistoryAdapter // Receptor para detectar cambio de día/hora mientras la app está abierta private val dateChangeReceiver = object : BroadcastReceiver() { @@ -121,8 +127,9 @@ class MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { // 1. Aplicar tema ANTES de super.onCreate para evitar flickering initSharedPreferences() + Prefs.migrateToV14IfNeeded(this) // Migrar datos al nuevo formato si es necesario applyStoredTheme() - + super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) @@ -172,7 +179,7 @@ class MainActivity : AppCompatActivity() { addAction(Intent.ACTION_TIME_CHANGED) addAction(Intent.ACTION_TIMEZONE_CHANGED) } - registerReceiver(dateChangeReceiver, filter) + registerReceiver(dateChangeReceiver, filter, Context.RECEIVER_NOT_EXPORTED) } override fun onPause() { @@ -187,7 +194,11 @@ class MainActivity : AppCompatActivity() { private fun initViews() { // Views principales - counterText = findViewById(R.id.counterText) + counterText = findViewById(R.id.counterText) // Oculto, para compatibilidad + cbdCountText = findViewById(R.id.cbdCountText) + thcCountText = findViewById(R.id.thcCountText) + cbdContainer = findViewById(R.id.cbdContainer) + thcContainer = findViewById(R.id.thcContainer) dateText = findViewById(R.id.dateText) emojiText = findViewById(R.id.emojiText) addButton = findViewById(R.id.addButton) @@ -278,24 +289,24 @@ class MainActivity : AppCompatActivity() { private fun loadTodayData() { val today = getCurrentDateKey() - currentCount = sharedPrefs.getInt("count_$today", 0) + cbdCount = Prefs.getCbdCount(this, today) + thcCount = Prefs.getThcCount(this, today) } private fun loadAllHistoryData() { allHistoryData.clear() - val allEntries = sharedPrefs.all val dateFormat = SimpleDateFormat("dd/MM/yyyy", Locale.getDefault()) - allEntries.forEach { (key, value) -> - if (key.startsWith("count_") && value is Int) { - val dateString = key.removePrefix("count_") - try { - val date = dateFormat.parse(dateString) - if (date != null) { - allHistoryData.add(HistoryItem(dateString, value, date)) - } - } catch (_: Exception) {} - } + val allDates = Prefs.getAllDatesWithData(this) + allDates.forEach { dateString -> + try { + val date = dateFormat.parse(dateString) + if (date != null) { + val cbd = Prefs.getCbdCount(this, dateString) + val thc = Prefs.getThcCount(this, dateString) + allHistoryData.add(HistoryItem(dateString, cbd, thc, date)) + } + } catch (_: Exception) {} } allHistoryData.sortByDescending { it.dateObject } } @@ -335,9 +346,9 @@ class MainActivity : AppCompatActivity() { streakText.text = "Racha: 0 días" return } - val average = displayedHistoryData.map { it.count }.average() + val average = displayedHistoryData.map { it.totalCount }.average() avgText.text = "Promedio: %.1f".format(average) - val total = displayedHistoryData.sumOf { it.count } + val total = displayedHistoryData.sumOf { it.totalCount } totalText.text = "Total: $total" val streak = calculateCleanStreak() streakText.text = "Racha limpia: $streak días" @@ -347,14 +358,16 @@ class MainActivity : AppCompatActivity() { var streak = 0 val sortedData = allHistoryData.sortedByDescending { it.dateObject } for (item in sortedData) { - if (item.count == 0) streak++ else break + if (item.totalCount == 0) streak++ else break } return streak } private fun saveData() { val today = getCurrentDateKey() - sharedPrefs.edit().putInt("count_$today", currentCount).apply() + Prefs.setCbdCount(this, today, cbdCount) + Prefs.setThcCount(this, today, thcCount) + loadAllHistoryData() updateHistoryView() updateStats() @@ -385,10 +398,14 @@ class MainActivity : AppCompatActivity() { } private fun updateDisplay(animate: Boolean = true) { - counterText.text = currentCount.toString() + // Actualizar contadores duales + cbdCountText.text = cbdCount.toString() + thcCountText.text = thcCount.toString() + counterText.text = currentCount.toString() // Total oculto para compatibilidad + dateText.text = getCurrentDateDisplay() val newEmoji = getEmoji(currentCount) - + if (animate && emojiText.text != newEmoji && emojiText.text.isNotEmpty()) { emojiText.animate().alpha(0f).setDuration(150).withEndAction { emojiText.text = newEmoji @@ -399,13 +416,56 @@ class MainActivity : AppCompatActivity() { emojiText.text = newEmoji } - val color = when { - currentCount == 0 -> R.color.green_safe - currentCount <= 3 -> R.color.yellow_warning - currentCount <= 6 -> R.color.orange_danger + // Destacar el modo activo + val isThc = Prefs.getSubstanceType(this) == "THC" + highlightActiveCounter(isThc) + + // Actualizar colores según cantidad + updateCounterColors() + } + + private fun highlightActiveCounter(isThcActive: Boolean) { + // El contador activo se ve más grande/destacado + val activeScale = 1.1f + val inactiveScale = 0.9f + val activeAlpha = 1.0f + val inactiveAlpha = 0.6f + + if (isThcActive) { + thcContainer.scaleX = activeScale + thcContainer.scaleY = activeScale + thcContainer.alpha = activeAlpha + cbdContainer.scaleX = inactiveScale + cbdContainer.scaleY = inactiveScale + cbdContainer.alpha = inactiveAlpha + } else { + cbdContainer.scaleX = activeScale + cbdContainer.scaleY = activeScale + cbdContainer.alpha = activeAlpha + thcContainer.scaleX = inactiveScale + thcContainer.scaleY = inactiveScale + thcContainer.alpha = inactiveAlpha + } + } + + private fun updateCounterColors() { + // Colores CBD según cantidad + val cbdColor = when { + cbdCount == 0 -> R.color.green_safe + cbdCount <= 4 -> R.color.cbd_text + cbdCount <= 6 -> R.color.orange_danger + else -> R.color.red_critical + } + cbdCountText.setTextColor(ContextCompat.getColor(this, cbdColor)) + + // Colores THC según cantidad + val thcColor = when { + thcCount == 0 -> R.color.green_safe + thcCount <= 4 -> R.color.thc_text + thcCount <= 6 -> R.color.orange_danger else -> R.color.red_critical } - counterText.setTextColor(ContextCompat.getColor(this, color)) + thcCountText.setTextColor(ContextCompat.getColor(this, thcColor)) } private fun getEmoji(count: Int): String = EmojiUtils.emojiForCount(count, this) @@ -419,10 +479,13 @@ class MainActivity : AppCompatActivity() { startActivity(Intent(this, SettingsActivity::class.java)) } subtractButton.setOnClickListener { - if (currentCount > 0) { + val isThc = Prefs.getSubstanceType(this) == "THC" + val activeCount = if (isThc) thcCount else cbdCount + + if (activeCount > 0) { // Inflar layout personalizado val dialogView = LayoutInflater.from(this).inflate(R.layout.dialog_confirm_delete, null) - + // Crear el diálogo val dialog = MaterialAlertDialogBuilder(this) .setView(dialogView) @@ -431,17 +494,18 @@ class MainActivity : AppCompatActivity() { // Configurar chips dialogView.findViewById(R.id.chip_confirm).setOnClickListener { - currentCount-- + if (isThc) thcCount-- else cbdCount-- updateDisplay() - removeLastEntryFromTodayNote() // 🎯 Borrar último timestamp + removeLastEntryFromTodayNote() // Borrar último timestamp saveData() animateCounter(0.9f) - showFeedback(getString(R.string.cbd_subtracted), true) + val msg = if (isThc) getString(R.string.thc_subtracted) else getString(R.string.cbd_subtracted) + showFeedback(msg, true) dialog.dismiss() } dialogView.findViewById(R.id.chip_keep_note).setOnClickListener { - currentCount-- + if (isThc) thcCount-- else cbdCount-- updateDisplay() // NO borramos la nota, solo restamos el contador saveData() @@ -454,7 +518,7 @@ class MainActivity : AppCompatActivity() { dialog.dismiss() } - // Mostrar con fondo transparente para que se vea bien el card (opcional, pero recomendado si el root es CardView) + // Mostrar con fondo transparente para que se vea bien el card dialog.window?.setBackgroundDrawableResource(android.R.color.transparent) dialog.show() } @@ -462,9 +526,10 @@ class MainActivity : AppCompatActivity() { resetButton.setOnClickListener { AlertDialog.Builder(this) .setTitle("Reiniciar contador") - .setMessage("¿Estás seguro de que quieres reiniciar el contador de hoy?") + .setMessage("¿Estás seguro de que quieres reiniciar el contador de hoy? (CBD y THC)") .setPositiveButton("Sí") { _, _ -> - currentCount = 0 + cbdCount = 0 + thcCount = 0 updateDisplay() saveData() showFeedback("¡Día reiniciado! 💪", true) @@ -534,32 +599,27 @@ class MainActivity : AppCompatActivity() { } private fun buildCsvContent(): String { - val prefsMap = sharedPrefs.all - if (prefsMap.isEmpty()) return "" - - val dates = mutableSetOf() - prefsMap.keys.forEach { key -> - when { - key.startsWith("count_") -> dates.add(key.removePrefix("count_")) - key.startsWith("NOTE_") -> dates.add(key.removePrefix("NOTE_")) - } - } - if (dates.isEmpty()) return "" + val allDates = Prefs.getAllDatesWithData(this) + if (allDates.isEmpty()) return "" val dateFormat = SimpleDateFormat("dd/MM/yyyy", Locale.getDefault()) - val sortedDates = dates.mapNotNull { dateString -> + val sortedDates = allDates.mapNotNull { dateString -> runCatching { dateFormat.parse(dateString) }.getOrNull()?.let { parsed -> dateString to parsed } }.sortedBy { it.second } - val builder = StringBuilder("date,count,note\n") + val builder = StringBuilder("date,count_cbd,count_thc,note\n") sortedDates.forEach { (dateString, _) -> - val count = sharedPrefs.getInt("count_$dateString", 0) + val cbdCount = Prefs.getCbdCount(this, dateString) + val thcCount = Prefs.getThcCount(this, dateString) val note = Prefs.getNote(this, dateString) ?: "" + builder.append(dateString) .append(',') - .append(count) + .append(cbdCount) + .append(',') + .append(thcCount) .append(',') .append(escapeCsvField(note)) .append('\n') @@ -575,23 +635,51 @@ class MainActivity : AppCompatActivity() { if (lines.isEmpty()) throw IllegalArgumentException("Archivo vacío") val editor = sharedPrefs.edit() + // Limpiar datos existentes sharedPrefs.all.keys.filter { it.startsWith("count_") || it.startsWith("NOTE_") }.forEach { key -> editor.remove(key) } + // Detectar formato por cabecera + val header = lines.first().lowercase() + val isNewFormat = header.contains("count_cbd") + lines.drop(1).forEach { line -> if (line.isBlank()) return@forEach val columns = splitCsvLine(line) if (columns.size < 2) return@forEach val date = columns[0] - val count = columns[1].toIntOrNull() ?: return@forEach - editor.putInt("count_$date", count) - val rawNote = if (columns.size >= 3) columns[2] else "" - val note = unescapeCsvField(rawNote) - if (note.isNotEmpty()) { - editor.putString("NOTE_$date", note) + if (isNewFormat) { + // Nuevo formato: date,count_cbd,count_thc,note + val cbdCount = columns.getOrNull(1)?.toIntOrNull() ?: 0 + val thcCount = columns.getOrNull(2)?.toIntOrNull() ?: 0 + editor.putInt("${Prefs.KEY_COUNT_CBD_PREFIX}$date", cbdCount) + editor.putInt("${Prefs.KEY_COUNT_THC_PREFIX}$date", thcCount) + + val rawNote = columns.getOrNull(3) ?: "" + val note = unescapeCsvField(rawNote) + if (note.isNotEmpty()) { + editor.putString("${Prefs.KEY_NOTE_PREFIX}$date", note) + } + } else { + // Formato legacy: date,count,note,substance + val count = columns[1].toIntOrNull() ?: return@forEach + val substance = if (columns.size >= 4) unescapeCsvField(columns[3]) else "CBD" + + // Importar al contador correspondiente + if (substance == "THC") { + editor.putInt("${Prefs.KEY_COUNT_THC_PREFIX}$date", count) + } else { + editor.putInt("${Prefs.KEY_COUNT_CBD_PREFIX}$date", count) + } + + val rawNote = if (columns.size >= 3) columns[2] else "" + val note = unescapeCsvField(rawNote) + if (note.isNotEmpty()) { + editor.putString("${Prefs.KEY_NOTE_PREFIX}$date", note) + } } } editor.apply() @@ -606,6 +694,9 @@ class MainActivity : AppCompatActivity() { updateDisplay() updateHistoryView() updateStats() + + // Notify other activities (like DashboardActivity) that data has changed + sendBroadcast(Intent(ACTION_DATA_CHANGED)) } showFeedback("Importación completada", false) }.onFailure { e -> @@ -640,20 +731,28 @@ class MainActivity : AppCompatActivity() { private fun escapeCsvField(value: String): String { if (value.isEmpty()) return "" - val builder = StringBuilder() - value.forEach { char -> - when (char) { - '\\' -> builder.append("\\\\") - '\n' -> builder.append("\\n") - ',' -> builder.append("\\,") - else -> builder.append(char) - } + + // RFC 4180: Si el campo contiene comas, saltos de línea o comillas, + // debe ir entre comillas dobles. Las comillas dobles se duplican. + val needsQuotes = value.contains(',') || value.contains('\n') || value.contains('"') || value.contains('\r') + + return if (needsQuotes) { + // Duplicar comillas dobles y envolver todo en comillas + "\"${value.replace("\"", "\"\"")}\"" + } else { + value } - return builder.toString() } private fun unescapeCsvField(value: String): String { if (value.isEmpty()) return "" + + // RFC 4180: Si el campo está entre comillas, removerlas y desduplicar comillas internas + if (value.startsWith("\"") && value.endsWith("\"") && value.length >= 2) { + return value.substring(1, value.length - 1).replace("\"\"", "\"") + } + + // Backward compatibility: Manejar formato antiguo con backslash val builder = StringBuilder() var escape = false value.forEach { char -> @@ -678,14 +777,32 @@ class MainActivity : AppCompatActivity() { } private fun registerStandardIntake() { - val entry = "🔹 ${getCurrentTimestamp()}" - registerIntake(entry, getString(R.string.cbd_added)) + val isThc = Prefs.getSubstanceType(this) == "THC" + val entry = if (isThc) "🟢 ${getCurrentTimestamp()}" else "🔹 ${getCurrentTimestamp()}" + val feedback = if (isThc) getString(R.string.thc_added) else getString(R.string.cbd_added) + registerIntake(entry, feedback) } private fun showInfusionDialog() { val dialogView = LayoutInflater.from(this).inflate(R.layout.dialog_infusion_choice, null) val weedButton = dialogView.findViewById(R.id.weedButton) val polemButton = dialogView.findViewById(R.id.polemButton) + val title = dialogView.findViewById(R.id.infusionTitle) + val subtitle = dialogView.findViewById(R.id.infusionSubtitle) + + // Adjust text based on substance + val substanceType = Prefs.getSubstanceType(this) + if (substanceType == "THC") { + title.text = getString(R.string.infusion_question_thc) + subtitle.text = getString(R.string.infusion_subtitle_thc) + + // Adjust colors for THC mode + weedButton.setTextColor(ContextCompat.getColor(this, R.color.thc_weed_orange)) + weedButton.strokeColor = androidx.core.content.res.ResourcesCompat.getColorStateList(resources, R.color.thc_weed_outline, theme) + polemButton.setTextColor(ContextCompat.getColor(this, R.color.thc_weed_orange)) + polemButton.strokeColor = androidx.core.content.res.ResourcesCompat.getColorStateList(resources, R.color.thc_weed_outline, theme) + } + // Default CBD strings are already in layout (or we can set them explicitely) val dialog = MaterialAlertDialogBuilder(this) .setView(dialogView) @@ -711,11 +828,27 @@ class MainActivity : AppCompatActivity() { val label = getString(type.labelRes) val suffix = getString(R.string.infusion_note_suffix, label) val entry = "${type.icon} ${getCurrentTimestamp()}$suffix" - registerIntake(entry, getString(type.feedbackRes)) + // Infusión (weed/polen) SIEMPRE suma a THC + registerThcIntake(entry, getString(type.feedbackRes)) + } + + /** Registra una toma que siempre va al contador THC (para infusiones) */ + private fun registerThcIntake(entry: String, feedbackMessage: String) { + thcCount++ + updateDisplay() + appendEntryToTodayNote(entry) + saveData() + animateCounter(1.1f) + showFeedback("$feedbackMessage (THC)", false) } private fun registerIntake(entry: String, feedbackMessage: String) { - currentCount++ + val isThc = Prefs.getSubstanceType(this) == "THC" + if (isThc) { + thcCount++ + } else { + cbdCount++ + } updateDisplay() appendEntryToTodayNote(entry) saveData() @@ -796,12 +929,19 @@ class MainActivity : AppCompatActivity() { } private fun openStatsCalendar() { - startActivity(Intent(this, StatsActivity::class.java)) + startActivity(Intent(this, DashboardActivity::class.java)) } } // Data class -data class HistoryItem(val date: String, val count: Int, val dateObject: Date) +data class HistoryItem( + val date: String, + val cbdCount: Int, + val thcCount: Int, + val dateObject: Date +) { + val totalCount: Int get() = cbdCount + thcCount +} // Adapter class ImprovedHistoryAdapter( @@ -839,7 +979,9 @@ class ImprovedHistoryAdapter( class ItemViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { val dateText: TextView = itemView.findViewById(R.id.historyDate) - val countText: TextView = itemView.findViewById(R.id.historyCount) + val countText: TextView = itemView.findViewById(R.id.historyCount) // Oculto + val cbdChip: TextView = itemView.findViewById(R.id.cbdChip) + val thcChip: TextView = itemView.findViewById(R.id.thcChip) val emojiText: TextView = itemView.findViewById(R.id.historyEmoji) val progressBar: View = itemView.findViewById(R.id.progressBar) val noteBadge: TextView? = itemView.findViewById(R.id.noteBadge) @@ -866,48 +1008,84 @@ class ImprovedHistoryAdapter( val dayFormat = SimpleDateFormat("EEEE dd", Locale("es", "ES")) holder.dateText.text = dayFormat.format(item.dateObject) .replaceFirstChar { it.uppercase() } - holder.countText.text = "${item.count} CBD" + // Mostrar chips según los datos + if (item.cbdCount > 0) { + holder.cbdChip.text = "${item.cbdCount} CBD" + holder.cbdChip.visibility = View.VISIBLE + } else { + holder.cbdChip.visibility = View.GONE + } + + if (item.thcCount > 0) { + holder.thcChip.text = "${item.thcCount} THC" + holder.thcChip.visibility = View.VISIBLE + } else { + holder.thcChip.visibility = View.GONE + } + + // Si ambos son 0, mostrar chip CBD con 0 + if (item.cbdCount == 0 && item.thcCount == 0) { + holder.cbdChip.text = "0 CBD" + holder.cbdChip.visibility = View.VISIBLE + } + + val total = item.totalCount holder.emojiText.text = when { - item.count == 0 -> "😌" - item.count <= 2 -> "🙂" - item.count <= 4 -> "😄" - item.count <= 5 -> "🫠" - item.count <= 6 -> "🤔" - item.count <= 7 -> "🙄" - item.count <= 8 -> "😶‍🌫️" - item.count <= 9 -> "🫡" - item.count <= 10 -> "🫥" - item.count <= 11 -> "⛔️" + total == 0 -> "😌" + total <= 2 -> "🙂" + total <= 4 -> "😄" + total <= 5 -> "🫠" + total <= 6 -> "🤔" + total <= 7 -> "🙄" + total <= 8 -> "😶‍🌫️" + total <= 9 -> "🫡" + total <= 10 -> "🫥" + total <= 11 -> "⛔️" else -> "💀" } - // Barra de progreso (como ya tenías) + // Barra de progreso basada en el total val maxWidth = holder.itemView.width - val progress = minOf(item.count / 10f, 1f) + val progress = minOf(total / 10f, 1f) val layoutParams = holder.progressBar.layoutParams layoutParams.width = (maxWidth * progress).toInt() holder.progressBar.layoutParams = layoutParams - val color = when { - item.count == 0 -> R.color.green_safe - item.count <= 3 -> R.color.yellow_warning - item.count <= 6 -> R.color.orange_danger - else -> R.color.red_critical + + // Color de la barra: del mayor, o verde si es 0 + val barColor = when { + total == 0 -> R.color.green_safe + item.thcCount > item.cbdCount -> { + // THC es mayor, usar escala verde + when { + total <= 4 -> R.color.thc_primary_light + total <= 6 -> R.color.orange_danger + else -> R.color.red_critical + } + } + else -> { + // CBD es mayor o igual, usar escala azul + when { + total <= 4 -> R.color.primary_light + total <= 6 -> R.color.orange_danger + else -> R.color.red_critical + } + } } holder.progressBar.setBackgroundColor( - ContextCompat.getColor(holder.itemView.context, color) + ContextCompat.getColor(holder.itemView.context, barColor) ) - // --- NUEVO: badge de nota visible si existe nota para ese día + // Badge de nota visible si existe nota para ese día val ctx = holder.itemView.context holder.noteBadge?.visibility = if (Prefs.hasNote(ctx, item.date)) View.VISIBLE else View.GONE - // --- NUEVO: badge de audio visible si existe audio para ese día + // Badge de audio visible si existe audio para ese día holder.audioBadge?.visibility = if (Prefs.hasAudio(ctx, item.date)) View.VISIBLE else View.GONE - // --- NUEVO: clicks para abrir el modal + // Clicks para abrir el modal holder.itemView.setOnClickListener { onDayClick(item.date) } holder.noteBadge?.setOnClickListener { onDayClick(item.date) } holder.audioBadge?.setOnClickListener { onDayClick(item.date) } diff --git a/app/src/main/java/com/d4vram/cbdcounter/Prefs.kt b/app/src/main/java/com/d4vram/cbdcounter/Prefs.kt index a1bdc60..1ad3352 100644 --- a/app/src/main/java/com/d4vram/cbdcounter/Prefs.kt +++ b/app/src/main/java/com/d4vram/cbdcounter/Prefs.kt @@ -6,11 +6,14 @@ import java.io.File object Prefs { private const val PREFS_NAME = "CBDCounter" - + // Constantes de claves para evitar errores tipográficos - const val KEY_COUNT_PREFIX = "count_" + const val KEY_COUNT_PREFIX = "count_" // Legacy, para migración + const val KEY_COUNT_CBD_PREFIX = "count_cbd_" + const val KEY_COUNT_THC_PREFIX = "count_thc_" const val KEY_NOTE_PREFIX = "NOTE_" const val KEY_DARK_MODE = "dark_mode" + private const val KEY_MIGRATION_V14_DONE = "migration_v1.4_done" // función privada para acceder a las SharedPreferences private fun prefs(ctx: Context): SharedPreferences = @@ -37,4 +40,106 @@ object Prefs { val audioFile = File(ctx.filesDir, "audios/audio_$date.mp3") return audioFile.exists() } + + // ---- Sustancia (CBD vs THC) - Modo activo ---- + fun getSubstanceType(ctx: Context): String = + prefs(ctx).getString("substance_type", "CBD") ?: "CBD" + + fun setSubstanceType(ctx: Context, type: String) { + prefs(ctx).edit().putString("substance_type", type).apply() + } + + // ---- Contadores separados CBD/THC ---- + fun getCbdCount(ctx: Context, date: String): Int = + prefs(ctx).getInt("${KEY_COUNT_CBD_PREFIX}$date", 0) + + fun setCbdCount(ctx: Context, date: String, count: Int) { + prefs(ctx).edit().putInt("${KEY_COUNT_CBD_PREFIX}$date", count).apply() + } + + fun getThcCount(ctx: Context, date: String): Int = + prefs(ctx).getInt("${KEY_COUNT_THC_PREFIX}$date", 0) + + fun setThcCount(ctx: Context, date: String, count: Int) { + prefs(ctx).edit().putInt("${KEY_COUNT_THC_PREFIX}$date", count).apply() + } + + fun getTotalCount(ctx: Context, date: String): Int = + getCbdCount(ctx, date) + getThcCount(ctx, date) + + /** Incrementa el contador del modo activo y devuelve el nuevo valor */ + fun incrementActiveCount(ctx: Context, date: String): Int { + val isThc = getSubstanceType(ctx) == "THC" + return if (isThc) { + val newCount = getThcCount(ctx, date) + 1 + setThcCount(ctx, date, newCount) + newCount + } else { + val newCount = getCbdCount(ctx, date) + 1 + setCbdCount(ctx, date, newCount) + newCount + } + } + + /** Decrementa el contador del modo activo (mínimo 0) y devuelve el nuevo valor */ + fun decrementActiveCount(ctx: Context, date: String): Int { + val isThc = getSubstanceType(ctx) == "THC" + return if (isThc) { + val newCount = maxOf(0, getThcCount(ctx, date) - 1) + setThcCount(ctx, date, newCount) + newCount + } else { + val newCount = maxOf(0, getCbdCount(ctx, date) - 1) + setCbdCount(ctx, date, newCount) + newCount + } + } + + /** Obtiene el conteo del modo activo */ + fun getActiveCount(ctx: Context, date: String): Int { + val isThc = getSubstanceType(ctx) == "THC" + return if (isThc) getThcCount(ctx, date) else getCbdCount(ctx, date) + } + + // ---- Migración v1.4 ---- + fun migrateToV14IfNeeded(ctx: Context) { + val prefs = prefs(ctx) + if (prefs.getBoolean(KEY_MIGRATION_V14_DONE, false)) return + + val editor = prefs.edit() + val allEntries = prefs.all + + // Migrar count_* → count_cbd_* + allEntries.keys + .filter { it.startsWith(KEY_COUNT_PREFIX) && !it.startsWith(KEY_COUNT_CBD_PREFIX) && !it.startsWith(KEY_COUNT_THC_PREFIX) } + .forEach { oldKey -> + val date = oldKey.removePrefix(KEY_COUNT_PREFIX) + val count = allEntries[oldKey] as? Int ?: 0 + editor.putInt("${KEY_COUNT_CBD_PREFIX}$date", count) + editor.remove(oldKey) + } + + // Eliminar claves substance_* obsoletas + allEntries.keys + .filter { it.startsWith("substance_") && it != "substance_type" } + .forEach { editor.remove(it) } + + editor.putBoolean(KEY_MIGRATION_V14_DONE, true) + editor.apply() + } + + /** Obtiene todas las fechas que tienen datos (para historial) */ + fun getAllDatesWithData(ctx: Context): Set { + val prefs = prefs(ctx) + val dates = mutableSetOf() + + prefs.all.keys.forEach { key -> + when { + key.startsWith(KEY_COUNT_CBD_PREFIX) -> dates.add(key.removePrefix(KEY_COUNT_CBD_PREFIX)) + key.startsWith(KEY_COUNT_THC_PREFIX) -> dates.add(key.removePrefix(KEY_COUNT_THC_PREFIX)) + key.startsWith(KEY_NOTE_PREFIX) -> dates.add(key.removePrefix(KEY_NOTE_PREFIX)) + } + } + return dates + } } diff --git a/app/src/main/java/com/d4vram/cbdcounter/SettingsActivity.kt b/app/src/main/java/com/d4vram/cbdcounter/SettingsActivity.kt index 822a19c..c0e5015 100644 --- a/app/src/main/java/com/d4vram/cbdcounter/SettingsActivity.kt +++ b/app/src/main/java/com/d4vram/cbdcounter/SettingsActivity.kt @@ -2,49 +2,130 @@ package com.d4vram.cbdcounter import android.content.Intent import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView import android.widget.Toast +import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.ContextCompat +import androidx.core.content.FileProvider +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.appbar.MaterialToolbar import com.google.android.material.button.MaterialButton +import com.google.android.material.button.MaterialButtonToggleGroup import com.google.android.material.switchmaterial.SwitchMaterial import java.io.File import java.io.FileInputStream import java.io.FileOutputStream +import java.text.SimpleDateFormat +import java.util.* import java.util.zip.ZipEntry import java.util.zip.ZipOutputStream -import javax.crypto.Cipher -import javax.crypto.CipherOutputStream -import javax.crypto.spec.SecretKeySpec class SettingsActivity : AppCompatActivity() { - private lateinit var exportAudioZipButton: MaterialButton - private lateinit var backupCsvButton: MaterialButton - private lateinit var autoBackupSwitch: SwitchMaterial + private lateinit var recyclerView: RecyclerView + private lateinit var adapter: EmojiRangeAdapter + + // Lista de rangos con sus emojis por defecto + private val emojiRanges = listOf( + EmojiRange(0, "😌", R.color.green_safe, "0"), + EmojiRange(1, "🙂", R.color.green_safe, "1-2"), + EmojiRange(3, "😄", R.color.yellow_warning, "3-4"), + EmojiRange(5, "🫠", R.color.yellow_warning, "5"), + EmojiRange(6, "🤔", R.color.orange_danger, "6"), + EmojiRange(7, "🙄", R.color.orange_danger, "7"), + EmojiRange(8, "😶‍🌫️", R.color.orange_danger, "8"), + EmojiRange(9, "🫡", R.color.red_critical, "9"), + EmojiRange(10, "🫥", R.color.red_critical, "10"), + EmojiRange(11, "⛔️", R.color.red_critical, "11"), + EmojiRange(12, "💀", R.color.primary_purple, "12+") + ) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_settings) - exportAudioZipButton = findViewById(R.id.exportAudioZipButton) - backupCsvButton = findViewById(R.id.backupCsvButton) - autoBackupSwitch = findViewById(R.id.autoBackupSwitch) + // Forzar status bar con color del toolbar + window.statusBarColor = getColor(R.color.gradient_start) - // Load auto backup preference - autoBackupSwitch.isChecked = getSharedPreferences("CBDCounter", MODE_PRIVATE) - .getBoolean("auto_backup", false) + // Toolbar + val toolbar = findViewById(R.id.settingsToolbar) + toolbar.setNavigationOnClickListener { finish() } + + setupSubstanceToggle() + setupBackupSection() + setupEmojiSection() + } - exportAudioZipButton.setOnClickListener { - exportAudiosZip() + // ======================================== + // SECCIÓN: Tipo de Sustancia (CBD/THC) + // ======================================== + private fun setupSubstanceToggle() { + val substanceToggle = findViewById(R.id.substanceToggleGroup) + val currentSubstance = Prefs.getSubstanceType(this) + + if (currentSubstance == "THC") { + substanceToggle.check(R.id.btnThc) + } else { + substanceToggle.check(R.id.btnCbd) } - backupCsvButton.setOnClickListener { - backupCsvManual() + substanceToggle.addOnButtonCheckedListener { _, checkedId, isChecked -> + if (isChecked) { + val type = if (checkedId == R.id.btnThc) "THC" else "CBD" + Prefs.setSubstanceType(this, type) + Toast.makeText(this, "Modo $type activado", Toast.LENGTH_SHORT).show() + } } + } + + // ======================================== + // SECCIÓN: Backup + // ======================================== + private fun setupBackupSection() { + val autoBackupSwitch = findViewById(R.id.switchBackupAuto) + val btnBackupCsv = findViewById(R.id.btnBackupCsv) + val btnExportAudios = findViewById(R.id.btnExportAudios) + + // Cargar preferencia de auto backup + autoBackupSwitch.isChecked = getSharedPreferences("CBDCounter", MODE_PRIVATE) + .getBoolean("auto_backup", false) autoBackupSwitch.setOnCheckedChangeListener { _, isChecked -> getSharedPreferences("CBDCounter", MODE_PRIVATE).edit() .putBoolean("auto_backup", isChecked).apply() - Toast.makeText(this, "Backup automático ${if (isChecked) "activado" else "desactivado"}", Toast.LENGTH_SHORT).show() + Toast.makeText( + this, + "Backup automático ${if (isChecked) "activado" else "desactivado"}", + Toast.LENGTH_SHORT + ).show() + } + + btnBackupCsv.setOnClickListener { exportCsvBackup() } + btnExportAudios.setOnClickListener { exportAudiosZip() } + } + + private fun exportCsvBackup() { + val csvContent = buildCsvContent() + if (csvContent.isBlank()) { + Toast.makeText(this, "No hay datos para exportar", Toast.LENGTH_SHORT).show() + return + } + + val backupDir = File(cacheDir, "backups").apply { if (!exists()) mkdirs() } + val fileName = "cbdcounter_backup_${SimpleDateFormat("yyyyMMdd_HHmm", Locale.getDefault()).format(Date())}.csv" + val file = File(backupDir, fileName) + + try { + file.writeText(csvContent, Charsets.UTF_8) + shareFile(file, "text/csv", "Compartir Backup CSV") + Toast.makeText(this, "Backup CSV exportado", Toast.LENGTH_SHORT).show() + } catch (e: Exception) { + Toast.makeText(this, "Error: ${e.message}", Toast.LENGTH_SHORT).show() } } @@ -58,7 +139,6 @@ class SettingsActivity : AppCompatActivity() { val zipFile = File(cacheDir, "audios_export.zip") try { ZipOutputStream(FileOutputStream(zipFile)).use { zos -> - // For simplicity, no encryption yet; add dialog for password later audioDir.listFiles()?.forEach { file -> FileInputStream(file).use { fis -> zos.putNextEntry(ZipEntry(file.name)) @@ -67,46 +147,21 @@ class SettingsActivity : AppCompatActivity() { } } } - - // Share the ZIP - val uri = androidx.core.content.FileProvider.getUriForFile(this, "$packageName.fileprovider", zipFile) - val shareIntent = Intent(Intent.ACTION_SEND).apply { - type = "application/zip" - putExtra(Intent.EXTRA_STREAM, uri) - addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - } - startActivity(Intent.createChooser(shareIntent, "Compartir ZIP de audios")) - Toast.makeText(this, "ZIP exportado", Toast.LENGTH_SHORT).show() + shareFile(zipFile, "application/zip", "Compartir ZIP de audios") + Toast.makeText(this, "Audios exportados", Toast.LENGTH_SHORT).show() } catch (e: Exception) { - Toast.makeText(this, "Error al exportar: ${e.message}", Toast.LENGTH_SHORT).show() + Toast.makeText(this, "Error: ${e.message}", Toast.LENGTH_SHORT).show() } } - private fun backupCsvManual() { - // Reuse logic from MainActivity - val csvContent = buildCsvContent() - if (csvContent.isBlank()) { - Toast.makeText(this, "No hay datos para backup", Toast.LENGTH_SHORT).show() - return - } - - val backupDir = File(cacheDir, "backups").apply { if (!exists()) mkdirs() } - val fileName = "backup_" + java.text.SimpleDateFormat("yyyyMMdd_HHmm", java.util.Locale.getDefault()).format(java.util.Date()) + ".csv" - val file = File(backupDir, fileName) - - try { - file.writeText(csvContent, Charsets.UTF_8) - val uri = androidx.core.content.FileProvider.getUriForFile(this, "$packageName.fileprovider", file) - val shareIntent = Intent(Intent.ACTION_SEND).apply { - type = "text/csv" - putExtra(Intent.EXTRA_STREAM, uri) - addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - } - startActivity(Intent.createChooser(shareIntent, "Compartir Backup CSV")) - Toast.makeText(this, "Backup CSV exportado", Toast.LENGTH_SHORT).show() - } catch (e: Exception) { - Toast.makeText(this, "Error al crear backup: ${e.message}", Toast.LENGTH_SHORT).show() + private fun shareFile(file: File, mimeType: String, title: String) { + val uri = FileProvider.getUriForFile(this, "$packageName.fileprovider", file) + val shareIntent = Intent(Intent.ACTION_SEND).apply { + type = mimeType + putExtra(Intent.EXTRA_STREAM, uri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) } + startActivity(Intent.createChooser(shareIntent, title)) } private fun buildCsvContent(): String { @@ -123,7 +178,7 @@ class SettingsActivity : AppCompatActivity() { } if (dates.isEmpty()) return "" - val dateFormat = java.text.SimpleDateFormat("dd/MM/yyyy", java.util.Locale.getDefault()) + val dateFormat = SimpleDateFormat("dd/MM/yyyy", Locale.getDefault()) val sortedDates = dates.mapNotNull { dateString -> kotlin.runCatching { dateFormat.parse(dateString) }.getOrNull()?.let { parsed -> dateString to parsed @@ -146,15 +201,144 @@ class SettingsActivity : AppCompatActivity() { private fun escapeCsvField(value: String): String { if (value.isEmpty()) return "" - val builder = StringBuilder() - value.forEach { char -> - when (char) { - '\\' -> builder.append("\\\\") - '\n' -> builder.append("\\n") - ',' -> builder.append("\\,") - else -> builder.append(char) + return value.replace("\\", "\\\\").replace("\n", "\\n").replace(",", "\\,") + } + + // ======================================== + // SECCIÓN: Personalizar Emojis + // ======================================== + private fun setupEmojiSection() { + recyclerView = findViewById(R.id.emojiRangesRecycler) + recyclerView.layoutManager = LinearLayoutManager(this) + + val currentEmojis = loadCustomEmojis() + adapter = EmojiRangeAdapter(emojiRanges, currentEmojis) { range, newEmoji -> + saveCustomEmoji(range.count, newEmoji) + } + recyclerView.adapter = adapter + + findViewById(R.id.resetEmojiButton).setOnClickListener { + showResetConfirmationDialog() + } + } + + private fun loadCustomEmojis(): Map { + val prefs = getSharedPreferences("emoji_prefs", MODE_PRIVATE) + val customEmojis = mutableMapOf() + for (range in emojiRanges) { + prefs.getString("emoji_${range.count}", null)?.let { + customEmojis[range.count] = it } } - return builder.toString() + return customEmojis + } + + private fun saveCustomEmoji(count: Int, emoji: String) { + getSharedPreferences("emoji_prefs", MODE_PRIVATE) + .edit().putString("emoji_$count", emoji).apply() + } + + private fun resetToDefaults() { + getSharedPreferences("emoji_prefs", MODE_PRIVATE).edit().clear().apply() + adapter.resetToDefaults() + Toast.makeText(this, "Emojis restaurados", Toast.LENGTH_SHORT).show() + } + + private fun showResetConfirmationDialog() { + AlertDialog.Builder(this) + .setTitle("Restaurar emojis") + .setMessage("¿Restaurar todos los emojis a sus valores por defecto?") + .setPositiveButton("Sí") { _, _ -> resetToDefaults() } + .setNegativeButton("Cancelar", null) + .show() + } + + private fun showEmojiPicker(currentEmoji: String, onEmojiSelected: (String) -> Unit) { + val emojis = listOf( + "😌", "🙂", "😊", "😀", "😃", "😄", "😁", "😆", "😅", "🤣", + "😂", "🙃", "😉", "😇", "🤩", "☺️", "🥲", "😋", "😛", "😜", "🤪", "😝", + "🤔", "🤨", "😐", "😑", "😶", "🙄", "😣", "😥", "😮", "😯", "😪", "😫", "🥱", "😴", "🤤", + "🫠", "😵", "😵‍💫", "🤯", "🥴", "😲", + "🫡", "😬", "🫨", "🫥", + "😞", "😔", "😟", "😕", "🙁", "☹️", "😰", "😨", "😧", "😦", "😈", + "👿", "💀", "☠️", "👻", "👽", "👾", + "👍", "👎", "🤞", "✌️", "👌", "🤌", "🤏", "✋", "🤚", + "🌿", "🍀", "🌱", "🌾", "🪴", "🍃", + "⚠️", "🚫", "⛔️", "🔞", "📵", "🔕", "❌", "⭕️", "❗️", "❓", + "🟢", "🟡", "🟠", "🔴", "🟣", "🔵", "🟤", "⚫️", "⚪️", + "💚", "💛", "🧡", "❤️", "💜", "💙", "🖤", "🤍", "🤎", "💯", + "💥", "💫", "⭐️", "🌟", "✨", "⚡️", "🔥" + ) + + val emojiArray = emojis.toTypedArray() + var selectedIndex = emojis.indexOf(currentEmoji).takeIf { it >= 0 } ?: 0 + + AlertDialog.Builder(this) + .setTitle("Selecciona un emoji") + .setSingleChoiceItems(emojiArray, selectedIndex) { _, which -> + selectedIndex = which + } + .setPositiveButton("Aceptar") { _, _ -> + onEmojiSelected(emojis[selectedIndex]) + } + .setNegativeButton("Cancelar", null) + .show() + } + + // ======================================== + // Clases de datos y Adapter + // ======================================== + data class EmojiRange( + val count: Int, + val defaultEmoji: String, + val colorRes: Int, + val rangeText: String + ) + + inner class EmojiRangeAdapter( + private val ranges: List, + private var customEmojis: Map, + private val onEmojiChanged: (EmojiRange, String) -> Unit + ) : RecyclerView.Adapter() { + + inner class ViewHolder(view: View) : RecyclerView.ViewHolder(view) { + val colorIndicator: View = view.findViewById(R.id.colorIndicator) + val rangeText: TextView = view.findViewById(R.id.rangeText) + val emojiText: TextView = view.findViewById(R.id.emojiText) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + val view = LayoutInflater.from(parent.context) + .inflate(R.layout.item_emoji_range, parent, false) + return ViewHolder(view) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + val range = ranges[position] + val currentEmoji = customEmojis[range.count] ?: range.defaultEmoji + + holder.rangeText.text = range.rangeText + holder.emojiText.text = currentEmoji + holder.colorIndicator.setBackgroundColor( + ContextCompat.getColor(holder.itemView.context, range.colorRes) + ) + + holder.emojiText.setOnClickListener { + showEmojiPicker(currentEmoji) { newEmoji -> + val mutableCustom = customEmojis.toMutableMap() + mutableCustom[range.count] = newEmoji + customEmojis = mutableCustom + notifyItemChanged(position) + onEmojiChanged(range, newEmoji) + } + } + } + + override fun getItemCount() = ranges.size + + fun resetToDefaults() { + customEmojis = emptyMap() + notifyDataSetChanged() + } } -} \ No newline at end of file +} diff --git a/app/src/main/res/drawable/bg_chip_cbd.xml b/app/src/main/res/drawable/bg_chip_cbd.xml new file mode 100644 index 0000000..3a64556 --- /dev/null +++ b/app/src/main/res/drawable/bg_chip_cbd.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/app/src/main/res/drawable/bg_chip_thc.xml b/app/src/main/res/drawable/bg_chip_thc.xml new file mode 100644 index 0000000..54ca5f7 --- /dev/null +++ b/app/src/main/res/drawable/bg_chip_thc.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/app/src/main/res/drawable/bg_counter_cbd.xml b/app/src/main/res/drawable/bg_counter_cbd.xml new file mode 100644 index 0000000..ad33198 --- /dev/null +++ b/app/src/main/res/drawable/bg_counter_cbd.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/app/src/main/res/drawable/bg_counter_thc.xml b/app/src/main/res/drawable/bg_counter_thc.xml new file mode 100644 index 0000000..bc7438a --- /dev/null +++ b/app/src/main/res/drawable/bg_counter_thc.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/app/src/main/res/drawable/bg_rounded_card.xml b/app/src/main/res/drawable/bg_rounded_card.xml new file mode 100644 index 0000000..3ca0a4a --- /dev/null +++ b/app/src/main/res/drawable/bg_rounded_card.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_settings.xml b/app/src/main/res/drawable/ic_settings.xml new file mode 100644 index 0000000..2ea0fbc --- /dev/null +++ b/app/src/main/res/drawable/ic_settings.xml @@ -0,0 +1,11 @@ + + + + diff --git a/app/src/main/res/layout/activity_dashboard.xml b/app/src/main/res/layout/activity_dashboard.xml new file mode 100644 index 0000000..b175319 --- /dev/null +++ b/app/src/main/res/layout/activity_dashboard.xml @@ -0,0 +1,312 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/activity_emoji_settings.xml b/app/src/main/res/layout/activity_emoji_settings.xml index 5190c66..360a52a 100644 --- a/app/src/main/res/layout/activity_emoji_settings.xml +++ b/app/src/main/res/layout/activity_emoji_settings.xml @@ -1,68 +1,203 @@ - + android:background="@color/background" + android:fitsSystemWindows="true"> - - - - - - - - - - - - - + android:fitsSystemWindows="true"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/activity_evolution.xml b/app/src/main/res/layout/activity_evolution.xml index 4f031d4..b4b7518 100644 --- a/app/src/main/res/layout/activity_evolution.xml +++ b/app/src/main/res/layout/activity_evolution.xml @@ -3,7 +3,8 @@ xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="match_parent" - android:background="@color/background"> + android:background="@color/background" + android:fitsSystemWindows="true"> + + android:gravity="center" + android:padding="12dp"> - + - + android:orientation="horizontal" + android:gravity="center"> + + + + + + + + + + + + + + + + + + + + + + + android:layout_marginTop="8dp" /> + + + diff --git a/app/src/main/res/layout/activity_settings.xml b/app/src/main/res/layout/activity_settings.xml index cf97f2b..abc33ad 100644 --- a/app/src/main/res/layout/activity_settings.xml +++ b/app/src/main/res/layout/activity_settings.xml @@ -1,50 +1,188 @@ - + android:fitsSystemWindows="true"> - - - - - - - + android:fitsSystemWindows="true"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - \ No newline at end of file + diff --git a/app/src/main/res/layout/activity_stats.xml b/app/src/main/res/layout/activity_stats.xml index d3cfda9..4df5683 100644 --- a/app/src/main/res/layout/activity_stats.xml +++ b/app/src/main/res/layout/activity_stats.xml @@ -3,7 +3,8 @@ xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="match_parent" - android:background="@color/background"> + android:background="@color/background" + android:fitsSystemWindows="true"> + app:constraint_referenced_ids="countersContainer"/> - + + + + + + + + + + + + app:layout_constraintTop_toTopOf="parent" /> diff --git a/app/src/main/res/menu/dashboard_menu.xml b/app/src/main/res/menu/dashboard_menu.xml new file mode 100644 index 0000000..fd56548 --- /dev/null +++ b/app/src/main/res/menu/dashboard_menu.xml @@ -0,0 +1,9 @@ + + + + diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml index a08dd0b..61f722c 100644 --- a/app/src/main/res/values-night/themes.xml +++ b/app/src/main/res/values-night/themes.xml @@ -15,8 +15,6 @@ @color/red_critical - @android:color/transparent @color/surface - false diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 6c290cf..3a793f8 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -58,4 +58,18 @@ #667eea #6B7280 + + #4CAF50 + #388E3C + #81C784 + #A5D6A7 + #E8F5E9 + #2E7D32 + #4CAF50 + #81C784 + + + #E3F2FD + #1565C0 + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f5b58ae..cfbfbd1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -46,4 +46,41 @@ Anterior Siguiente + + Estadísticas + Hoy + Semana + Promedio + Racha + 📅 Ver calendario completo + Patrones + Día más activo: %1$s + Mejor día: %1$s (%2$d) + + + Tipo de Sustancia + THC + THC agregado + THC restado + THC con weed agregado + THC con polen agregado + ¿Qué estás fumando? + Etiqueta la toma con vibra 420. + + + Ajustes + Copia de Seguridad + Backup automático + 📄 Exportar datos (CSV) + 🎙️ Exportar audios (ZIP) + 💾 Crear backup ahora + 📥 Restaurar backup + Cifrar con contraseña (AES-256) + Backup creado con éxito + Datos restaurados correctamente + Introduce contraseña del backup + Personalizar Emojis + Toca un emoji para cambiarlo. Se actualizará en la pantalla principal y en el widget. + Restaurar emojis por defecto + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 89ad6a5..20cfb3b 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -15,8 +15,6 @@ @color/red_critical - @android:color/transparent @color/surface - false diff --git a/docs/index.md b/docs/index.md deleted file mode 100644 index 84b7ab4..0000000 --- a/docs/index.md +++ /dev/null @@ -1,86 +0,0 @@ -# CBDCounter - Documentación Oficial - -
- -🌿 **Tracking Personal de CBD con Total Privacidad** - -[![Versión](https://img.shields.io/badge/versión-1.1-green.svg)](https://github.com/tu-usuario/CBDcounter2/releases) -[![Android](https://img.shields.io/badge/Android-7.0%2B-blue.svg)](https://developer.android.com) -[![Licencia](https://img.shields.io/badge/licencia-GPL--3.0-orange.svg)](LICENSE) - -
- ---- - -## 📱 Acerca de CBDCounter - -CBDCounter es una aplicación Android intuitiva y privada diseñada para el seguimiento personal de consumo de CBD. Con un enfoque en la privacidad absoluta, todos tus datos permanecen en tu dispositivo. - -**Características principales:** -- 📊 Contador diario con estadísticas detalladas -- 🏠 Widget de pantalla principal -- 📅 Calendario visual con emojis personalizables -- 📝 Sistema de notas con timestamps -- 💾 Exportación/Importación CSV -- 🔒 100% privado - cero recopilación de datos -- 🌙 Tema oscuro/claro - ---- - -## 📋 Documentación - -### Información Legal - -- **[Política de Privacidad](privacy-policy.md)** - Cómo manejamos (o mejor dicho, NO manejamos) tus datos - -### Soporte - -- **Email de contacto:** d4vram369@gmail.com -- **Tiempo de respuesta:** ~7 días hábiles - ---- - -## 🔒 Privacidad - -**Tu privacidad es nuestra prioridad #1:** - -✅ CERO recopilación de datos -✅ TODO se guarda localmente -✅ NO hay servidores externos -✅ NO usamos analytics -✅ NO compartimos nada con terceros - ---- - -## ⚠️ Descargo de Responsabilidad - -CBDCounter es una herramienta de tracking personal y **NO** constituye: -- Dispositivo médico -- Consejo médico profesional -- Promoción de consumo de sustancias -- Facilitación de compra/venta - -Consulta siempre con un profesional de la salud. - ---- - -## 🆓 Licencia - -Este proyecto está licenciado bajo GPL-3.0. Ver el archivo [LICENSE](../LICENSE) para más detalles. - ---- - -## 👨‍💻 Desarrollador - -**D4vRAM** -🇮🇨 Gran Canaria, España - ---- - -
- -© 2025 D4vRAM. Todos los derechos reservados. - -Desarrollado con ❤️ desde Islas Canarias 🇮🇨 (España🇪 🇪🇸) - -
diff --git a/docs/privacy-policy.md b/docs/privacy-policy.md deleted file mode 100644 index 383e701..0000000 --- a/docs/privacy-policy.md +++ /dev/null @@ -1,229 +0,0 @@ -# Política de Privacidad de CBDCounter - -**Última actualización:** 10 de noviembre de 2025 - -**Desarrollador:** D4vRAM -**Contacto:** d4vram369@gmail.com - ---- - -## 1. Introducción - -CBDCounter ("la Aplicación") es una herramienta de seguimiento personal desarrollada por D4vRAM. Esta Política de Privacidad describe cómo manejamos la información en relación con el uso de la Aplicación. - -**En resumen:** Esta aplicación NO recopila, transmite, almacena en servidores ni comparte ningún dato personal. - ---- - -## 2. Información que Recopilamos - -**CBDCounter NO recopila ningún dato personal de los usuarios.** - -La Aplicación no: -- ❌ Recopila nombres, correos electrónicos o información de contacto -- ❌ Recopila datos de ubicación -- ❌ Accede a tu lista de contactos, fotos o archivos -- ❌ Rastrea tu actividad fuera de la app -- ❌ Utiliza cookies o tecnologías de seguimiento -- ❌ Recopila identificadores de dispositivo únicos -- ❌ Transmite datos a servidores externos - ---- - -## 3. Datos Almacenados Localmente - -Todos los datos que generes usando CBDCounter se almacenan **exclusivamente en tu dispositivo** mediante la tecnología SharedPreferences de Android: - -### Datos almacenados localmente: -- **Contadores diarios:** Número de consumos registrados por fecha -- **Notas personales:** Texto que escribas para cada día -- **Emojis personalizados:** Configuraciones visuales que hayas modificado -- **Preferencias de tema:** Modo oscuro/claro -- **Estado del disclaimer:** Indicador de que aceptaste el aviso legal - -### Características importantes: -✅ Estos datos **NUNCA** salen de tu dispositivo -✅ NO se sincronizan con ningún servidor -✅ NO son accesibles por el desarrollador -✅ Son eliminados automáticamente si desinstalas la app - ---- - -## 4. Función de Exportación CSV - -La Aplicación permite exportar tus datos a un archivo CSV para crear copias de seguridad personales. - -**Importante:** -- Tú controlas este archivo exportado -- El archivo se guarda en tu dispositivo -- Puedes compartirlo manualmente mediante las opciones estándar de Android -- El desarrollador NO tiene acceso a este archivo -- Tú eres responsable de cómo compartes o almacenas este archivo - ---- - -## 5. Permisos de Android - -La Aplicación solicita los siguientes permisos: - -### `RECEIVE_BOOT_COMPLETED` -**Propósito:** Restaurar el widget de pantalla principal después de reiniciar el dispositivo. -**Uso:** Solo se utiliza para actualizar el widget, no para rastrear ni monitorear actividad. - -**Ningún otro permiso es solicitado o utilizado.** - ---- - -## 6. Servicios de Terceros - -CBDCounter **NO utiliza**: -- ❌ Servicios de analytics (Google Analytics, Firebase, etc.) -- ❌ Redes publicitarias -- ❌ Sistemas de tracking o métricas -- ❌ SDKs de terceros (excepto AndroidX y Material Design de Google, que son librerías estándar de UI sin capacidad de recopilación de datos) - ---- - -## 7. Seguridad de los Datos - -Dado que todos los datos se almacenan localmente en tu dispositivo: - -- La seguridad depende de la protección de tu dispositivo (PIN, huella, etc.) -- En dispositivos rooteados, aplicaciones con permisos de root podrían acceder a los datos -- Recomendamos usar las funciones de seguridad nativas de Android - -**Nota:** Si tu dispositivo es compartido con otras personas, ellas podrían acceder a la Aplicación y ver tus datos. - ---- - -## 8. Privacidad de Menores - -CBDCounter no está dirigida a menores de 13 años. Dado que no recopilamos ningún dato, no recopilamos intencionalmente información de niños menores de 13 años. - ---- - -## 9. Cambios a esta Política - -Podemos actualizar esta Política de Privacidad ocasionalmente. Te notificaremos cualquier cambio publicando la nueva Política en esta página y actualizando la fecha de "Última actualización". - -**Es tu responsabilidad revisar esta Política periódicamente.** El uso continuado de la Aplicación después de cambios constituye aceptación de dichos cambios. - ---- - -## 10. Limitaciones y Descargo de Responsabilidad - -### CBDCounter NO es un dispositivo médico - -- Esta Aplicación NO proporciona consejo médico, diagnóstico o tratamiento -- NO está destinada a sustituir la consulta con un profesional de la salud -- El contenido es solo para fines informativos y de seguimiento personal -- Siempre consulta con un médico u otro profesional de la salud calificado sobre cualquier pregunta relacionada con tu salud - -### Uso de la Aplicación - -- El uso de CBDCounter es bajo tu propio riesgo -- La Aplicación se proporciona "tal cual" sin garantías de ningún tipo -- El desarrollador no se hace responsable del uso que hagas de los datos rastreados -- No promovemos, facilitamos ni apoyamos actividades ilegales - -### Legalidad del CBD - -- Es responsabilidad del usuario verificar la legalidad del CBD en su jurisdicción -- El desarrollador no asume responsabilidad por el uso de la Aplicación en jurisdicciones donde el CBD es ilegal -- Esta Aplicación NO facilita la compra, venta ni distribución de CBD u otras sustancias - ---- - -## 11. Tus Derechos - -Dado que no recopilamos ni almacenamos datos fuera de tu dispositivo: - -### Derecho de acceso -✅ Todos tus datos están siempre accesibles en la Aplicación - -### Derecho de rectificación -✅ Puedes editar tus notas y datos en cualquier momento - -### Derecho de eliminación -✅ Simplemente desinstala la Aplicación o usa la función de "reset" en ajustes - -### Derecho de portabilidad -✅ Usa la función "Exportar CSV" para obtener tus datos en formato portable - -### Derecho de oposición -✅ No aplica, ya que no procesamos tus datos - ---- - -## 12. Cumplimiento Legal - -Esta Política de Privacidad cumple con: -- **RGPD** (Reglamento General de Protección de Datos de la UE) -- **LOPD** (Ley Orgánica de Protección de Datos de España) -- **Políticas de Privacidad de Google Play** - ---- - -## 13. Transferencias Internacionales - -No aplicable. Tus datos nunca abandonan tu dispositivo. - ---- - -## 14. Retención de Datos - -Los datos se retienen mientras: -- Mantengas la Aplicación instalada en tu dispositivo -- No uses la función de reset o borrado manual - -Los datos se eliminan automáticamente cuando: -- Desinstalas la Aplicación -- Borras manualmente los datos desde configuración de Android -- Reseteas tu dispositivo a valores de fábrica - ---- - -## 15. Base Legal para el Procesamiento (RGPD) - -No aplicable, ya que no procesamos datos personales fuera de tu dispositivo. - -El almacenamiento local en tu dispositivo está bajo tu control exclusivo. - ---- - -## 16. Contacto - -Si tienes preguntas, comentarios o inquietudes sobre esta Política de Privacidad: - -**Email:** d4vram369@gmail.com -**Método de contacto preferido:** Email -**Tiempo de respuesta esperado:** 7 días hábiles - ---- - -## 17. Jurisdicción - -Esta Política de Privacidad se rige por las leyes de España. - ---- - -## 18. Idioma - -Esta Política está disponible en español. En caso de discrepancia entre traducciones, la versión en español prevalecerá. - ---- - -## Resumen Ejecutivo (TL;DR) - -✅ **NO recopilamos datos** -✅ **Todo se guarda en tu dispositivo** -✅ **NO compartimos nada con nadie** -✅ **NO usamos analytics ni publicidad** -✅ **Tú controlas 100% de tus datos** -✅ **Desinstalar = eliminar todo** - ---- - -**Fecha de entrada en vigor:** 10 de noviembre de 2025 - -© 2025 D4vRAM. Todos los derechos reservados.