Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,30 @@ Rules:
- Released versions are immutable — never re-tag, never amend, never delete an entry
- Merge conflicts must preserve both sides; if both branches used the same version string, renumber the lower-priority one upward

---
## [0.25.1-beta.1] - 2026-06-03

### Fixed
- Bottom navigation bar now always navigates to the root of the tapped tab and discards any sub-screen history. Previously, tapping a tab after having navigated deep within it would restore the previous sub-screen stack, causing Back to return to unexpected screens.

---
## [0.25.0-beta.1] - 2026-06-03

### Added
- Full-screen disruptive alarm mode: when delivery mode is set to Alarm, the notification now launches a lock-screen alarm activity with a large icon and the user's custom alarm name.
- User-nameable alarms: set a custom label in Manage > Reminders that appears on the alarm screen when it fires.
- Year navigation in Stats chart area: CalendarYear and YTD modes now show prev/next year arrows directly above the chart, consistent with Month view navigation.
- Alarm and notification permission shortcuts in Manage screen for quick system-settings access.

### Changed
- Stats: Time range picker moved below the chart so the chart is immediately visible when a category is selected.
- Stats: Plus One (increment) categories now correctly sum the logged count per day in Time Series charts instead of showing 1 for every day logged.
- Stats: Removing a pin from the Dashboard now immediately clears the Unpin button in Stats.
- Settings: Tracking and Notifications sections removed; these are now exclusively in Manage.
- Manage: Items now show trailing chevron arrows, matching the Settings screen style.
- Manage > Reminders: Delivery mode description updated to say "Full-screen alarm" instead of "Plays alarm sound".
- Category management: Removed redundant Save button from the values list screen; changes save automatically.

---
## [0.24.1-beta.1] - 2026-06-02

Expand Down
4 changes: 2 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ android {
applicationId = "com.mapgie.goflo"
minSdk = 26
targetSdk = 34
versionCode = 64
versionName = "0.24.1-beta.1"
versionCode = 66
versionName = "0.25.1-beta.1"
}

