ios widget showing what is available at chucks
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add future days and favorites to android

+670 -21
+1
android/app/src/main/AndroidManifest.xml
··· 3 3 xmlns:tools="http://schemas.android.com/tools"> 4 4 5 5 <uses-permission android:name="android.permission.INTERNET" /> 6 + <uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> 6 7 7 8 <application 8 9 android:name=".WasupChucksApplication"
+54
android/app/src/main/java/com/wasupchucks/data/model/FavoriteMealMatch.kt
··· 1 + package com.wasupchucks.data.model 2 + 3 + data class FavoriteMealMatch( 4 + val dateKey: String, 5 + val meal: MealPhase, 6 + val matchedItems: List<String> 7 + ) 8 + 9 + fun findFavoriteMatches( 10 + menus: Map<String, List<VenueMenu>>, 11 + favoriteItems: Set<String>, 12 + favoriteKeywords: Set<String> 13 + ): List<FavoriteMealMatch> { 14 + if (favoriteItems.isEmpty() && favoriteKeywords.isEmpty()) return emptyList() 15 + 16 + val results = mutableListOf<FavoriteMealMatch>() 17 + 18 + for ((dateKey, venues) in menus) { 19 + val slots = venues.groupBy { it.slot } 20 + 21 + for ((slot, slotVenues) in slots) { 22 + val meal = mealPhaseForSlot(slot) ?: continue 23 + 24 + val matched = mutableListOf<String>() 25 + for (venue in slotVenues) { 26 + for (item in venue.items) { 27 + if (favoriteItems.contains(item.name)) { 28 + matched.add(item.name) 29 + continue 30 + } 31 + val lowered = item.name.lowercase() 32 + if (favoriteKeywords.any { lowered.contains(it.lowercase()) }) { 33 + matched.add(item.name) 34 + } 35 + } 36 + } 37 + 38 + if (matched.isNotEmpty()) { 39 + results.add(FavoriteMealMatch(dateKey, meal, matched)) 40 + } 41 + } 42 + } 43 + 44 + return results 45 + } 46 + 47 + private fun mealPhaseForSlot(slot: String): MealPhase? { 48 + return when (slot) { 49 + "breakfast" -> MealPhase.BREAKFAST 50 + "lunch" -> MealPhase.LUNCH 51 + "dinner" -> MealPhase.DINNER 52 + else -> null 53 + } 54 + }
+64
android/app/src/main/java/com/wasupchucks/data/repository/FavoritesRepository.kt
··· 1 + package com.wasupchucks.data.repository 2 + 3 + import android.content.Context 4 + import androidx.datastore.core.DataStore 5 + import androidx.datastore.preferences.core.Preferences 6 + import androidx.datastore.preferences.core.edit 7 + import androidx.datastore.preferences.core.stringSetPreferencesKey 8 + import androidx.datastore.preferences.preferencesDataStore 9 + import com.wasupchucks.data.model.MenuItem 10 + import dagger.hilt.android.qualifiers.ApplicationContext 11 + import kotlinx.coroutines.flow.Flow 12 + import kotlinx.coroutines.flow.map 13 + import javax.inject.Inject 14 + import javax.inject.Singleton 15 + 16 + private val Context.favoritesDataStore: DataStore<Preferences> by preferencesDataStore(name = "favorites") 17 + 18 + @Singleton 19 + class FavoritesRepository @Inject constructor( 20 + @ApplicationContext private val context: Context 21 + ) { 22 + private val favoriteItemsKey = stringSetPreferencesKey("favorite_items") 23 + private val favoriteKeywordsKey = stringSetPreferencesKey("favorite_keywords") 24 + 25 + val favoriteItems: Flow<Set<String>> = context.favoritesDataStore.data 26 + .map { preferences -> preferences[favoriteItemsKey] ?: emptySet() } 27 + 28 + val favoriteKeywords: Flow<Set<String>> = context.favoritesDataStore.data 29 + .map { preferences -> preferences[favoriteKeywordsKey] ?: emptySet() } 30 + 31 + suspend fun toggleItem(name: String) { 32 + context.favoritesDataStore.edit { preferences -> 33 + val current = preferences[favoriteItemsKey] ?: emptySet() 34 + preferences[favoriteItemsKey] = if (current.contains(name)) { 35 + current - name 36 + } else { 37 + current + name 38 + } 39 + } 40 + } 41 + 42 + suspend fun addKeyword(keyword: String) { 43 + val trimmed = keyword.trim() 44 + if (trimmed.isEmpty()) return 45 + 46 + context.favoritesDataStore.edit { preferences -> 47 + val current = preferences[favoriteKeywordsKey] ?: emptySet() 48 + preferences[favoriteKeywordsKey] = current + trimmed 49 + } 50 + } 51 + 52 + suspend fun removeKeyword(keyword: String) { 53 + context.favoritesDataStore.edit { preferences -> 54 + val current = preferences[favoriteKeywordsKey] ?: emptySet() 55 + preferences[favoriteKeywordsKey] = current - keyword 56 + } 57 + } 58 + 59 + fun isFavorite(item: MenuItem, favoriteItems: Set<String>, favoriteKeywords: Set<String>): Boolean { 60 + if (favoriteItems.contains(item.name)) return true 61 + val lowered = item.name.lowercase() 62 + return favoriteKeywords.any { lowered.contains(it.lowercase()) } 63 + } 64 + }
+164
android/app/src/main/java/com/wasupchucks/notifications/NotificationScheduler.kt
··· 1 + package com.wasupchucks.notifications 2 + 3 + import android.Manifest 4 + import android.app.NotificationChannel 5 + import android.app.NotificationManager 6 + import android.content.Context 7 + import android.content.pm.PackageManager 8 + import android.os.Build 9 + import androidx.core.app.ActivityCompat 10 + import androidx.core.app.NotificationCompat 11 + import androidx.core.app.NotificationManagerCompat 12 + import androidx.work.CoroutineWorker 13 + import androidx.work.ExistingWorkPolicy 14 + import androidx.work.OneTimeWorkRequestBuilder 15 + import androidx.work.WorkManager 16 + import androidx.work.WorkerParameters 17 + import androidx.work.workDataOf 18 + import com.wasupchucks.R 19 + import com.wasupchucks.data.model.MealPhase 20 + import com.wasupchucks.data.model.MealSchedule 21 + import com.wasupchucks.data.model.VenueMenu 22 + import com.wasupchucks.data.model.findFavoriteMatches 23 + import com.wasupchucks.data.repository.FavoritesRepository 24 + import com.wasupchucks.data.repository.MenuRepository 25 + import dagger.hilt.android.qualifiers.ApplicationContext 26 + import kotlinx.coroutines.flow.first 27 + import java.time.LocalDate 28 + import java.time.LocalDateTime 29 + import java.time.ZoneId 30 + import java.time.format.DateTimeFormatter 31 + import java.time.temporal.ChronoUnit 32 + import java.util.concurrent.TimeUnit 33 + import javax.inject.Inject 34 + import javax.inject.Singleton 35 + 36 + private const val CHANNEL_ID = "favorites_notifications" 37 + private const val NOTIFICATION_WORK_TAG = "favorites_notification_work" 38 + 39 + @Singleton 40 + class NotificationScheduler @Inject constructor( 41 + @ApplicationContext private val context: Context, 42 + private val favoritesRepository: FavoritesRepository, 43 + private val menuRepository: MenuRepository 44 + ) { 45 + private val cedarvilleZone = ZoneId.of("America/New_York") 46 + private val workManager = WorkManager.getInstance(context) 47 + 48 + init { 49 + createNotificationChannel() 50 + } 51 + 52 + private fun createNotificationChannel() { 53 + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { 54 + val name = "Favorite Menu Items" 55 + val descriptionText = "Notifications when your favorite items are available" 56 + val importance = NotificationManager.IMPORTANCE_DEFAULT 57 + val channel = NotificationChannel(CHANNEL_ID, name, importance).apply { 58 + description = descriptionText 59 + } 60 + val notificationManager: NotificationManager = 61 + context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager 62 + notificationManager.createNotificationChannel(channel) 63 + } 64 + } 65 + 66 + suspend fun rescheduleNotifications( 67 + menus: Map<String, List<VenueMenu>>, 68 + favoriteItems: Set<String>, 69 + favoriteKeywords: Set<String> 70 + ) { 71 + // Cancel all existing notification work 72 + workManager.cancelAllWorkByTag(NOTIFICATION_WORK_TAG) 73 + 74 + if (favoriteItems.isEmpty() && favoriteKeywords.isEmpty()) { 75 + return 76 + } 77 + 78 + val matches = findFavoriteMatches(menus, favoriteItems, favoriteKeywords) 79 + val now = LocalDateTime.now(cedarvilleZone) 80 + val dateFormatter = DateTimeFormatter.ISO_LOCAL_DATE 81 + 82 + for (match in matches) { 83 + val date = try { 84 + LocalDate.parse(match.dateKey, dateFormatter) 85 + } catch (e: Exception) { 86 + continue 87 + } 88 + 89 + val schedule = MealSchedule.scheduleFor(date) 90 + val mealSchedule = schedule.firstOrNull { it.phase == match.meal } ?: continue 91 + 92 + // Calculate notification time (1 hour before meal start) 93 + val mealStartTime = LocalDateTime.of( 94 + date, 95 + java.time.LocalTime.of(mealSchedule.startHour, mealSchedule.startMinute) 96 + ) 97 + val notificationTime = mealStartTime.minusHours(1) 98 + 99 + if (notificationTime.isAfter(now)) { 100 + val delay = ChronoUnit.MILLIS.between(now, notificationTime) 101 + 102 + val itemNames = match.matchedItems.take(3).joinToString(", ") 103 + val additionalCount = match.matchedItems.size - 3 104 + 105 + val workData = workDataOf( 106 + "mealName" to match.meal.displayName, 107 + "itemNames" to itemNames, 108 + "additionalCount" to additionalCount, 109 + "notificationId" to "${match.dateKey}-${match.meal.name}".hashCode() 110 + ) 111 + 112 + val notificationWork = OneTimeWorkRequestBuilder<NotificationWorker>() 113 + .setInitialDelay(delay, TimeUnit.MILLISECONDS) 114 + .setInputData(workData) 115 + .addTag(NOTIFICATION_WORK_TAG) 116 + .build() 117 + 118 + workManager.enqueueUniqueWork( 119 + "${match.dateKey}-${match.meal.name}", 120 + ExistingWorkPolicy.REPLACE, 121 + notificationWork 122 + ) 123 + } 124 + } 125 + } 126 + } 127 + 128 + class NotificationWorker( 129 + appContext: Context, 130 + params: WorkerParameters 131 + ) : CoroutineWorker(appContext, params) { 132 + 133 + override suspend fun doWork(): Result { 134 + val mealName = inputData.getString("mealName") ?: return Result.failure() 135 + val itemNames = inputData.getString("itemNames") ?: return Result.failure() 136 + val additionalCount = inputData.getInt("additionalCount", 0) 137 + val notificationId = inputData.getInt("notificationId", 0) 138 + 139 + val bodyText = if (additionalCount > 0) { 140 + "$itemNames +$additionalCount more at Chuck's today." 141 + } else { 142 + "$itemNames at Chuck's today." 143 + } 144 + 145 + val builder = NotificationCompat.Builder(applicationContext, CHANNEL_ID) 146 + .setSmallIcon(R.drawable.ic_launcher_foreground) 147 + .setContentTitle("$mealName has your favorites!") 148 + .setContentText(bodyText) 149 + .setStyle(NotificationCompat.BigTextStyle().bigText(bodyText)) 150 + .setPriority(NotificationCompat.PRIORITY_DEFAULT) 151 + .setAutoCancel(true) 152 + 153 + if (ActivityCompat.checkSelfPermission( 154 + applicationContext, 155 + Manifest.permission.POST_NOTIFICATIONS 156 + ) == PackageManager.PERMISSION_GRANTED || Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU 157 + ) { 158 + NotificationManagerCompat.from(applicationContext) 159 + .notify(notificationId, builder.build()) 160 + } 161 + 162 + return Result.success() 163 + } 164 + }
+231
android/app/src/main/java/com/wasupchucks/ui/components/FavoritesManagerSheet.kt
··· 1 + package com.wasupchucks.ui.components 2 + 3 + import androidx.compose.foundation.background 4 + import androidx.compose.foundation.layout.Arrangement 5 + import androidx.compose.foundation.layout.Box 6 + import androidx.compose.foundation.layout.Column 7 + import androidx.compose.foundation.layout.Row 8 + import androidx.compose.foundation.layout.Spacer 9 + import androidx.compose.foundation.layout.fillMaxWidth 10 + import androidx.compose.foundation.layout.height 11 + import androidx.compose.foundation.layout.padding 12 + import androidx.compose.foundation.layout.size 13 + import androidx.compose.foundation.lazy.LazyColumn 14 + import androidx.compose.foundation.lazy.items 15 + import androidx.compose.foundation.shape.RoundedCornerShape 16 + import androidx.compose.material.icons.Icons 17 + import androidx.compose.material.icons.filled.Add 18 + import androidx.compose.material.icons.filled.Star 19 + import androidx.compose.material3.ExperimentalMaterial3Api 20 + import androidx.compose.material3.HorizontalDivider 21 + import androidx.compose.material3.Icon 22 + import androidx.compose.material3.IconButton 23 + import androidx.compose.material3.ListItem 24 + import androidx.compose.material3.MaterialTheme 25 + import androidx.compose.material3.ModalBottomSheet 26 + import androidx.compose.material3.OutlinedTextField 27 + import androidx.compose.material3.SwipeToDismissBox 28 + import androidx.compose.material3.SwipeToDismissBoxValue 29 + import androidx.compose.material3.Text 30 + import androidx.compose.material3.rememberSwipeToDismissBoxState 31 + import androidx.compose.runtime.Composable 32 + import androidx.compose.runtime.getValue 33 + import androidx.compose.runtime.mutableStateOf 34 + import androidx.compose.runtime.remember 35 + import androidx.compose.runtime.setValue 36 + import androidx.compose.ui.Alignment 37 + import androidx.compose.ui.Modifier 38 + import androidx.compose.ui.graphics.Color 39 + import androidx.compose.ui.text.font.FontWeight 40 + import androidx.compose.ui.unit.dp 41 + 42 + @OptIn(ExperimentalMaterial3Api::class) 43 + @Composable 44 + fun FavoritesManagerSheet( 45 + favoriteItems: Set<String>, 46 + favoriteKeywords: Set<String>, 47 + onAddKeyword: (String) -> Unit, 48 + onRemoveKeyword: (String) -> Unit, 49 + onToggleItem: (String) -> Unit, 50 + onDismiss: () -> Unit 51 + ) { 52 + var newKeyword by remember { mutableStateOf("") } 53 + 54 + ModalBottomSheet( 55 + onDismissRequest = onDismiss 56 + ) { 57 + LazyColumn( 58 + modifier = Modifier 59 + .fillMaxWidth() 60 + .padding(horizontal = 16.dp) 61 + ) { 62 + item { 63 + Text( 64 + text = "Manage Favorites", 65 + style = MaterialTheme.typography.headlineSmall, 66 + fontWeight = FontWeight.Bold, 67 + modifier = Modifier.padding(bottom = 16.dp) 68 + ) 69 + } 70 + 71 + item { 72 + Text( 73 + text = "Keywords", 74 + style = MaterialTheme.typography.titleMedium, 75 + fontWeight = FontWeight.SemiBold, 76 + color = MaterialTheme.colorScheme.primary, 77 + modifier = Modifier.padding(vertical = 8.dp) 78 + ) 79 + } 80 + 81 + item { 82 + Row( 83 + modifier = Modifier 84 + .fillMaxWidth() 85 + .padding(bottom = 16.dp), 86 + horizontalArrangement = Arrangement.spacedBy(8.dp), 87 + verticalAlignment = Alignment.CenterVertically 88 + ) { 89 + OutlinedTextField( 90 + value = newKeyword, 91 + onValueChange = { newKeyword = it }, 92 + label = { Text("Add keyword (e.g. fish, pizza)") }, 93 + modifier = Modifier.weight(1f), 94 + singleLine = true 95 + ) 96 + IconButton( 97 + onClick = { 98 + if (newKeyword.isNotBlank()) { 99 + onAddKeyword(newKeyword.trim()) 100 + newKeyword = "" 101 + } 102 + }, 103 + enabled = newKeyword.isNotBlank() 104 + ) { 105 + Icon(Icons.Filled.Add, contentDescription = "Add keyword") 106 + } 107 + } 108 + } 109 + 110 + item { 111 + Text( 112 + text = "Items containing a keyword will be highlighted as favorites.", 113 + style = MaterialTheme.typography.bodySmall, 114 + color = MaterialTheme.colorScheme.onSurfaceVariant, 115 + modifier = Modifier.padding(bottom = 8.dp) 116 + ) 117 + } 118 + 119 + if (favoriteKeywords.isNotEmpty()) { 120 + item { 121 + Text( 122 + text = "Current Keywords", 123 + style = MaterialTheme.typography.labelMedium, 124 + color = MaterialTheme.colorScheme.onSurfaceVariant, 125 + modifier = Modifier.padding(vertical = 8.dp) 126 + ) 127 + } 128 + 129 + items(favoriteKeywords.sorted()) { keyword -> 130 + val dismissState = rememberSwipeToDismissBoxState( 131 + confirmValueChange = { 132 + if (it == SwipeToDismissBoxValue.EndToStart) { 133 + onRemoveKeyword(keyword) 134 + true 135 + } else false 136 + } 137 + ) 138 + 139 + SwipeToDismissBox( 140 + state = dismissState, 141 + backgroundContent = { 142 + Box( 143 + modifier = Modifier 144 + .fillMaxWidth() 145 + .background(Color.Red, RoundedCornerShape(8.dp)) 146 + .padding(16.dp), 147 + contentAlignment = Alignment.CenterEnd 148 + ) { 149 + Text("Delete", color = Color.White) 150 + } 151 + } 152 + ) { 153 + ListItem( 154 + headlineContent = { Text(keyword) }, 155 + leadingContent = { 156 + Icon( 157 + imageVector = Icons.Filled.Star, 158 + contentDescription = null, 159 + tint = Color(0xFFFF9800), 160 + modifier = Modifier.size(20.dp) 161 + ) 162 + }, 163 + modifier = Modifier.background(MaterialTheme.colorScheme.surface) 164 + ) 165 + } 166 + } 167 + } 168 + 169 + if (favoriteItems.isNotEmpty()) { 170 + item { 171 + Spacer(modifier = Modifier.height(16.dp)) 172 + HorizontalDivider() 173 + Spacer(modifier = Modifier.height(16.dp)) 174 + } 175 + 176 + item { 177 + Text( 178 + text = "Favorited Items", 179 + style = MaterialTheme.typography.titleMedium, 180 + fontWeight = FontWeight.SemiBold, 181 + color = MaterialTheme.colorScheme.primary, 182 + modifier = Modifier.padding(vertical = 8.dp) 183 + ) 184 + } 185 + 186 + items(favoriteItems.sorted()) { item -> 187 + val dismissState = rememberSwipeToDismissBoxState( 188 + confirmValueChange = { 189 + if (it == SwipeToDismissBoxValue.EndToStart) { 190 + onToggleItem(item) 191 + true 192 + } else false 193 + } 194 + ) 195 + 196 + SwipeToDismissBox( 197 + state = dismissState, 198 + backgroundContent = { 199 + Box( 200 + modifier = Modifier 201 + .fillMaxWidth() 202 + .background(Color.Red, RoundedCornerShape(8.dp)) 203 + .padding(16.dp), 204 + contentAlignment = Alignment.CenterEnd 205 + ) { 206 + Text("Delete", color = Color.White) 207 + } 208 + } 209 + ) { 210 + ListItem( 211 + headlineContent = { Text(item) }, 212 + leadingContent = { 213 + Icon( 214 + imageVector = Icons.Filled.Star, 215 + contentDescription = null, 216 + tint = Color(0xFFFF9800), 217 + modifier = Modifier.size(20.dp) 218 + ) 219 + }, 220 + modifier = Modifier.background(MaterialTheme.colorScheme.surface) 221 + ) 222 + } 223 + } 224 + } 225 + 226 + item { 227 + Spacer(modifier = Modifier.height(32.dp)) 228 + } 229 + } 230 + } 231 + }
+38 -10
android/app/src/main/java/com/wasupchucks/ui/components/VenueCard.kt
··· 20 20 import androidx.compose.material.icons.Icons 21 21 import androidx.compose.material.icons.filled.ExpandLess 22 22 import androidx.compose.material.icons.filled.ExpandMore 23 + import androidx.compose.material.icons.filled.Star 24 + import androidx.compose.material.icons.outlined.StarOutline 23 25 import androidx.compose.material3.Icon 26 + import androidx.compose.material3.IconButton 24 27 import androidx.compose.material3.MaterialTheme 25 28 import androidx.compose.material3.OutlinedCard 26 29 import androidx.compose.material3.Text ··· 32 35 import androidx.compose.ui.Alignment 33 36 import androidx.compose.ui.Modifier 34 37 import androidx.compose.ui.draw.clip 38 + import androidx.compose.ui.graphics.Color 35 39 import androidx.compose.ui.semantics.contentDescription 36 40 import androidx.compose.ui.semantics.semantics 37 41 import androidx.compose.ui.text.font.FontWeight 38 42 import androidx.compose.ui.unit.dp 43 + import com.wasupchucks.data.model.MenuItem 39 44 import com.wasupchucks.data.model.VenueMenu 40 45 41 46 @Composable 42 47 fun VenueCard( 43 48 venue: VenueMenu, 44 - modifier: Modifier = Modifier 49 + modifier: Modifier = Modifier, 50 + onFavoriteToggle: ((String) -> Unit)? = null, 51 + isFavorite: ((MenuItem) -> Boolean)? = null 45 52 ) { 46 53 var isExpanded by rememberSaveable { mutableStateOf(true) } 47 54 ··· 100 107 verticalArrangement = Arrangement.spacedBy(10.dp) 101 108 ) { 102 109 venue.items.forEach { item -> 110 + val isFav = isFavorite?.invoke(item) ?: false 103 111 Row( 104 112 modifier = Modifier 105 113 .fillMaxWidth() 114 + .background( 115 + if (isFav) Color(0xFFFF9800).copy(alpha = 0.08f) 116 + else Color.Transparent, 117 + MaterialTheme.shapes.small 118 + ) 119 + .padding(vertical = 4.dp, horizontal = 4.dp) 106 120 .semantics { contentDescription = item.name }, 107 121 verticalAlignment = Alignment.CenterVertically, 108 - horizontalArrangement = Arrangement.spacedBy(12.dp) 122 + horizontalArrangement = Arrangement.spacedBy(8.dp) 109 123 ) { 110 - // Bullet point 111 - Box( 112 - modifier = Modifier 113 - .size(6.dp) 114 - .background( 115 - MaterialTheme.colorScheme.primary.copy(alpha = 0.6f), 116 - CircleShape 124 + // Star button or bullet 125 + if (onFavoriteToggle != null) { 126 + IconButton( 127 + onClick = { onFavoriteToggle(item.name) }, 128 + modifier = Modifier.size(32.dp) 129 + ) { 130 + Icon( 131 + imageVector = if (isFav) Icons.Filled.Star else Icons.Outlined.StarOutline, 132 + contentDescription = if (isFav) "Remove from favorites" else "Add to favorites", 133 + tint = if (isFav) Color(0xFFFF9800) else MaterialTheme.colorScheme.onSurfaceVariant, 134 + modifier = Modifier.size(18.dp) 117 135 ) 118 - ) 136 + } 137 + } else { 138 + Box( 139 + modifier = Modifier 140 + .size(6.dp) 141 + .background( 142 + MaterialTheme.colorScheme.primary.copy(alpha = 0.6f), 143 + CircleShape 144 + ) 145 + ) 146 + } 119 147 120 148 Text( 121 149 text = item.name,
+50 -8
android/app/src/main/java/com/wasupchucks/ui/screens/home/HomeScreen.kt
··· 24 24 import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft 25 25 import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight 26 26 import androidx.compose.material.icons.filled.Schedule 27 + import androidx.compose.material.icons.filled.Star 27 28 import androidx.compose.material3.CircularProgressIndicator 28 29 import androidx.compose.material3.ExperimentalMaterial3Api 29 30 import androidx.compose.material3.HorizontalDivider ··· 44 45 import androidx.compose.runtime.snapshotFlow 45 46 import androidx.compose.ui.Alignment 46 47 import androidx.compose.ui.Modifier 48 + import androidx.compose.ui.graphics.Color 47 49 import androidx.compose.ui.platform.LocalContext 48 50 import androidx.compose.ui.res.stringResource 49 51 import androidx.compose.ui.text.font.FontWeight ··· 56 58 import com.wasupchucks.data.model.MealSchedule 57 59 import com.wasupchucks.data.model.VenueMenu 58 60 import com.wasupchucks.ui.components.ErrorCard 61 + import com.wasupchucks.ui.components.FavoritesManagerSheet 59 62 import com.wasupchucks.ui.components.MealDetailSheet 60 63 import com.wasupchucks.ui.components.ScheduleCard 61 64 import com.wasupchucks.ui.components.StatusCard ··· 100 103 topBar = { 101 104 TopAppBar( 102 105 title = { Text(stringResource(R.string.app_name)) }, 106 + actions = { 107 + IconButton(onClick = { viewModel.showFavoritesManager(true) }) { 108 + Icon( 109 + imageVector = Icons.Filled.Star, 110 + contentDescription = "Manage favorites", 111 + tint = Color(0xFFFF9800) 112 + ) 113 + } 114 + }, 103 115 colors = TopAppBarDefaults.topAppBarColors( 104 116 containerColor = MaterialTheme.colorScheme.surfaceContainer 105 117 ) ··· 170 182 onDismiss = { viewModel.selectMeal(null) } 171 183 ) 172 184 } 185 + 186 + // Favorites Manager Sheet 187 + if (uiState.showFavoritesManager) { 188 + FavoritesManagerSheet( 189 + favoriteItems = uiState.favoriteItems, 190 + favoriteKeywords = uiState.favoriteKeywords, 191 + onAddKeyword = { viewModel.addFavoriteKeyword(it) }, 192 + onRemoveKeyword = { viewModel.removeFavoriteKeyword(it) }, 193 + onToggleItem = { viewModel.toggleFavoriteItem(it) }, 194 + onDismiss = { viewModel.showFavoritesManager(false) } 195 + ) 196 + } 173 197 } 174 198 175 199 @Composable ··· 234 258 isExpandedWidth: Boolean, 235 259 onMealClick: (MealSchedule) -> Unit, 236 260 onRetry: () -> Unit, 237 - context: Context 261 + context: Context, 262 + viewModel: HomeViewModel = hiltViewModel() 238 263 ) { 239 264 val mealLabel = getMealLabel(uiState.currentSlot) 240 265 ··· 311 336 mealVenues = uiState.mealSpecificVenues, 312 337 alwaysAvailableVenues = uiState.alwaysAvailableVenues, 313 338 mealLabel = mealLabel, 314 - isExpandedWidth = isExpandedWidth 339 + isExpandedWidth = isExpandedWidth, 340 + onFavoriteToggle = { viewModel.toggleFavoriteItem(it) }, 341 + isFavorite = { viewModel.isFavorite(it) } 315 342 ) 316 343 } 317 344 ··· 328 355 isExpandedWidth: Boolean, 329 356 onMealSelect: (MealPhase) -> Unit, 330 357 onRetry: () -> Unit, 331 - context: Context 358 + context: Context, 359 + viewModel: HomeViewModel = hiltViewModel() 332 360 ) { 333 361 val date = uiState.availableDates.getOrNull(page) 334 362 val schedule = if (date != null) { ··· 407 435 mealVenues = mealVenues, 408 436 alwaysAvailableVenues = alwaysAvailable, 409 437 mealLabel = uiState.selectedFutureMealPhase.displayName, 410 - isExpandedWidth = isExpandedWidth 438 + isExpandedWidth = isExpandedWidth, 439 + onFavoriteToggle = { viewModel.toggleFavoriteItem(it) }, 440 + isFavorite = { viewModel.isFavorite(it) } 411 441 ) 412 442 } 413 443 ··· 421 451 mealVenues: List<VenueMenu>, 422 452 alwaysAvailableVenues: List<VenueMenu>, 423 453 mealLabel: String, 424 - isExpandedWidth: Boolean 454 + isExpandedWidth: Boolean, 455 + onFavoriteToggle: ((String) -> Unit)? = null, 456 + isFavorite: ((com.wasupchucks.data.model.MenuItem) -> Boolean)? = null 425 457 ) { 426 458 // Meal Specials Section 427 459 if (mealVenues.isNotEmpty()) { ··· 469 501 } 470 502 } else { 471 503 items(mealVenues, key = { it.id }) { venue -> 472 - VenueCard(venue = venue) 504 + VenueCard( 505 + venue = venue, 506 + onFavoriteToggle = onFavoriteToggle, 507 + isFavorite = isFavorite 508 + ) 473 509 } 474 510 } 475 511 } ··· 507 543 rowVenues.forEach { venue -> 508 544 VenueCard( 509 545 venue = venue, 510 - modifier = Modifier.weight(1f) 546 + modifier = Modifier.weight(1f), 547 + onFavoriteToggle = onFavoriteToggle, 548 + isFavorite = isFavorite 511 549 ) 512 550 } 513 551 if (rowVenues.size == 1) { ··· 517 555 } 518 556 } else { 519 557 items(alwaysAvailableVenues, key = { "${it.id}-always" }) { venue -> 520 - VenueCard(venue = venue) 558 + VenueCard( 559 + venue = venue, 560 + onFavoriteToggle = onFavoriteToggle, 561 + isFavorite = isFavorite 562 + ) 521 563 } 522 564 } 523 565 }
+4 -1
android/app/src/main/java/com/wasupchucks/ui/screens/home/HomeUiState.kt
··· 17 17 val allMenus: Map<String, List<VenueMenu>> = emptyMap(), 18 18 val availableDates: List<LocalDate> = emptyList(), 19 19 val selectedDateIndex: Int = 0, 20 - val selectedFutureMealPhase: MealPhase = MealPhase.BREAKFAST 20 + val selectedFutureMealPhase: MealPhase = MealPhase.BREAKFAST, 21 + val favoriteItems: Set<String> = emptySet(), 22 + val favoriteKeywords: Set<String> = emptySet(), 23 + val showFavoritesManager: Boolean = false 21 24 ) { 22 25 val currentSlot: String 23 26 get() = if (status.isOpen) {
+63 -1
android/app/src/main/java/com/wasupchucks/ui/screens/home/HomeViewModel.kt
··· 6 6 import com.wasupchucks.data.model.MealPhase 7 7 import com.wasupchucks.data.model.MealSchedule 8 8 import com.wasupchucks.data.model.VenueMenu 9 + import com.wasupchucks.data.repository.FavoritesRepository 9 10 import com.wasupchucks.data.repository.MenuRepository 11 + import com.wasupchucks.notifications.NotificationScheduler 10 12 import dagger.hilt.android.lifecycle.HiltViewModel 11 13 import kotlinx.coroutines.delay 12 14 import kotlinx.coroutines.flow.MutableStateFlow ··· 21 23 22 24 @HiltViewModel 23 25 class HomeViewModel @Inject constructor( 24 - private val menuRepository: MenuRepository 26 + private val menuRepository: MenuRepository, 27 + private val favoritesRepository: FavoritesRepository, 28 + private val notificationScheduler: NotificationScheduler 25 29 ) : ViewModel() { 26 30 27 31 private val _uiState = MutableStateFlow(HomeUiState()) ··· 33 37 init { 34 38 loadMenu() 35 39 startStatusTimer() 40 + observeFavorites() 41 + } 42 + 43 + private fun observeFavorites() { 44 + viewModelScope.launch { 45 + favoritesRepository.favoriteItems.collect { items -> 46 + _uiState.update { it.copy(favoriteItems = items) } 47 + rescheduleNotifications() 48 + } 49 + } 50 + viewModelScope.launch { 51 + favoritesRepository.favoriteKeywords.collect { keywords -> 52 + _uiState.update { it.copy(favoriteKeywords = keywords) } 53 + rescheduleNotifications() 54 + } 55 + } 56 + } 57 + 58 + private fun rescheduleNotifications() { 59 + viewModelScope.launch { 60 + val state = _uiState.value 61 + if (state.allMenus.isNotEmpty()) { 62 + notificationScheduler.rescheduleNotifications( 63 + menus = state.allMenus, 64 + favoriteItems = state.favoriteItems, 65 + favoriteKeywords = state.favoriteKeywords 66 + ) 67 + } 68 + } 36 69 } 37 70 38 71 private fun startStatusTimer() { ··· 64 97 error = null 65 98 ) 66 99 } 100 + rescheduleNotifications() 67 101 } 68 102 .onFailure { error -> 69 103 _uiState.update { ··· 98 132 error = null 99 133 ) 100 134 } 135 + rescheduleNotifications() 101 136 } 102 137 .onFailure { error -> 103 138 _uiState.update { ··· 125 160 126 161 fun selectMeal(meal: MealSchedule?) { 127 162 _uiState.update { it.copy(selectedMeal = meal) } 163 + } 164 + 165 + fun toggleFavoriteItem(name: String) { 166 + viewModelScope.launch { 167 + favoritesRepository.toggleItem(name) 168 + } 169 + } 170 + 171 + fun addFavoriteKeyword(keyword: String) { 172 + viewModelScope.launch { 173 + favoritesRepository.addKeyword(keyword) 174 + } 175 + } 176 + 177 + fun removeFavoriteKeyword(keyword: String) { 178 + viewModelScope.launch { 179 + favoritesRepository.removeKeyword(keyword) 180 + } 181 + } 182 + 183 + fun showFavoritesManager(show: Boolean) { 184 + _uiState.update { it.copy(showFavoritesManager = show) } 185 + } 186 + 187 + fun isFavorite(item: com.wasupchucks.data.model.MenuItem): Boolean { 188 + val state = _uiState.value 189 + return favoritesRepository.isFavorite(item, state.favoriteItems, state.favoriteKeywords) 128 190 } 129 191 130 192 private fun parseSortedDates(menuMap: Map<String, List<VenueMenu>>): List<LocalDate> {
+1 -1
android/gradle/libs.versions.toml
··· 1 1 [versions] 2 - agp = "8.13.2" 2 + agp = "8.7.3" 3 3 kotlin = "2.1.0" 4 4 ksp = "2.1.0-1.0.29" 5 5 coreKtx = "1.15.0"