Skip to content

Commit 6d699ca

Browse files
committed
add day picker context menu for route preview
1 parent d23aacb commit 6d699ca

File tree

2 files changed

+290
-12
lines changed

2 files changed

+290
-12
lines changed
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
import * as React from 'react'
2+
import {
3+
ColorValue,
4+
ScrollView,
5+
StyleSheet,
6+
Text,
7+
TouchableOpacity,
8+
View,
9+
} from 'react-native'
10+
import type {Moment} from 'moment-timezone'
11+
import type {DayOfWeek} from '../types'
12+
import * as c from '@frogpond/colors'
13+
import {ContextMenu} from '@frogpond/context-menu'
14+
import {Icon} from '@frogpond/icon'
15+
16+
const styles = StyleSheet.create({
17+
dayPickerContainer: {
18+
backgroundColor: c.systemGroupedBackground,
19+
paddingVertical: 12,
20+
paddingHorizontal: 16,
21+
},
22+
dayPickerScroll: {
23+
flexGrow: 0,
24+
},
25+
dayButton: {
26+
paddingHorizontal: 12,
27+
paddingVertical: 8,
28+
marginRight: 8,
29+
borderRadius: 6,
30+
backgroundColor: c.secondarySystemGroupedBackground,
31+
},
32+
dayButtonSelected: {
33+
backgroundColor: c.systemBlue,
34+
},
35+
dayButtonText: {
36+
fontSize: 14,
37+
fontWeight: '500',
38+
color: c.label,
39+
},
40+
dayButtonTextSelected: {
41+
color: c.white,
42+
},
43+
resetButton: {
44+
marginTop: 8,
45+
alignSelf: 'center',
46+
paddingHorizontal: 12,
47+
paddingVertical: 6,
48+
},
49+
resetButtonText: {
50+
fontSize: 14,
51+
color: c.systemRed,
52+
fontWeight: '500',
53+
},
54+
headerButtonContainer: {
55+
borderWidth: 1,
56+
borderRadius: 6,
57+
paddingHorizontal: 8,
58+
paddingVertical: 6,
59+
backgroundColor: c.systemBackground,
60+
alignItems: 'center',
61+
justifyContent: 'center',
62+
flexDirection: 'row',
63+
gap: 6,
64+
},
65+
headerButtonText: {
66+
fontSize: 14,
67+
fontWeight: '500',
68+
},
69+
})
70+
71+
export const DAYS_OF_WEEK: Array<{day: DayOfWeek; label: string}> = [
72+
{day: 'Su', label: 'Sunday'},
73+
{day: 'Mo', label: 'Monday'},
74+
{day: 'Tu', label: 'Tuesday'},
75+
{day: 'We', label: 'Wednesday'},
76+
{day: 'Th', label: 'Thursday'},
77+
{day: 'Fr', label: 'Friday'},
78+
{day: 'Sa', label: 'Saturday'},
79+
]
80+
81+
export type DayPickerProps = {
82+
selectedDay: DayOfWeek
83+
onDaySelect: (day: DayOfWeek) => void
84+
currentDay: DayOfWeek
85+
onReset?: () => void
86+
}
87+
88+
export const DayPicker = ({
89+
selectedDay,
90+
onDaySelect,
91+
currentDay,
92+
onReset,
93+
}: DayPickerProps): JSX.Element => {
94+
const isOverridden = selectedDay !== currentDay
95+
96+
return (
97+
<View style={styles.dayPickerContainer}>
98+
<ScrollView
99+
contentContainerStyle={styles.dayPickerScroll}
100+
horizontal={true}
101+
showsHorizontalScrollIndicator={false}
102+
>
103+
{DAYS_OF_WEEK.map(({day, label}) => {
104+
const isSelected = selectedDay === day
105+
return (
106+
<TouchableOpacity
107+
key={day}
108+
onPress={() => onDaySelect(day)}
109+
style={[styles.dayButton, isSelected && styles.dayButtonSelected]}
110+
>
111+
<Text
112+
style={[
113+
styles.dayButtonText,
114+
isSelected && styles.dayButtonTextSelected,
115+
]}
116+
>
117+
{label}
118+
</Text>
119+
</TouchableOpacity>
120+
)
121+
})}
122+
</ScrollView>
123+
{isOverridden && onReset && (
124+
<TouchableOpacity onPress={onReset} style={styles.resetButton}>
125+
<Text style={styles.resetButtonText}>Reset</Text>
126+
</TouchableOpacity>
127+
)}
128+
</View>
129+
)
130+
}
131+
132+
export const momentToDayOfWeek = (moment: Moment): DayOfWeek => {
133+
const dayMap: Record<number, DayOfWeek> = {
134+
0: 'Su',
135+
1: 'Mo',
136+
2: 'Tu',
137+
3: 'We',
138+
4: 'Th',
139+
5: 'Fr',
140+
6: 'Sa',
141+
}
142+
return dayMap[moment.day()]
143+
}
144+
145+
export const createMomentForDay = (
146+
baseMoment: Moment,
147+
targetDay: DayOfWeek,
148+
): Moment => {
149+
const dayMap: Record<DayOfWeek, number> = {
150+
Su: 0,
151+
Mo: 1,
152+
Tu: 2,
153+
We: 3,
154+
Th: 4,
155+
Fr: 5,
156+
Sa: 6,
157+
}
158+
159+
const targetDayNumber = dayMap[targetDay]
160+
const currentDayNumber = baseMoment.day()
161+
const diff = targetDayNumber - currentDayNumber
162+
163+
return baseMoment.clone().add(diff, 'days')
164+
}
165+
166+
export type DayPickerHeaderProps = {
167+
selectedDay: DayOfWeek
168+
onDaySelect: (day: DayOfWeek) => void
169+
currentDay: DayOfWeek
170+
accentColor?: ColorValue
171+
}
172+
173+
export const DayPickerHeader = ({
174+
selectedDay,
175+
onDaySelect,
176+
currentDay,
177+
accentColor = c.systemBlue,
178+
}: DayPickerHeaderProps): JSX.Element => {
179+
const dayOptions = DAYS_OF_WEEK.map(({label}) => label)
180+
const selectedIndex = DAYS_OF_WEEK.findIndex(({day}) => day === selectedDay)
181+
const selectedLabel = DAYS_OF_WEEK[selectedIndex]?.label || 'Today'
182+
183+
const isOverridden = selectedDay !== currentDay
184+
const displayText = isOverridden ? selectedLabel : 'Today'
185+
186+
return (
187+
<ContextMenu
188+
actions={dayOptions}
189+
isMenuPrimaryAction={true}
190+
onPressMenuItem={(item: string) => {
191+
const selectedDayData = DAYS_OF_WEEK.find(({label}) => label === item)
192+
if (selectedDayData) {
193+
onDaySelect(selectedDayData.day)
194+
}
195+
}}
196+
title="Pick a schedule"
197+
>
198+
<View style={[styles.headerButtonContainer, {borderColor: accentColor}]}>
199+
<Icon color={accentColor} name="calendar" size={16} />
200+
<Text style={[styles.headerButtonText, {color: accentColor}]}>
201+
{displayText}
202+
</Text>
203+
</View>
204+
</ContextMenu>
205+
)
206+
}

