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 support for multi menu

+771 -245
+4
android/app/src/main/java/com/wasupchucks/data/model/MealSchedule.kt
··· 48 48 } 49 49 } 50 50 51 + fun scheduleFor(date: LocalDate): List<MealSchedule> { 52 + return scheduleFor(date.dayOfWeek) 53 + } 54 + 51 55 fun scheduleForToday(): List<MealSchedule> { 52 56 val cedarvilleZone = ZoneId.of("America/New_York") 53 57 val today = LocalDate.now(cedarvilleZone)
+21 -5
android/app/src/main/java/com/wasupchucks/ui/components/ScheduleCard.kt
··· 26 26 import androidx.compose.ui.unit.dp 27 27 import com.wasupchucks.R 28 28 import com.wasupchucks.data.model.ChucksStatus 29 + import com.wasupchucks.data.model.MealPhase 29 30 import com.wasupchucks.data.model.MealSchedule 30 31 31 32 @Composable 32 33 fun ScheduleCard( 33 - status: ChucksStatus, 34 34 schedule: List<MealSchedule>, 35 35 onMealClick: (MealSchedule) -> Unit, 36 - modifier: Modifier = Modifier 36 + modifier: Modifier = Modifier, 37 + status: ChucksStatus? = null, 38 + title: String? = null, 39 + selectedPhase: MealPhase? = null, 40 + onMealSelect: ((MealPhase) -> Unit)? = null 37 41 ) { 42 + val isTabMode = selectedPhase != null 43 + 38 44 Card( 39 45 modifier = modifier.fillMaxWidth(), 40 46 colors = CardDefaults.cardColors( ··· 48 54 .padding(20.dp) 49 55 ) { 50 56 Text( 51 - text = stringResource(R.string.todays_schedule), 57 + text = title ?: stringResource(R.string.todays_schedule), 52 58 style = MaterialTheme.typography.titleMedium, 53 59 fontWeight = FontWeight.SemiBold, 54 60 color = MaterialTheme.colorScheme.onSurface ··· 61 67 horizontalArrangement = Arrangement.spacedBy(12.dp) 62 68 ) { 63 69 schedule.forEach { meal -> 64 - val isCurrent = status.isOpen && status.currentPhase == meal.phase 70 + val isCurrent = if (isTabMode) { 71 + selectedPhase == meal.phase 72 + } else { 73 + status?.isOpen == true && status.currentPhase == meal.phase 74 + } 65 75 66 76 ScheduleButton( 67 77 meal = meal, 68 78 isCurrent = isCurrent, 69 - onClick = { onMealClick(meal) }, 79 + onClick = { 80 + if (isTabMode) { 81 + onMealSelect?.invoke(meal.phase) 82 + } else { 83 + onMealClick(meal) 84 + } 85 + }, 70 86 modifier = Modifier.weight(1f) 71 87 ) 72 88 }
+453 -187
android/app/src/main/java/com/wasupchucks/ui/screens/home/HomeScreen.kt
··· 1 1 package com.wasupchucks.ui.screens.home 2 2 3 + import android.content.Context 3 4 import android.content.Intent 4 5 import android.net.Uri 6 + import androidx.compose.animation.AnimatedContent 5 7 import androidx.compose.foundation.layout.Arrangement 6 8 import androidx.compose.foundation.layout.Box 7 9 import androidx.compose.foundation.layout.Column ··· 14 16 import androidx.compose.foundation.layout.padding 15 17 import androidx.compose.foundation.layout.widthIn 16 18 import androidx.compose.foundation.lazy.LazyColumn 19 + import androidx.compose.foundation.lazy.LazyListScope 17 20 import androidx.compose.foundation.lazy.items 21 + import androidx.compose.foundation.pager.HorizontalPager 22 + import androidx.compose.foundation.pager.rememberPagerState 18 23 import androidx.compose.material.icons.Icons 24 + import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft 25 + import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight 19 26 import androidx.compose.material.icons.filled.Schedule 20 27 import androidx.compose.material3.CircularProgressIndicator 21 28 import androidx.compose.material3.ExperimentalMaterial3Api 22 29 import androidx.compose.material3.HorizontalDivider 23 30 import androidx.compose.material3.Icon 31 + import androidx.compose.material3.IconButton 24 32 import androidx.compose.material3.MaterialTheme 25 33 import androidx.compose.material3.Scaffold 26 34 import androidx.compose.material3.Text ··· 30 38 import androidx.compose.material3.pulltorefresh.PullToRefreshBox 31 39 import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass 32 40 import androidx.compose.runtime.Composable 41 + import androidx.compose.runtime.LaunchedEffect 33 42 import androidx.compose.runtime.getValue 43 + import androidx.compose.runtime.rememberCoroutineScope 44 + import androidx.compose.runtime.snapshotFlow 34 45 import androidx.compose.ui.Alignment 35 46 import androidx.compose.ui.Modifier 36 47 import androidx.compose.ui.platform.LocalContext ··· 41 52 import androidx.hilt.navigation.compose.hiltViewModel 42 53 import androidx.lifecycle.compose.collectAsStateWithLifecycle 43 54 import com.wasupchucks.R 55 + import com.wasupchucks.data.model.MealPhase 56 + import com.wasupchucks.data.model.MealSchedule 57 + import com.wasupchucks.data.model.VenueMenu 44 58 import com.wasupchucks.ui.components.ErrorCard 45 59 import com.wasupchucks.ui.components.MealDetailSheet 46 60 import com.wasupchucks.ui.components.ScheduleCard 47 61 import com.wasupchucks.ui.components.StatusCard 48 62 import com.wasupchucks.ui.components.VenueCard 63 + import kotlinx.coroutines.launch 64 + import java.time.LocalDate 65 + import java.time.format.DateTimeFormatter 49 66 50 67 @OptIn(ExperimentalMaterial3Api::class) 51 68 @Composable ··· 55 72 ) { 56 73 val uiState by viewModel.uiState.collectAsStateWithLifecycle() 57 74 val context = LocalContext.current 75 + val scope = rememberCoroutineScope() 58 76 59 77 val isExpandedWidth = widthSizeClass == WindowWidthSizeClass.Expanded || 60 78 widthSizeClass == WindowWidthSizeClass.Medium 61 79 80 + val pageCount = uiState.availableDates.size.coerceAtLeast(1) 81 + val pagerState = rememberPagerState(pageCount = { pageCount }) 82 + 83 + // Sync pager -> viewModel 84 + LaunchedEffect(pagerState) { 85 + snapshotFlow { pagerState.currentPage }.collect { page -> 86 + if (page != uiState.selectedDateIndex) { 87 + viewModel.selectDate(page) 88 + } 89 + } 90 + } 91 + 92 + // Sync viewModel -> pager 93 + LaunchedEffect(uiState.selectedDateIndex) { 94 + if (pagerState.currentPage != uiState.selectedDateIndex) { 95 + pagerState.animateScrollToPage(uiState.selectedDateIndex) 96 + } 97 + } 98 + 62 99 Scaffold( 63 100 topBar = { 64 101 TopAppBar( ··· 78 115 .fillMaxSize() 79 116 .padding(paddingValues) 80 117 ) { 81 - LazyColumn( 82 - modifier = Modifier 83 - .fillMaxSize() 84 - .padding(horizontal = 16.dp), 85 - horizontalAlignment = Alignment.CenterHorizontally, 86 - verticalArrangement = Arrangement.spacedBy(16.dp) 87 - ) { 88 - // Status and Schedule cards 89 - item { 90 - if (isExpandedWidth) { 91 - Row( 92 - modifier = Modifier 93 - .widthIn(max = 900.dp) 94 - .fillMaxWidth(), 95 - horizontalArrangement = Arrangement.spacedBy(16.dp) 96 - ) { 97 - StatusCard( 98 - status = uiState.status, 99 - modifier = Modifier.weight(1f) 100 - ) 101 - ScheduleCard( 102 - status = uiState.status, 103 - schedule = uiState.todaySchedule, 104 - onMealClick = { viewModel.selectMeal(it) }, 105 - modifier = Modifier.weight(1f) 106 - ) 118 + Column(modifier = Modifier.fillMaxSize()) { 119 + // Day navigation header 120 + if (uiState.availableDates.size > 1) { 121 + DateNavigationHeader( 122 + selectedDateIndex = uiState.selectedDateIndex, 123 + availableDates = uiState.availableDates, 124 + onPrevious = { 125 + scope.launch { 126 + pagerState.animateScrollToPage(uiState.selectedDateIndex - 1) 127 + } 128 + }, 129 + onNext = { 130 + scope.launch { 131 + pagerState.animateScrollToPage(uiState.selectedDateIndex + 1) 132 + } 107 133 } 134 + ) 135 + } 136 + 137 + HorizontalPager( 138 + state = pagerState, 139 + modifier = Modifier.fillMaxSize(), 140 + beyondViewportPageCount = 1 141 + ) { page -> 142 + if (page == 0) { 143 + TodayPage( 144 + uiState = uiState, 145 + isExpandedWidth = isExpandedWidth, 146 + onMealClick = { viewModel.selectMeal(it) }, 147 + onRetry = { viewModel.loadMenu() }, 148 + context = context 149 + ) 108 150 } else { 109 - Column( 110 - modifier = Modifier.fillMaxWidth(), 111 - verticalArrangement = Arrangement.spacedBy(16.dp) 112 - ) { 113 - StatusCard(status = uiState.status) 114 - ScheduleCard( 115 - status = uiState.status, 116 - schedule = uiState.todaySchedule, 117 - onMealClick = { viewModel.selectMeal(it) } 118 - ) 119 - } 151 + FutureDayPage( 152 + uiState = uiState, 153 + page = page, 154 + isExpandedWidth = isExpandedWidth, 155 + onMealSelect = { viewModel.selectFutureMeal(it) }, 156 + onRetry = { viewModel.loadMenu() }, 157 + context = context 158 + ) 120 159 } 121 160 } 161 + } 162 + } 163 + } 122 164 123 - // Loading state 124 - if (uiState.isLoading) { 125 - item { 126 - Box( 127 - modifier = Modifier 128 - .fillMaxWidth() 129 - .height(200.dp), 130 - contentAlignment = Alignment.Center 131 - ) { 132 - CircularProgressIndicator() 133 - } 134 - } 165 + // Meal Detail Sheet 166 + uiState.selectedMeal?.let { meal -> 167 + MealDetailSheet( 168 + meal = meal, 169 + menu = uiState.todayMenu, 170 + onDismiss = { viewModel.selectMeal(null) } 171 + ) 172 + } 173 + } 174 + 175 + @Composable 176 + private fun DateNavigationHeader( 177 + selectedDateIndex: Int, 178 + availableDates: List<LocalDate>, 179 + onPrevious: () -> Unit, 180 + onNext: () -> Unit 181 + ) { 182 + Row( 183 + modifier = Modifier 184 + .fillMaxWidth() 185 + .padding(horizontal = 4.dp, vertical = 4.dp), 186 + verticalAlignment = Alignment.CenterVertically, 187 + horizontalArrangement = Arrangement.SpaceBetween 188 + ) { 189 + IconButton( 190 + onClick = onPrevious, 191 + enabled = selectedDateIndex > 0 192 + ) { 193 + Icon( 194 + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowLeft, 195 + contentDescription = stringResource(R.string.previous_day), 196 + tint = if (selectedDateIndex > 0) 197 + MaterialTheme.colorScheme.onSurface 198 + else 199 + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f) 200 + ) 201 + } 202 + 203 + AnimatedContent( 204 + targetState = selectedDateIndex, 205 + label = "dateLabel" 206 + ) { index -> 207 + Text( 208 + text = formatDateLabel(index, availableDates), 209 + style = MaterialTheme.typography.titleMedium, 210 + fontWeight = FontWeight.SemiBold, 211 + textAlign = TextAlign.Center 212 + ) 213 + } 214 + 215 + IconButton( 216 + onClick = onNext, 217 + enabled = selectedDateIndex < availableDates.size - 1 218 + ) { 219 + Icon( 220 + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, 221 + contentDescription = stringResource(R.string.next_day), 222 + tint = if (selectedDateIndex < availableDates.size - 1) 223 + MaterialTheme.colorScheme.onSurface 224 + else 225 + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f) 226 + ) 227 + } 228 + } 229 + } 230 + 231 + @Composable 232 + private fun TodayPage( 233 + uiState: HomeUiState, 234 + isExpandedWidth: Boolean, 235 + onMealClick: (MealSchedule) -> Unit, 236 + onRetry: () -> Unit, 237 + context: Context 238 + ) { 239 + val mealLabel = getMealLabel(uiState.currentSlot) 240 + 241 + LazyColumn( 242 + modifier = Modifier 243 + .fillMaxSize() 244 + .padding(horizontal = 16.dp), 245 + horizontalAlignment = Alignment.CenterHorizontally, 246 + verticalArrangement = Arrangement.spacedBy(16.dp) 247 + ) { 248 + // Status and Schedule cards 249 + item { 250 + if (isExpandedWidth) { 251 + Row( 252 + modifier = Modifier 253 + .widthIn(max = 900.dp) 254 + .fillMaxWidth(), 255 + horizontalArrangement = Arrangement.spacedBy(16.dp) 256 + ) { 257 + StatusCard( 258 + status = uiState.status, 259 + modifier = Modifier.weight(1f) 260 + ) 261 + ScheduleCard( 262 + schedule = uiState.todaySchedule, 263 + onMealClick = { onMealClick(it) }, 264 + modifier = Modifier.weight(1f), 265 + status = uiState.status 266 + ) 135 267 } 268 + } else { 269 + Column( 270 + modifier = Modifier.fillMaxWidth(), 271 + verticalArrangement = Arrangement.spacedBy(16.dp) 272 + ) { 273 + StatusCard(status = uiState.status) 274 + ScheduleCard( 275 + schedule = uiState.todaySchedule, 276 + onMealClick = { onMealClick(it) }, 277 + status = uiState.status 278 + ) 279 + } 280 + } 281 + } 136 282 137 - // Error state 138 - uiState.error?.let { error -> 139 - item { 140 - ErrorCard( 141 - error = error, 142 - onRetry = { viewModel.loadMenu() }, 143 - modifier = Modifier.widthIn(max = 900.dp) 144 - ) 145 - } 283 + // Loading state 284 + if (uiState.isLoading) { 285 + item { 286 + Box( 287 + modifier = Modifier 288 + .fillMaxWidth() 289 + .height(200.dp), 290 + contentAlignment = Alignment.Center 291 + ) { 292 + CircularProgressIndicator() 146 293 } 294 + } 295 + } 147 296 148 - // Menu content 149 - if (!uiState.isLoading && uiState.error == null) { 150 - // Meal Specials Section 151 - if (uiState.mealSpecificVenues.isNotEmpty()) { 152 - item { 153 - Row( 154 - modifier = Modifier 155 - .widthIn(max = 900.dp) 156 - .fillMaxWidth() 157 - .padding(horizontal = 4.dp), 158 - verticalAlignment = Alignment.CenterVertically, 159 - horizontalArrangement = Arrangement.spacedBy(8.dp) 160 - ) { 161 - Icon( 162 - imageVector = Icons.Filled.Schedule, 163 - contentDescription = null, 164 - tint = MaterialTheme.colorScheme.primary 165 - ) 166 - Text( 167 - text = stringResource(R.string.meal_specials, getMealLabel(uiState.currentSlot)), 168 - style = MaterialTheme.typography.titleMedium, 169 - fontWeight = FontWeight.SemiBold 170 - ) 171 - } 172 - } 297 + // Error state 298 + uiState.error?.let { error -> 299 + item { 300 + ErrorCard( 301 + error = error, 302 + onRetry = onRetry, 303 + modifier = Modifier.widthIn(max = 900.dp) 304 + ) 305 + } 306 + } 173 307 174 - if (isExpandedWidth) { 175 - // Two-column layout for expanded width 176 - val chunkedVenues = uiState.mealSpecificVenues.chunked(2) 177 - items(chunkedVenues, key = { it.map { v -> v.id }.joinToString() }) { rowVenues -> 178 - Row( 179 - modifier = Modifier 180 - .widthIn(max = 900.dp) 181 - .fillMaxWidth(), 182 - horizontalArrangement = Arrangement.spacedBy(16.dp) 183 - ) { 184 - rowVenues.forEach { venue -> 185 - VenueCard( 186 - venue = venue, 187 - modifier = Modifier.weight(1f) 188 - ) 189 - } 190 - if (rowVenues.size == 1) { 191 - Spacer(modifier = Modifier.weight(1f)) 192 - } 193 - } 194 - } 195 - } else { 196 - items(uiState.mealSpecificVenues, key = { it.id }) { venue -> 197 - VenueCard(venue = venue) 198 - } 199 - } 200 - } 308 + // Menu content 309 + if (!uiState.isLoading && uiState.error == null) { 310 + MenuVenueContent( 311 + mealVenues = uiState.mealSpecificVenues, 312 + alwaysAvailableVenues = uiState.alwaysAvailableVenues, 313 + mealLabel = mealLabel, 314 + isExpandedWidth = isExpandedWidth 315 + ) 316 + } 201 317 202 - // Always Available Section 203 - if (uiState.alwaysAvailableVenues.isNotEmpty()) { 204 - item { 205 - Row( 206 - modifier = Modifier 207 - .widthIn(max = 900.dp) 208 - .fillMaxWidth() 209 - .padding(top = 8.dp), 210 - verticalAlignment = Alignment.CenterVertically, 211 - horizontalArrangement = Arrangement.spacedBy(12.dp) 212 - ) { 213 - HorizontalDivider(modifier = Modifier.weight(1f)) 214 - Text( 215 - text = stringResource(R.string.always_available), 216 - style = MaterialTheme.typography.labelMedium, 217 - color = MaterialTheme.colorScheme.onSurfaceVariant 218 - ) 219 - HorizontalDivider(modifier = Modifier.weight(1f)) 220 - } 221 - } 318 + // Footer 319 + item { FooterContent(context = context) } 320 + item { Spacer(modifier = Modifier.height(16.dp)) } 321 + } 322 + } 222 323 223 - if (isExpandedWidth) { 224 - val chunkedVenues = uiState.alwaysAvailableVenues.chunked(2) 225 - items(chunkedVenues, key = { it.map { v -> v.id }.joinToString() + "-always" }) { rowVenues -> 226 - Row( 227 - modifier = Modifier 228 - .widthIn(max = 900.dp) 229 - .fillMaxWidth(), 230 - horizontalArrangement = Arrangement.spacedBy(16.dp) 231 - ) { 232 - rowVenues.forEach { venue -> 233 - VenueCard( 234 - venue = venue, 235 - modifier = Modifier.weight(1f) 236 - ) 237 - } 238 - if (rowVenues.size == 1) { 239 - Spacer(modifier = Modifier.weight(1f)) 240 - } 241 - } 242 - } 243 - } else { 244 - items(uiState.alwaysAvailableVenues, key = { "${it.id}-always" }) { venue -> 245 - VenueCard(venue = venue) 246 - } 247 - } 248 - } 324 + @Composable 325 + private fun FutureDayPage( 326 + uiState: HomeUiState, 327 + page: Int, 328 + isExpandedWidth: Boolean, 329 + onMealSelect: (MealPhase) -> Unit, 330 + onRetry: () -> Unit, 331 + context: Context 332 + ) { 333 + val date = uiState.availableDates.getOrNull(page) 334 + val schedule = if (date != null) { 335 + MealSchedule.scheduleFor(date) 336 + } else { 337 + emptyList() 338 + } 339 + 340 + // For future pages, get venue data based on the selected date 341 + val dateMenu = if (date != null) { 342 + uiState.allMenus[date.toString()] ?: emptyList() 343 + } else { 344 + emptyList() 345 + } 346 + 347 + val mealVenues = dateMenu 348 + .filter { it.slot == uiState.selectedFutureMealPhase.apiSlot } 349 + .sortedBy { it.venue } 350 + 351 + val alwaysAvailable = dateMenu 352 + .filter { it.slot == "anytime" } 353 + .sortedBy { it.venue } 354 + 355 + val scheduleTitle = if (date != null) { 356 + stringResource(R.string.schedule_for, formatDateLabel(page, uiState.availableDates)) 357 + } else { 358 + stringResource(R.string.todays_schedule) 359 + } 360 + 361 + LazyColumn( 362 + modifier = Modifier 363 + .fillMaxSize() 364 + .padding(horizontal = 16.dp), 365 + horizontalAlignment = Alignment.CenterHorizontally, 366 + verticalArrangement = Arrangement.spacedBy(16.dp) 367 + ) { 368 + // Schedule card in tab mode 369 + item { 370 + ScheduleCard( 371 + schedule = schedule, 372 + onMealClick = {}, 373 + title = scheduleTitle, 374 + selectedPhase = uiState.selectedFutureMealPhase, 375 + onMealSelect = onMealSelect 376 + ) 377 + } 378 + 379 + // Loading state 380 + if (uiState.isLoading) { 381 + item { 382 + Box( 383 + modifier = Modifier 384 + .fillMaxWidth() 385 + .height(200.dp), 386 + contentAlignment = Alignment.Center 387 + ) { 388 + CircularProgressIndicator() 249 389 } 390 + } 391 + } 250 392 251 - // Footer 252 - item { 253 - Column( 254 - modifier = Modifier 255 - .fillMaxWidth() 256 - .padding(vertical = 16.dp), 257 - horizontalAlignment = Alignment.CenterHorizontally 258 - ) { 259 - Text( 260 - text = stringResource(R.string.made_with_love), 261 - style = MaterialTheme.typography.labelSmall, 262 - color = MaterialTheme.colorScheme.onSurfaceVariant 393 + // Error state 394 + uiState.error?.let { error -> 395 + item { 396 + ErrorCard( 397 + error = error, 398 + onRetry = onRetry, 399 + modifier = Modifier.widthIn(max = 900.dp) 400 + ) 401 + } 402 + } 403 + 404 + // Menu content for future day 405 + if (!uiState.isLoading && uiState.error == null) { 406 + MenuVenueContent( 407 + mealVenues = mealVenues, 408 + alwaysAvailableVenues = alwaysAvailable, 409 + mealLabel = uiState.selectedFutureMealPhase.displayName, 410 + isExpandedWidth = isExpandedWidth 411 + ) 412 + } 413 + 414 + // Footer 415 + item { FooterContent(context = context) } 416 + item { Spacer(modifier = Modifier.height(16.dp)) } 417 + } 418 + } 419 + 420 + private fun LazyListScope.MenuVenueContent( 421 + mealVenues: List<VenueMenu>, 422 + alwaysAvailableVenues: List<VenueMenu>, 423 + mealLabel: String, 424 + isExpandedWidth: Boolean 425 + ) { 426 + // Meal Specials Section 427 + if (mealVenues.isNotEmpty()) { 428 + item(key = "meal-header") { 429 + Row( 430 + modifier = Modifier 431 + .widthIn(max = 900.dp) 432 + .fillMaxWidth() 433 + .padding(horizontal = 4.dp), 434 + verticalAlignment = Alignment.CenterVertically, 435 + horizontalArrangement = Arrangement.spacedBy(8.dp) 436 + ) { 437 + Icon( 438 + imageVector = Icons.Filled.Schedule, 439 + contentDescription = null, 440 + tint = MaterialTheme.colorScheme.primary 441 + ) 442 + Text( 443 + text = stringResource(R.string.meal_specials, mealLabel), 444 + style = MaterialTheme.typography.titleMedium, 445 + fontWeight = FontWeight.SemiBold 446 + ) 447 + } 448 + } 449 + 450 + if (isExpandedWidth) { 451 + val chunkedVenues = mealVenues.chunked(2) 452 + items(chunkedVenues, key = { it.map { v -> v.id }.joinToString() }) { rowVenues -> 453 + Row( 454 + modifier = Modifier 455 + .widthIn(max = 900.dp) 456 + .fillMaxWidth(), 457 + horizontalArrangement = Arrangement.spacedBy(16.dp) 458 + ) { 459 + rowVenues.forEach { venue -> 460 + VenueCard( 461 + venue = venue, 462 + modifier = Modifier.weight(1f) 263 463 ) 264 - TextButton( 265 - onClick = { 266 - val intent = Intent(Intent.ACTION_VIEW, Uri.parse(context.getString(R.string.privacy_policy_url))) 267 - context.startActivity(intent) 268 - } 269 - ) { 270 - Text( 271 - text = stringResource(R.string.privacy_policy), 272 - style = MaterialTheme.typography.labelSmall 273 - ) 274 - } 464 + } 465 + if (rowVenues.size == 1) { 466 + Spacer(modifier = Modifier.weight(1f)) 275 467 } 276 468 } 469 + } 470 + } else { 471 + items(mealVenues, key = { it.id }) { venue -> 472 + VenueCard(venue = venue) 473 + } 474 + } 475 + } 277 476 278 - item { 279 - Spacer(modifier = Modifier.height(16.dp)) 477 + // Always Available Section 478 + if (alwaysAvailableVenues.isNotEmpty()) { 479 + item(key = "always-header") { 480 + Row( 481 + modifier = Modifier 482 + .widthIn(max = 900.dp) 483 + .fillMaxWidth() 484 + .padding(top = 8.dp), 485 + verticalAlignment = Alignment.CenterVertically, 486 + horizontalArrangement = Arrangement.spacedBy(12.dp) 487 + ) { 488 + HorizontalDivider(modifier = Modifier.weight(1f)) 489 + Text( 490 + text = stringResource(R.string.always_available), 491 + style = MaterialTheme.typography.labelMedium, 492 + color = MaterialTheme.colorScheme.onSurfaceVariant 493 + ) 494 + HorizontalDivider(modifier = Modifier.weight(1f)) 495 + } 496 + } 497 + 498 + if (isExpandedWidth) { 499 + val chunkedVenues = alwaysAvailableVenues.chunked(2) 500 + items(chunkedVenues, key = { it.map { v -> v.id }.joinToString() + "-always" }) { rowVenues -> 501 + Row( 502 + modifier = Modifier 503 + .widthIn(max = 900.dp) 504 + .fillMaxWidth(), 505 + horizontalArrangement = Arrangement.spacedBy(16.dp) 506 + ) { 507 + rowVenues.forEach { venue -> 508 + VenueCard( 509 + venue = venue, 510 + modifier = Modifier.weight(1f) 511 + ) 512 + } 513 + if (rowVenues.size == 1) { 514 + Spacer(modifier = Modifier.weight(1f)) 515 + } 280 516 } 281 517 } 518 + } else { 519 + items(alwaysAvailableVenues, key = { "${it.id}-always" }) { venue -> 520 + VenueCard(venue = venue) 521 + } 282 522 } 283 523 } 524 + } 284 525 285 - // Meal Detail Sheet 286 - uiState.selectedMeal?.let { meal -> 287 - MealDetailSheet( 288 - meal = meal, 289 - menu = uiState.todayMenu, 290 - onDismiss = { viewModel.selectMeal(null) } 526 + @Composable 527 + private fun FooterContent(context: Context) { 528 + Column( 529 + modifier = Modifier 530 + .fillMaxWidth() 531 + .padding(vertical = 16.dp), 532 + horizontalAlignment = Alignment.CenterHorizontally 533 + ) { 534 + Text( 535 + text = stringResource(R.string.made_with_love), 536 + style = MaterialTheme.typography.labelSmall, 537 + color = MaterialTheme.colorScheme.onSurfaceVariant 291 538 ) 539 + TextButton( 540 + onClick = { 541 + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(context.getString(R.string.privacy_policy_url))) 542 + context.startActivity(intent) 543 + } 544 + ) { 545 + Text( 546 + text = stringResource(R.string.privacy_policy), 547 + style = MaterialTheme.typography.labelSmall 548 + ) 549 + } 292 550 } 551 + } 552 + 553 + @Composable 554 + private fun formatDateLabel(index: Int, dates: List<LocalDate>): String { 555 + if (index == 0) return stringResource(R.string.today) 556 + if (index == 1) return stringResource(R.string.tomorrow) 557 + val date = dates.getOrNull(index) ?: return "" 558 + return date.format(DateTimeFormatter.ofPattern("EEEE, MMM d")) 293 559 } 294 560 295 561 @Composable
+32 -1
android/app/src/main/java/com/wasupchucks/ui/screens/home/HomeUiState.kt
··· 1 1 package com.wasupchucks.ui.screens.home 2 2 3 3 import com.wasupchucks.data.model.ChucksStatus 4 + import com.wasupchucks.data.model.MealPhase 4 5 import com.wasupchucks.data.model.MealSchedule 5 6 import com.wasupchucks.data.model.VenueMenu 7 + import java.time.LocalDate 6 8 7 9 data class HomeUiState( 8 10 val status: ChucksStatus = ChucksStatus.calculate(), ··· 11 13 val isLoading: Boolean = true, 12 14 val error: Throwable? = null, 13 15 val selectedMeal: MealSchedule? = null, 14 - val isRefreshing: Boolean = false 16 + val isRefreshing: Boolean = false, 17 + val allMenus: Map<String, List<VenueMenu>> = emptyMap(), 18 + val availableDates: List<LocalDate> = emptyList(), 19 + val selectedDateIndex: Int = 0, 20 + val selectedFutureMealPhase: MealPhase = MealPhase.BREAKFAST 15 21 ) { 16 22 val currentSlot: String 17 23 get() = if (status.isOpen) { ··· 27 33 28 34 val alwaysAvailableVenues: List<VenueMenu> 29 35 get() = todayMenu 36 + .filter { it.slot == "anytime" } 37 + .sortedBy { it.venue } 38 + 39 + val isViewingToday: Boolean 40 + get() = selectedDateIndex == 0 41 + 42 + val selectedDateMenu: List<VenueMenu> 43 + get() { 44 + val date = availableDates.getOrNull(selectedDateIndex) ?: return emptyList() 45 + return allMenus[date.toString()] ?: emptyList() 46 + } 47 + 48 + val selectedDateSchedule: List<MealSchedule> 49 + get() { 50 + val date = availableDates.getOrNull(selectedDateIndex) ?: return emptyList() 51 + return MealSchedule.scheduleFor(date) 52 + } 53 + 54 + val futureMealVenues: List<VenueMenu> 55 + get() = selectedDateMenu 56 + .filter { it.slot == selectedFutureMealPhase.apiSlot } 57 + .sortedBy { it.venue } 58 + 59 + val futureAlwaysAvailableVenues: List<VenueMenu> 60 + get() = selectedDateMenu 30 61 .filter { it.slot == "anytime" } 31 62 .sortedBy { it.venue } 32 63 }
+43 -10
android/app/src/main/java/com/wasupchucks/ui/screens/home/HomeViewModel.kt
··· 3 3 import androidx.lifecycle.ViewModel 4 4 import androidx.lifecycle.viewModelScope 5 5 import com.wasupchucks.data.model.ChucksStatus 6 + import com.wasupchucks.data.model.MealPhase 6 7 import com.wasupchucks.data.model.MealSchedule 8 + import com.wasupchucks.data.model.VenueMenu 7 9 import com.wasupchucks.data.repository.MenuRepository 8 10 import dagger.hilt.android.lifecycle.HiltViewModel 9 11 import kotlinx.coroutines.delay ··· 14 16 import kotlinx.coroutines.launch 15 17 import java.time.LocalDate 16 18 import java.time.ZoneId 19 + import java.time.format.DateTimeFormatter 17 20 import javax.inject.Inject 18 21 19 22 @HiltViewModel ··· 24 27 private val _uiState = MutableStateFlow(HomeUiState()) 25 28 val uiState: StateFlow<HomeUiState> = _uiState.asStateFlow() 26 29 30 + private val cedarvilleZone = ZoneId.of("America/New_York") 31 + private val dateFormatter = DateTimeFormatter.ISO_LOCAL_DATE 32 + 27 33 init { 28 34 loadMenu() 29 35 startStatusTimer() ··· 42 48 viewModelScope.launch { 43 49 _uiState.update { it.copy(isLoading = true, error = null) } 44 50 45 - val cedarvilleZone = ZoneId.of("America/New_York") 46 - val today = LocalDate.now(cedarvilleZone) 51 + menuRepository.fetchMenu() 52 + .onSuccess { menuMap -> 53 + val today = LocalDate.now(cedarvilleZone) 54 + val todayMenu = menuMap[today.toString()] ?: emptyList() 55 + val dates = parseSortedDates(menuMap) 47 56 48 - menuRepository.getMenuForDate(today) 49 - .onSuccess { menu -> 50 57 _uiState.update { 51 58 it.copy( 52 - todayMenu = menu, 59 + allMenus = menuMap, 60 + availableDates = dates, 61 + todayMenu = todayMenu, 53 62 todaySchedule = MealSchedule.scheduleForToday(), 54 63 isLoading = false, 55 64 error = null ··· 73 82 74 83 menuRepository.invalidateCache() 75 84 76 - val cedarvilleZone = ZoneId.of("America/New_York") 77 - val today = LocalDate.now(cedarvilleZone) 85 + menuRepository.fetchMenu() 86 + .onSuccess { menuMap -> 87 + val today = LocalDate.now(cedarvilleZone) 88 + val todayMenu = menuMap[today.toString()] ?: emptyList() 89 + val dates = parseSortedDates(menuMap) 78 90 79 - menuRepository.getMenuForDate(today) 80 - .onSuccess { menu -> 81 91 _uiState.update { 82 92 it.copy( 83 - todayMenu = menu, 93 + allMenus = menuMap, 94 + availableDates = dates, 95 + todayMenu = todayMenu, 84 96 todaySchedule = MealSchedule.scheduleForToday(), 85 97 isRefreshing = false, 86 98 error = null ··· 98 110 } 99 111 } 100 112 113 + fun selectDate(index: Int) { 114 + _uiState.update { 115 + it.copy( 116 + selectedDateIndex = index, 117 + selectedFutureMealPhase = MealPhase.BREAKFAST 118 + ) 119 + } 120 + } 121 + 122 + fun selectFutureMeal(phase: MealPhase) { 123 + _uiState.update { it.copy(selectedFutureMealPhase = phase) } 124 + } 125 + 101 126 fun selectMeal(meal: MealSchedule?) { 102 127 _uiState.update { it.copy(selectedMeal = meal) } 128 + } 129 + 130 + private fun parseSortedDates(menuMap: Map<String, List<VenueMenu>>): List<LocalDate> { 131 + return menuMap.keys 132 + .mapNotNull { key -> 133 + runCatching { LocalDate.parse(key, dateFormatter) }.getOrNull() 134 + } 135 + .sorted() 103 136 } 104 137 }
+7
android/app/src/main/res/values/strings.xml
··· 18 18 <string name="lunch">Lunch</string> 19 19 <string name="dinner">Dinner</string> 20 20 21 + <!-- Day Navigation --> 22 + <string name="today">Today</string> 23 + <string name="tomorrow">Tomorrow</string> 24 + <string name="previous_day">Previous day</string> 25 + <string name="next_day">Next day</string> 26 + <string name="schedule_for">%1$s\'s Schedule</string> 27 + 21 28 <!-- Sections --> 22 29 <string name="todays_schedule">Today\'s Schedule</string> 23 30 <string name="meal_specials">%1$s Specials</string>
+211 -42
ios/wasup-chucks/ContentView.swift
··· 13 13 struct ContentView: View { 14 14 @State private var status = ChucksStatus.calculate() 15 15 @State private var todayMenu: [VenueMenu] = [] 16 + @State private var allMenus: MenuResponse = [:] 17 + @State private var selectedDateIndex: Int = 0 18 + @State private var selectedFutureMeal: MealPhase = .breakfast 16 19 @State private var isLoading = true 17 20 @State private var loadError: Error? = nil 18 21 @State private var selectedMeal: MealSchedule? = nil 19 22 @Environment(\.horizontalSizeClass) private var horizontalSizeClass 20 23 21 24 let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() 22 - 25 + 23 26 var currentSlot: String { 24 27 if status.isOpen { 25 28 return status.currentPhase.apiSlot ··· 28 31 } 29 32 return "lunch" 30 33 } 31 - 34 + 32 35 private var isRegularWidth: Bool { 33 36 horizontalSizeClass == .regular 34 37 } 35 38 39 + var availableDates: [String] { 40 + allMenus.keys.sorted() 41 + } 42 + 43 + var isViewingToday: Bool { 44 + selectedDateIndex == 0 45 + } 46 + 47 + var selectedDateMenu: [VenueMenu] { 48 + guard selectedDateIndex < availableDates.count else { return [] } 49 + return allMenus[availableDates[selectedDateIndex]] ?? [] 50 + } 51 + 52 + var selectedDateSchedule: [MealSchedule] { 53 + guard selectedDateIndex < availableDates.count else { return [] } 54 + let dateKey = availableDates[selectedDateIndex] 55 + let formatter = DateFormatter() 56 + formatter.dateFormat = "yyyy-MM-dd" 57 + formatter.timeZone = TimeZone(identifier: "America/New_York") 58 + guard let date = formatter.date(from: dateKey) else { return [] } 59 + let weekday = CedarvilleTime.calendar.component(.weekday, from: date) 60 + return MealSchedule.schedule(for: weekday) 61 + } 62 + 63 + var futureMealVenues: [VenueMenu] { 64 + selectedDateMenu.filter { $0.slot == selectedFutureMeal.apiSlot } 65 + .sorted { $0.venue < $1.venue } 66 + } 67 + 68 + var futureAlwaysAvailable: [VenueMenu] { 69 + selectedDateMenu.filter { $0.slot == "anytime" } 70 + .sorted { $0.venue < $1.venue } 71 + } 72 + 36 73 var body: some View { 37 74 NavigationStack { 38 75 ScrollView { 39 76 VStack(spacing: 16) { 40 - if isRegularWidth { 41 - // iPad: Two-column layout for top cards 42 - HStack(spacing: 16) { 77 + if availableDates.count > 1 { 78 + DateNavigationHeader( 79 + selectedDateIndex: $selectedDateIndex, 80 + selectedFutureMeal: $selectedFutureMeal, 81 + availableDates: availableDates 82 + ) 83 + } 84 + 85 + if isViewingToday { 86 + // Today's view 87 + if isRegularWidth { 88 + HStack(spacing: 16) { 89 + StatusCard(status: status) 90 + .frame(maxHeight: .infinity, alignment: .top) 91 + ScheduleCard(status: status, todayMenu: todayMenu, selectedMeal: $selectedMeal) 92 + .frame(maxHeight: .infinity, alignment: .top) 93 + } 94 + .fixedSize(horizontal: false, vertical: true) 95 + } else { 43 96 StatusCard(status: status) 44 - .frame(maxHeight: .infinity, alignment: .top) 45 97 ScheduleCard(status: status, todayMenu: todayMenu, selectedMeal: $selectedMeal) 46 - .frame(maxHeight: .infinity, alignment: .top) 47 98 } 48 - .fixedSize(horizontal: false, vertical: true) 99 + 100 + if isLoading { 101 + ProgressView() 102 + .frame(maxWidth: .infinity, minHeight: 200) 103 + } else if let error = loadError { 104 + ErrorCard(error: error) { 105 + Task { await loadMenu() } 106 + } 107 + } else { 108 + CurrentMealView(menu: todayMenu, slot: currentSlot, isOpen: status.isOpen, isRegularWidth: isRegularWidth) 109 + } 49 110 } else { 50 - // iPhone: Stacked layout 51 - StatusCard(status: status) 52 - ScheduleCard(status: status, todayMenu: todayMenu, selectedMeal: $selectedMeal) 53 - } 111 + // Future day view 112 + ScheduleCard( 113 + schedule: selectedDateSchedule, 114 + selectedMealPhase: $selectedFutureMeal 115 + ) 54 116 55 - if isLoading { 56 - ProgressView() 57 - .frame(maxWidth: .infinity, minHeight: 200) 58 - } else if let error = loadError { 59 - ErrorCard(error: error) { 60 - Task { await loadMenu() } 117 + if isLoading { 118 + ProgressView() 119 + .frame(maxWidth: .infinity, minHeight: 200) 120 + } else if let error = loadError { 121 + ErrorCard(error: error) { 122 + Task { await loadMenu() } 123 + } 124 + } else { 125 + CurrentMealView(menu: selectedDateMenu, slot: selectedFutureMeal.apiSlot, isOpen: true, isRegularWidth: isRegularWidth) 61 126 } 62 - } else { 63 - CurrentMealView(menu: todayMenu, slot: currentSlot, isOpen: status.isOpen, isRegularWidth: isRegularWidth) 64 127 } 65 128 66 129 // Footer ··· 87 150 await loadMenu() 88 151 } 89 152 .refreshable { 153 + selectedDateIndex = 0 90 154 await ChucksService.shared.invalidateCache() 91 155 await loadMenu() 92 156 } ··· 95 159 } 96 160 } 97 161 } 98 - 162 + 99 163 func loadMenu() async { 100 164 isLoading = true 101 165 loadError = nil 102 166 do { 103 167 let menu = try await ChucksService.shared.fetchMenu() 168 + allMenus = menu 104 169 let dateFormatter = DateFormatter() 105 170 dateFormatter.dateFormat = "yyyy-MM-dd" 106 171 dateFormatter.timeZone = TimeZone(identifier: "America/New_York") ··· 114 179 } 115 180 } 116 181 182 + // MARK: - Date Navigation Header 183 + 184 + struct DateNavigationHeader: View { 185 + @Binding var selectedDateIndex: Int 186 + @Binding var selectedFutureMeal: MealPhase 187 + let availableDates: [String] 188 + 189 + private func dateLabel(for index: Int) -> String { 190 + guard index < availableDates.count else { return "" } 191 + if index == 0 { return "Today" } 192 + if index == 1 { return "Tomorrow" } 193 + let dateKey = availableDates[index] 194 + let formatter = DateFormatter() 195 + formatter.dateFormat = "yyyy-MM-dd" 196 + formatter.timeZone = TimeZone(identifier: "America/New_York") 197 + guard let date = formatter.date(from: dateKey) else { return dateKey } 198 + let displayFormatter = DateFormatter() 199 + displayFormatter.dateFormat = "EEEE, MMM d" 200 + displayFormatter.timeZone = TimeZone(identifier: "America/New_York") 201 + return displayFormatter.string(from: date) 202 + } 203 + 204 + var body: some View { 205 + HStack { 206 + Button { 207 + selectedDateIndex -= 1 208 + selectedFutureMeal = .breakfast 209 + } label: { 210 + Image(systemName: "chevron.left") 211 + .font(.title3.weight(.semibold)) 212 + } 213 + .disabled(selectedDateIndex <= 0) 214 + .modifier(LiquidGlassButtonModifier()) 215 + 216 + Spacer() 217 + 218 + Text(dateLabel(for: selectedDateIndex)) 219 + .font(.headline) 220 + .animation(.easeInOut, value: selectedDateIndex) 221 + 222 + Spacer() 223 + 224 + Button { 225 + selectedDateIndex += 1 226 + selectedFutureMeal = .breakfast 227 + } label: { 228 + Image(systemName: "chevron.right") 229 + .font(.title3.weight(.semibold)) 230 + } 231 + .disabled(selectedDateIndex >= availableDates.count - 1) 232 + .modifier(LiquidGlassButtonModifier()) 233 + } 234 + .padding(.horizontal, 8) 235 + .padding(.vertical, 4) 236 + } 237 + } 238 + 117 239 // MARK: - Status Card 118 240 119 241 struct StatusCard: View { ··· 214 336 // MARK: - Schedule Card 215 337 216 338 struct ScheduleCard: View { 217 - let status: ChucksStatus 218 - let todayMenu: [VenueMenu] 339 + let title: String 340 + let schedule: [MealSchedule] 341 + let status: ChucksStatus? 219 342 @Binding var selectedMeal: MealSchedule? 220 - 221 - var schedule: [MealSchedule] { 222 - MealSchedule.schedule(for: CedarvilleTime.calendar.component(.weekday, from: Date())) 343 + var selectedMealPhase: Binding<MealPhase>? 344 + 345 + /// Today mode: shows schedule with sheet on tap 346 + init(status: ChucksStatus, todayMenu: [VenueMenu], selectedMeal: Binding<MealSchedule?>) { 347 + self.title = "Today's Schedule" 348 + self.schedule = MealSchedule.schedule(for: CedarvilleTime.calendar.component(.weekday, from: Date())) 349 + self.status = status 350 + self._selectedMeal = selectedMeal 351 + self.selectedMealPhase = nil 223 352 } 224 - 353 + 354 + /// Future day mode: shows schedule with tab selection 355 + init(schedule: [MealSchedule], selectedMealPhase: Binding<MealPhase>) { 356 + self.title = "Schedule" 357 + self.schedule = schedule 358 + self.status = nil 359 + self._selectedMeal = .constant(nil) 360 + self.selectedMealPhase = selectedMealPhase 361 + } 362 + 225 363 var body: some View { 226 364 VStack(alignment: .leading, spacing: 12) { 227 - Text("Today's Schedule") 365 + Text(title) 228 366 .font(.headline) 229 - 367 + 230 368 HStack(spacing: 8) { 231 369 ForEach(schedule, id: \.phase) { meal in 232 - ScheduleButton( 233 - meal: meal, 234 - isCurrent: status.isOpen && status.currentPhase == meal.phase 235 - ) { 236 - selectedMeal = meal 370 + if let binding = selectedMealPhase { 371 + ScheduleButton( 372 + meal: meal, 373 + isCurrent: false, 374 + isSelected: binding.wrappedValue == meal.phase 375 + ) { 376 + binding.wrappedValue = meal.phase 377 + } 378 + } else { 379 + ScheduleButton( 380 + meal: meal, 381 + isCurrent: status?.isOpen == true && status?.currentPhase == meal.phase 382 + ) { 383 + selectedMeal = meal 384 + } 237 385 } 238 386 } 239 387 } ··· 248 396 struct ScheduleButton: View { 249 397 let meal: MealSchedule 250 398 let isCurrent: Bool 399 + var isSelected: Bool = false 251 400 let action: () -> Void 252 - 401 + 402 + private var isHighlighted: Bool { 403 + isCurrent || isSelected 404 + } 405 + 406 + private var highlightColor: Color { 407 + isSelected ? .orange : .green 408 + } 409 + 253 410 var body: some View { 254 411 Button(action: action) { 255 412 VStack(spacing: 6) { 256 413 Image(systemName: meal.phase.icon) 257 414 .font(.title3) 258 415 .accessibilityHidden(true) 259 - 416 + 260 417 Text(meal.phase.shortName) 261 418 .font(.caption.weight(.medium)) 262 - 419 + 263 420 Text("\(formatTime(meal.startHour, meal.startMinute))-\(formatTime(meal.endHour, meal.endMinute))") 264 421 .font(.caption2) 265 422 .foregroundStyle(.secondary) 266 423 } 267 424 .frame(maxWidth: .infinity) 268 425 .padding(.vertical, 12) 269 - .background(isCurrent ? Color.green.opacity(0.15) : Color.clear, in: RoundedRectangle(cornerRadius: 12)) 426 + .background(isHighlighted ? highlightColor.opacity(0.15) : Color.clear, in: RoundedRectangle(cornerRadius: 12)) 270 427 .overlay( 271 428 RoundedRectangle(cornerRadius: 12) 272 - .stroke(isCurrent ? Color.green : Color.clear, lineWidth: 2) 429 + .stroke(isHighlighted ? highlightColor : Color.clear, lineWidth: 2) 273 430 ) 274 431 } 275 432 .buttonStyle(.plain) 276 - .foregroundStyle(isCurrent ? .green : .primary) 433 + .foregroundStyle(isHighlighted ? highlightColor : .primary) 277 434 .selectionHaptic(trigger: meal.phase) 278 - .accessibilityLabel("\(meal.phase.shortName), \(formatTime(meal.startHour, meal.startMinute)) to \(formatTime(meal.endHour, meal.endMinute))\(isCurrent ? ", current meal" : "")") 435 + .accessibilityLabel("\(meal.phase.shortName), \(formatTime(meal.startHour, meal.startMinute)) to \(formatTime(meal.endHour, meal.endMinute))\(isCurrent ? ", current meal" : "")\(isSelected ? ", selected" : "")") 279 436 .accessibilityHint("Double tap to view menu") 280 437 } 281 - 438 + 282 439 func formatTime(_ hour: Int, _ minute: Int) -> String { 283 440 let period = hour >= 12 ? "PM" : "AM" 284 441 let displayHour = hour > 12 ? hour - 12 : (hour == 0 ? 12 : hour) ··· 578 735 .padding(.vertical, 3) 579 736 .background(color.opacity(0.15), in: RoundedRectangle(cornerRadius: 4)) 580 737 .accessibilityHidden(true) 738 + } 739 + } 740 + 741 + // MARK: - Liquid Glass Button 742 + 743 + struct LiquidGlassButtonModifier: ViewModifier { 744 + func body(content: Content) -> some View { 745 + if #available(iOS 26.0, *) { 746 + content.buttonStyle(.glass) 747 + } else { 748 + content.buttonStyle(.bordered) 749 + } 581 750 } 582 751 } 583 752