Skip to content
Merged
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
32 changes: 31 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,34 @@

# Byte-compiled CI helper scripts
__pycache__/
*.pyc

# macOS
.DS_Store

# Android snapshot test images — generated by Paparazzi, not committed
android/app/src/test/snapshots/

# Android build outputs
android/.gradle/
android/**/build/
android/local.properties
android/dependency-check-data/

# iOS / Xcode generated project — regenerated by xcodegen, not committed
ios/EthosProtocol/Xcode/

# SwiftPM / Xcode build artifacts
ios/EthosProtocol/.build/
ios/EthosProtocol/DerivedData/
*.xcuserstate
xcuserdata/

# JetBrains IDEs
.idea/
*.iml

# Gradle wrapper (local only)
.gradle/

# Node / npm (if any tooling is added)
node_modules/
4 changes: 4 additions & 0 deletions android/app/src/main/java/com/ethosprotocol/api/ApiClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,10 @@ class ApiClient(
suspend fun unregisterPushToken(token: String): ApiResult<Unit> =
delete("/notifications/register", PushRegistration(token = token))

// #231: Persist notification preferences server-side so they survive reinstall.
suspend fun updateNotificationPreferences(preferences: com.ethosprotocol.models.NotificationPreferences): ApiResult<Unit> =
post("/notifications/preferences", preferences)

// Internals
private suspend inline fun <reified T> get(path: String): ApiResult<T> {
ensureFreshToken()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package com.ethosprotocol.models

import android.content.Context
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import java.util.Calendar

/**
* Per-category notification preferences and optional quiet hours.
* Persisted to SharedPreferences and synced server-side on change so
* preferences survive reinstall.
*/
@Serializable
data class NotificationPreferences(
/** Whether TTL-expiry warning notifications are enabled. */
val ttlWarningsEnabled: Boolean = true,
/** Whether check-in reminder notifications are enabled. */
val checkInRemindersEnabled: Boolean = true,
/** Whether quiet hours are active. */
val quietHoursEnabled: Boolean = false,
/** Start of quiet hours (hour 0-23, local time). */
val quietHoursStart: Int = 22,
/** End of quiet hours (hour 0-23, local time). */
val quietHoursEnd: Int = 8
) {
/**
* Returns true if a notification should be suppressed right now based on quiet hours.
* Mirrors iOS NotificationPreferences.isSuppressedByQuietHours.
*/
fun isSuppressedByQuietHours(hourOfDay: Int = Calendar.getInstance().get(Calendar.HOUR_OF_DAY)): Boolean {
if (!quietHoursEnabled) return false
return if (quietHoursStart <= quietHoursEnd) {
hourOfDay >= quietHoursStart && hourOfDay < quietHoursEnd
} else {
// Wraps midnight, e.g. 22:00–08:00
hourOfDay >= quietHoursStart || hourOfDay < quietHoursEnd
}
}

companion object {
private const val PREFS_NAME = "notification_preferences"
private const val KEY = "prefs_json"
private val json = Json { ignoreUnknownKeys = true }

fun load(context: Context): NotificationPreferences {
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
val stored = prefs.getString(KEY, null) ?: return NotificationPreferences()
return try { json.decodeFromString(stored) } catch (_: Exception) { NotificationPreferences() }
}

fun save(context: Context, preferences: NotificationPreferences) {
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit().putString(KEY, json.encodeToString(preferences)).apply()
}
}
}
15 changes: 15 additions & 0 deletions android/app/src/main/java/com/ethosprotocol/ui/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ import com.ethosprotocol.services.VaultDeepLinkParser
import com.ethosprotocol.ui.screens.AuthScreen
import com.ethosprotocol.ui.screens.BeneficiaryAcceptanceScreen
import com.ethosprotocol.ui.screens.DepositScreen
import com.ethosprotocol.ui.screens.NotificationPreferencesScreen
import com.ethosprotocol.ui.screens.SettingsScreen
import com.ethosprotocol.ui.screens.VaultDeepLinkScreen
import com.ethosprotocol.ui.screens.VaultListScreen
import com.ethosprotocol.ui.screens.WithdrawScreen
Expand Down Expand Up @@ -265,6 +267,19 @@ private fun AppNavigation(
onDone = { navController.popBackStack() }
)
}
// #231: Settings screen — entry point for notification preferences and future settings.
composable("settings") {
SettingsScreen(
onNotificationPreferences = { navController.navigate("notification_preferences") },
onBack = { navController.popBackStack() }
)
}
// #231: Notification preferences screen — per-category toggles and quiet hours.
composable("notification_preferences") {
NotificationPreferencesScreen(
onBack = { navController.popBackStack() }
)
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package com.ethosprotocol.ui

import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.ethosprotocol.api.ApiClient
import com.ethosprotocol.api.ApiResult
import com.ethosprotocol.models.NotificationPreferences
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject

data class NotificationPreferencesUiState(
val preferences: NotificationPreferences = NotificationPreferences(),
val isSaving: Boolean = false,
val error: String? = null
)

@HiltViewModel
class NotificationPreferencesViewModel @Inject constructor(
private val apiClient: ApiClient,
@ApplicationContext private val context: Context
) : ViewModel() {

private val _state = MutableStateFlow(
NotificationPreferencesUiState(
preferences = NotificationPreferences.load(context)
)
)
val state = _state.asStateFlow()

/**
* Persists updated preferences locally and syncs them server-side so they
* survive reinstall — mirrors iOS NotificationPreferencesView.save().
*/
fun update(preferences: NotificationPreferences) {
NotificationPreferences.save(context, preferences)
_state.update { it.copy(preferences = preferences, error = null) }
viewModelScope.launch {
_state.update { it.copy(isSaving = true) }
when (val result = apiClient.updateNotificationPreferences(preferences)) {
is ApiResult.Success -> _state.update { it.copy(isSaving = false) }
is ApiResult.Error -> _state.update { it.copy(isSaving = false, error = result.message) }
ApiResult.NetworkUnavailable -> _state.update {
// Offline: local save already happened; server-side sync will be retried
// on the next manual save. Don't surface an error for a best-effort call.
it.copy(isSaving = false)
}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
package com.ethosprotocol.ui.screens

import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.ethosprotocol.models.NotificationPreferences
import com.ethosprotocol.ui.NotificationPreferencesViewModel

@Composable
fun NotificationPreferencesScreen(
onBack: () -> Unit,
vm: NotificationPreferencesViewModel = hiltViewModel()
) {
val state by vm.state.collectAsStateWithLifecycle()
NotificationPreferencesContent(
preferences = state.preferences,
isSaving = state.isSaving,
error = state.error,
onUpdate = { vm.update(it) },
onBack = onBack
)
}

/**
* Stateless content layer, extracted for testability.
*/
@Composable
fun NotificationPreferencesContent(
preferences: NotificationPreferences,
isSaving: Boolean,
error: String?,
onUpdate: (NotificationPreferences) -> Unit,
onBack: () -> Unit
) {
Scaffold(
topBar = {
TopAppBar(
title = { Text("Notification Preferences") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
}
}
)
}
) { padding ->
Column(
modifier = Modifier
.padding(padding)
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text("Notification Types", style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary)

Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text("TTL Expiry Warnings", style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f))
Switch(
checked = preferences.ttlWarningsEnabled,
onCheckedChange = { onUpdate(preferences.copy(ttlWarningsEnabled = it)) }
)
}

Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text("Check-in Reminders", style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f))
Switch(
checked = preferences.checkInRemindersEnabled,
onCheckedChange = { onUpdate(preferences.copy(checkInRemindersEnabled = it)) }
)
}

Text(
"Control which push notifications Ethos-Protocol sends you. Changes are synced with the server so they survive reinstall.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)

Spacer(Modifier.height(8.dp))
Divider()
Spacer(Modifier.height(8.dp))

Text("Quiet Hours", style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary)

Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text("Enable Quiet Hours", style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f))
Switch(
checked = preferences.quietHoursEnabled,
onCheckedChange = { onUpdate(preferences.copy(quietHoursEnabled = it)) }
)
}

if (preferences.quietHoursEnabled) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text("Start hour: ${formatHour(preferences.quietHoursStart)}",
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f))
Row {
TextButton(onClick = {
onUpdate(preferences.copy(quietHoursStart = (preferences.quietHoursStart - 1 + 24) % 24))
}) { Text("-") }
TextButton(onClick = {
onUpdate(preferences.copy(quietHoursStart = (preferences.quietHoursStart + 1) % 24))
}) { Text("+") }
}
}
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text("End hour: ${formatHour(preferences.quietHoursEnd)}",
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f))
Row {
TextButton(onClick = {
onUpdate(preferences.copy(quietHoursEnd = (preferences.quietHoursEnd - 1 + 24) % 24))
}) { Text("-") }
TextButton(onClick = {
onUpdate(preferences.copy(quietHoursEnd = (preferences.quietHoursEnd + 1) % 24))
}) { Text("+") }
}
}
Text(
"Notifications suppressed between ${formatHour(preferences.quietHoursStart)} and ${formatHour(preferences.quietHoursEnd)}.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}

error?.let {
Spacer(Modifier.height(8.dp))
Text(it, color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall)
}

if (isSaving) {
Spacer(Modifier.height(8.dp))
LinearProgressIndicator(Modifier.fillMaxWidth())
}
}
}
}

private fun formatHour(hour: Int): String {
val h = hour % 12
val amPm = if (hour < 12) "AM" else "PM"
return "${if (h == 0) 12 else h}:00 $amPm"
}
Loading