source/views/transportation/bus/line.tsx

Lines changed: 84 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,14 @@
11
import * as React from 'react'
22
import {useEffect, useState} from 'react'
3-
import {FlatList, Platform, StyleSheet, Text, TouchableOpacity} from 'react-native'
4-
import type {BusSchedule, UnprocessedBusLine} from './types'
3+
import {
4+
FlatList,
5+
Platform,
6+
StyleSheet,
7+
Text,
8+
TouchableOpacity,
9+
View,
10+
} from 'react-native'
11+
import type {BusSchedule, UnprocessedBusLine, DayOfWeek} from './types'
512
import {
613
BusStateEnum,
714
getCurrentBusIteration,
@@ -13,11 +20,16 @@ import find from 'lodash/find'
1320
import findLast from 'lodash/findLast'
1421
import {Separator} from '@frogpond/separator'
1522
import {BusStopRow} from './components/bus-stop-row'
16-
import {ListFooter, ListRow, ListSectionHeader} from '@frogpond/lists'
23+
import {ListFooter, ListRow} from '@frogpond/lists'
1724
import {InfoHeader} from '@frogpond/info-header'
1825
import * as c from '@frogpond/colors'
1926
import {useNavigation} from '@react-navigation/native'
2027
import {BUS_FOOTER_MESSAGE} from './constants'
28+
import {
29+
DayPickerHeader,
30+
momentToDayOfWeek,
31+
createMomentForDay,
32+
} from './components/day-picker'
2133

2234
const styles = StyleSheet.create({
2335
container: {
@@ -31,6 +43,34 @@ const styles = StyleSheet.create({
3143
// erase the gap in the bar caused by the separators' block-ness
3244
marginTop: -1,
3345
},
46+
headerContainer: {
47+
paddingLeft: 15,
48+
paddingRight: 15,
49+
paddingVertical: Platform.OS === 'ios' ? 6 : 10,
50+
backgroundColor: c.systemGroupedBackground,
51+
borderTopWidth: Platform.OS === 'ios' ? StyleSheet.hairlineWidth : 1,
52+
borderBottomWidth: Platform.OS === 'ios' ? StyleSheet.hairlineWidth : 0,
53+
borderTopColor: c.separator,
54+
borderBottomColor: c.separator,
55+
flexDirection: 'row',
56+
alignItems: 'center',
57+
justifyContent: 'space-between',
58+
},
59+
headerTextContainer: {
60+
flex: 1,
61+
},
62+
headerTitle: {
63+
fontSize: 16,
64+
fontWeight: Platform.OS === 'ios' ? '500' : '600',
65+
color: c.label,
66+
fontFamily: Platform.OS === 'android' ? 'sans-serif-condensed' : undefined,
67+
},
68+
headerSubtitle: {
69+
fontSize: 16,
70+
fontWeight: '400',
71+
color: c.secondaryLabel,
72+
fontFamily: Platform.OS === 'android' ? 'sans-serif-condensed' : undefined,
73+
},
3474
})
3575

3676
const isTruthy = (x: unknown) => Boolean(x)
@@ -106,7 +146,7 @@ function deriveFromProps({line, now}: {line: UnprocessedBusLine; now: Moment}) {
106146

107147
if (process.env.NODE_ENV !== 'production') {
108148
// for debugging
109-
subtitle += ` (${now.format('dd h:mma')})`
149+
subtitle += ` (${now.format('h:mma')})`
110150
}
111151

112152
return {
@@ -127,34 +167,64 @@ export function BusLine(props: Props): JSX.Element {
127167
null,
128168
)
129169
let [status, setStatus] = useState<BusStateEnum>('none')
170+
let [selectedDay, setSelectedDay] = useState<DayOfWeek>(() =>
171+
momentToDayOfWeek(now),
172+
)
173+
174+
const currentDay = momentToDayOfWeek(now)
130175

131176
useEffect(() => {
177+
const newCurrentDay = momentToDayOfWeek(now)
178+
if (selectedDay === currentDay && newCurrentDay !== currentDay) {
179+
setSelectedDay(newCurrentDay)
180+
}
181+
}, [now, selectedDay, currentDay])
182+
183+
useEffect(() => {
184+
const momentForSelectedDay = createMomentForDay(now, selectedDay)
185+
132186
let {
133187
schedule: scheduleForToday,
134188
subtitle: scheduleSubtitle,
135189
currentBusIteration: busIteration,
136190
status: currentStatus,
137191
} = deriveFromProps({
138192
line,
139-
now,
193+
now: momentForSelectedDay,
140194
})
141195
setSchedule(scheduleForToday)
142196
setSubtitle(scheduleSubtitle)
143197
setStatus(currentStatus)
144198
setCurrentBusIteration(busIteration)
145-
}, [line, now])
199+
}, [line, now, selectedDay])
146200

147201
let INFO_EL = (
148-
<ListSectionHeader
149-
subtitle={subtitle}
150-
title={line.line}
151-
titleStyle={Platform.OS === 'android' ? {color: line.colors.bar} : null}
152-
/>
202+
<View style={styles.headerContainer}>
203+
<View style={styles.headerTextContainer}>
204+
<Text>
205+
<Text style={[styles.headerTitle]}>{line.line}</Text>
206+
{subtitle ? (
207+
<Text style={styles.headerSubtitle}>
208+
{' — '}
209+
{subtitle}
210+
</Text>
211+
) : null}
212+
</Text>
213+
</View>
214+
215+
<DayPickerHeader
216+
accentColor={line.colors.bar}
217+
currentDay={currentDay}
218+
onDaySelect={setSelectedDay}
219+
selectedDay={selectedDay}
220+
/>
221+
</View>
153222
)
154223

155224
let lineMessage = line.notice || ''
156225

157226
let footerElement = <ListFooter title={BUS_FOOTER_MESSAGE} />
227+
158228
let headerElement = lineMessage ? (
159229
<>
160230
<InfoHeader message={lineMessage} title={`About ${line.line}`} />
@@ -166,6 +236,8 @@ export function BusLine(props: Props): JSX.Element {
166236

167237
let timetable = schedule?.timetable ?? []
168238

239+
const momentForSelectedDay = createMomentForDay(now, selectedDay)
240+
169241
return (
170242
<FlatList
171243
ItemSeparatorComponent={BusLineSeparator}
@@ -186,7 +258,7 @@ export function BusLine(props: Props): JSX.Element {
186258
departureIndex={currentBusIteration}
187259
isFirstRow={index === 0}
188260
isLastRow={timetable.length === 0 || index === timetable.length - 1}
189-
now={now}
261+
now={momentForSelectedDay}
190262
status={status}
191263
stop={item}
192264
/>

0 commit comments

Comments
 (0)