signingConfigs {
Expand Down
11 changes: 11 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,17 @@
</intent-filter>
</activity-alias>

<!--
AlarmActivity is launched via a full-screen intent when delivery mode is ALARM.
It shows a disruptive lock-screen-visible alarm UI with the user's custom label.
-->
<activity
android:name=".notifications.AlarmActivity"
android:exported="false"
android:showOnLockScreen="true"
android:turnScreenOn="true"
android:excludeFromRecents="true" />

<!--
ReminderReceiver handles alarm-fired notifications only. It does NOT need
to be exported — AlarmManager delivers via explicit component PendingIntents
Expand Down
20 changes: 10 additions & 10 deletions app/src/main/java/com/mapgie/goflo/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -195,17 +195,17 @@ private fun MainNavHost(app: GoFloApplication, currentTheme: AppTheme, pendingCa
NavigationBarItem(
selected = currentRoute == Screen.Home.route,
onClick = { navController.navigate(Screen.Home.route) {
popUpTo(navController.graph.findStartDestination().id) { saveState = true }
launchSingleTop = true; restoreState = true
popUpTo(navController.graph.findStartDestination().id)
launchSingleTop = true
} },
icon = { Icon(Icons.Default.Home, contentDescription = "Home") },
label = { Text("Home") }
)
NavigationBarItem(
selected = currentRoute == Screen.History.route,
onClick = { navController.navigate(Screen.History.route) {
popUpTo(navController.graph.findStartDestination().id) { saveState = true }
launchSingleTop = true; restoreState = true
popUpTo(navController.graph.findStartDestination().id)
launchSingleTop = true
} },
icon = { Icon(Icons.Default.DateRange, contentDescription = "History") },
label = { Text("History") }
Expand All @@ -214,8 +214,8 @@ private fun MainNavHost(app: GoFloApplication, currentTheme: AppTheme, pendingCa
NavigationBarItem(
selected = currentRoute == Screen.Dashboard.route,
onClick = { navController.navigate(Screen.Dashboard.route) {
popUpTo(navController.graph.findStartDestination().id) { saveState = true }
launchSingleTop = true; restoreState = true
popUpTo(navController.graph.findStartDestination().id)
launchSingleTop = true
} },
icon = { Icon(Icons.Default.Dashboard, contentDescription = "Dashboard") },
label = { Text("Dashboard") }
Expand All @@ -224,17 +224,17 @@ private fun MainNavHost(app: GoFloApplication, currentTheme: AppTheme, pendingCa
NavigationBarItem(
selected = currentRoute == Screen.Stats.route,
onClick = { navController.navigate(Screen.Stats.route) {
popUpTo(navController.graph.findStartDestination().id) { saveState = true }
launchSingleTop = true; restoreState = true
popUpTo(navController.graph.findStartDestination().id)
launchSingleTop = true
} },
icon = { Icon(Icons.Default.BarChart, contentDescription = "Stats") },
label = { Text("Stats") }
)
NavigationBarItem(
selected = currentRoute == Screen.Manage.route,
onClick = { navController.navigate(Screen.Manage.route) {
popUpTo(navController.graph.findStartDestination().id) { saveState = true }
launchSingleTop = true; restoreState = true
popUpTo(navController.graph.findStartDestination().id)
launchSingleTop = true
} },
icon = { Icon(Icons.Outlined.Tune, contentDescription = "Manage") },
label = { Text("Manage") }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ data class ReminderSettings(
val reminderMinute: Int = 0,
/** "NOTIFICATION" (inexact, no special permission) or "ALARM" (exact, requires SCHEDULE_EXACT_ALARM). */
val deliveryMode: String = "NOTIFICATION",
/** Custom label shown on the alarm screen when delivery mode is ALARM. */
val alarmLabel: String = "",
)

data class AppPreferences(
Expand Down Expand Up @@ -101,6 +103,7 @@ class AppPreferencesStore(private val context: Context) {
val REMINDER_HOUR = intPreferencesKey("reminder_hour")
val REMINDER_MINUTE = intPreferencesKey("reminder_minute")
val REMINDER_DELIVERY_MODE = stringPreferencesKey("reminder_delivery_mode")
val ALARM_LABEL = stringPreferencesKey("alarm_label")
val PREFERRED_CYCLE_LENGTH = intPreferencesKey("preferred_cycle_length")
val QUICK_LOG_CATEGORY_ID = longPreferencesKey("quick_log_category_id")
val SHOW_PERIOD_PREDICTION = booleanPreferencesKey("show_period_prediction")
Expand Down Expand Up @@ -151,6 +154,7 @@ class AppPreferencesStore(private val context: Context) {
reminderHour = prefs[Keys.REMINDER_HOUR] ?: 8,
reminderMinute = prefs[Keys.REMINDER_MINUTE] ?: 0,
deliveryMode = prefs[Keys.REMINDER_DELIVERY_MODE] ?: "NOTIFICATION",
alarmLabel = prefs[Keys.ALARM_LABEL] ?: "",
)
)
}
Expand Down Expand Up @@ -190,6 +194,10 @@ class AppPreferencesStore(private val context: Context) {
context.dataStore.edit { it[Keys.REMINDER_DELIVERY_MODE] = mode }
}

suspend fun setAlarmLabel(label: String) {
context.dataStore.edit { it[Keys.ALARM_LABEL] = label }
}

/**
* Persists the user's preferred cycle length.
*
Expand Down
121 changes: 121 additions & 0 deletions app/src/main/java/com/mapgie/goflo/notifications/AlarmActivity.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
package com.mapgie.goflo.notifications

import android.os.Build
import android.os.Bundle
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.NotificationsActive
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.mapgie.goflo.GoFloApplication
import com.mapgie.goflo.ui.theme.AppTheme
import com.mapgie.goflo.ui.theme.GoFloTheme
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking

class AlarmActivity : ComponentActivity() {

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

@Suppress("DEPRECATION")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
setShowWhenLocked(true)
setTurnScreenOn(true)
} else {
window.addFlags(
WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or
WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON
)
}
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)

val app = application as GoFloApplication
val prefs = runBlocking { app.preferencesStore.preferences.first() }
val theme = runCatching { AppTheme.valueOf(prefs.theme) }
.getOrDefault(AppTheme.CORAL)

val alarmLabel = intent.getStringExtra(EXTRA_ALARM_LABEL)?.takeIf { it.isNotBlank() }
?: "Time to log"
val alarmTitle = intent.getStringExtra(EXTRA_ALARM_TITLE) ?: ""

setContent {
GoFloTheme(appTheme = theme, wcag = prefs.wcagMode) {
AlarmScreen(
label = alarmLabel,
subtitle = alarmTitle,
onDismiss = { finish() }
)
}
}
}

companion object {
const val EXTRA_ALARM_LABEL = "alarm_label"
const val EXTRA_ALARM_TITLE = "alarm_title"
}
}

@Composable
private fun AlarmScreen(
label: String,
subtitle: String,
onDismiss: () -> Unit,
) {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background,
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Icon(
imageVector = Icons.Outlined.NotificationsActive,
contentDescription = null,
modifier = Modifier.size(80.dp),
tint = MaterialTheme.colorScheme.primary,
)
Spacer(Modifier.height(24.dp))
Text(
text = label,
style = MaterialTheme.typography.headlineMedium,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onBackground,
)
if (subtitle.isNotBlank()) {
Spacer(Modifier.height(8.dp))
Text(
text = subtitle,
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(Modifier.height(40.dp))
Button(onClick = onDismiss) {
Text("Dismiss")
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,32 +16,43 @@ class ReminderReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
ReminderScheduler.createChannel(context)
val useAlarm = intent.getBooleanExtra(EXTRA_USE_ALARM_CHANNEL, false)
val alarmLabel = intent.getStringExtra(EXTRA_ALARM_LABEL) ?: ""
when (intent.action) {
ACTION_PREPERIOD -> showNotification(
context,
id = 1,
title = context.getString(R.string.notification_preperiod_title),
text = context.getString(R.string.notification_preperiod_text),
useAlarm = useAlarm,
alarmLabel = alarmLabel,
)
ACTION_OVULATION -> showNotification(
context,
id = 2,
title = context.getString(R.string.notification_ovulation_title),
text = context.getString(R.string.notification_ovulation_text),
useAlarm = useAlarm,
alarmLabel = alarmLabel,
)
ACTION_DAILY -> showNotification(
context,
id = 3,
title = context.getString(R.string.notification_daily_title),
text = context.getString(R.string.notification_daily_text),
useAlarm = useAlarm,
alarmLabel = alarmLabel,
)
}
}

private fun showNotification(context: Context, id: Int, title: String, text: String, useAlarm: Boolean = false) {
private fun showNotification(
context: Context,
id: Int,
title: String,
text: String,
useAlarm: Boolean = false,
alarmLabel: String = "",
) {
val tapIntent = Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
}
Expand All @@ -51,16 +62,32 @@ class ReminderReceiver : BroadcastReceiver() {
)

val channelId = if (useAlarm) CHANNEL_ID else CHANNEL_NOTIF_ID
val notification = NotificationCompat.Builder(context, channelId)
val displayTitle = if (useAlarm && alarmLabel.isNotBlank()) alarmLabel else title

val builder = NotificationCompat.Builder(context, channelId)
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setContentTitle(title)
.setContentTitle(displayTitle)
.setContentText(text)
.setContentIntent(tapPending)
.setAutoCancel(true)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.build()
.setPriority(if (useAlarm) NotificationCompat.PRIORITY_MAX else NotificationCompat.PRIORITY_HIGH)
.setCategory(if (useAlarm) NotificationCompat.CATEGORY_ALARM else NotificationCompat.CATEGORY_REMINDER)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)

if (useAlarm) {
val fullScreenIntent = Intent(context, AlarmActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
putExtra(AlarmActivity.EXTRA_ALARM_LABEL, alarmLabel.ifBlank { title })
putExtra(AlarmActivity.EXTRA_ALARM_TITLE, text)
}
val fullScreenPending = PendingIntent.getActivity(
context, id + 1000, fullScreenIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
builder.setFullScreenIntent(fullScreenPending, true)
}

val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
manager.notify(id, notification)
manager.notify(id, builder.build())
}
}
Loading