Skip to content

Commit e166605

Browse files
authored
feat: add locationless crags and show all days in optimal windows (#39)
1 parent 172404b commit e166605

20 files changed

Lines changed: 719 additions & 251 deletions

File tree

mobile/app/(tabs)/favorites.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@ export default function FavoritesScreen() {
4949

5050
const updated = await Promise.all(
5151
currentFavorites.map(async (fav) => {
52+
// Skip conditions fetch for locationless crags
53+
if (fav.isLocationless) return fav;
5254
try {
5355
const response = await getConditions(
5456
fav.latitude,
@@ -101,13 +103,19 @@ export default function FavoritesScreen() {
101103
<Text style={[styles.location, { color: colors.textSecondary }]} numberOfLines={1}>
102104
{item.location}
103105
</Text>
104-
{rColors && ratingInfo && (
106+
{item.isLocationless ? (
107+
<View style={[styles.ratingBadge, { backgroundColor: isDark ? "rgba(147,51,234,0.12)" : "#faf5ff" }]}>
108+
<Text style={[styles.ratingText, { color: isDark ? "#d8b4fe" : "#7c3aed" }]}>
109+
{t("favorites.reportsOnly", "Reports only")}
110+
</Text>
111+
</View>
112+
) : rColors && ratingInfo ? (
105113
<View style={[styles.ratingBadge, { backgroundColor: rColors.bg }]}>
106114
<Text style={[styles.ratingText, { color: rColors.text }]}>
107115
{t(`ratings.${ratingInfo.label.toLowerCase()}`, ratingInfo.label)}
108116
</Text>
109117
</View>
110-
)}
118+
) : null}
111119
{item.rockType && item.rockType !== "unknown" && (
112120
<Text style={[styles.rockType, { color: colors.muted }]}>
113121
{item.rockType.charAt(0).toUpperCase() + item.rockType.slice(1)}

mobile/app/add-crag.tsx

Lines changed: 74 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ export default function AddCragScreen() {
5454
const [selectedClimbingTypes, setSelectedClimbingTypes] = useState<string[]>([]);
5555
const [description, setDescription] = useState("");
5656
const [isSecret, setIsSecret] = useState(false);
57+
const [isLocationless, setIsLocationless] = useState(false);
5758

5859
// Loading states
5960
const [isSubmitting, setIsSubmitting] = useState(false);
@@ -119,7 +120,7 @@ export default function AddCragScreen() {
119120
Alert.alert(t("addCragModal.errors.nameRequired", "Name is required"));
120121
return;
121122
}
122-
if (!position) {
123+
if (!isLocationless && !position) {
123124
Alert.alert(
124125
t("addCragModal.errors.locationRequired", "Location is required"),
125126
isSecret
@@ -128,7 +129,7 @@ export default function AddCragScreen() {
128129
);
129130
return;
130131
}
131-
if (!country.trim() || country.trim().length < 2) {
132+
if (!isLocationless && (!country.trim() || country.trim().length < 2)) {
132133
Alert.alert(
133134
t("addCragModal.errors.countryRequired", "Country is required"),
134135
t("addCragModal.errors.countryRequiredDesc", "Please enter a 2-letter country code (e.g., CH, US, FR).")
@@ -145,9 +146,13 @@ export default function AddCragScreen() {
145146
const result = await submitCrag(
146147
{
147148
name: name.trim(),
148-
lat: position.latitude,
149-
lon: position.longitude,
150-
country: (() => {
149+
lat: isLocationless ? undefined : position!.latitude,
150+
lon: isLocationless ? undefined : position!.longitude,
151+
country: isLocationless ? (country.trim() ? (() => {
152+
const code = country.trim().substring(0, 2).toUpperCase();
153+
if (/^[A-Z]{2}$/.test(code)) return code;
154+
return undefined;
155+
})() : undefined) : (() => {
151156
const code = country.trim().substring(0, 2).toUpperCase();
152157
if (/^[A-Z]{2}$/.test(code)) return code;
153158
return "";
@@ -160,6 +165,7 @@ export default function AddCragScreen() {
160165
climbingTypes: selectedClimbingTypes.length > 0 ? selectedClimbingTypes : undefined,
161166
description: description.trim() || undefined,
162167
isSecret: isSecret || undefined,
168+
isLocationless: isLocationless || undefined,
163169
},
164170
syncKeyHash
165171
);
@@ -192,7 +198,7 @@ export default function AddCragScreen() {
192198
}
193199
}
194200

195-
const canSubmit = name.trim() && position && country.trim().length >= 2 && !isSubmitting && !isGeocoding;
201+
const canSubmit = name.trim() && (isLocationless || (position && country.trim().length >= 2)) && !isSubmitting && !isGeocoding;
196202

197203
return (
198204
<KeyboardAvoidingView
@@ -235,7 +241,7 @@ export default function AddCragScreen() {
235241
</View>
236242
<View style={styles.secretTextContainer}>
237243
<View style={styles.secretLabelRow}>
238-
<Text style={[styles.secretLabel, isSecret && { color: isDark ? "#fef3c7" : "#78350f" }]}>
244+
<Text style={[styles.secretLabel, { color: isSecret ? (isDark ? "#fef3c7" : "#78350f") : colors.text }]}>
239245
{t("addCragModal.secretCrag.label", "Secret Crag")}
240246
</Text>
241247
{isSecret && (
@@ -250,7 +256,63 @@ export default function AddCragScreen() {
250256
</View>
251257
</TouchableOpacity>
252258

253-
{/* Map Location Picker */}
259+
{/* Locationless Crag Toggle */}
260+
<TouchableOpacity
261+
style={[
262+
styles.secretToggle,
263+
{
264+
backgroundColor: isLocationless
265+
? isDark ? "rgba(147,51,234,0.15)" : "#faf5ff"
266+
: colors.surface,
267+
borderColor: isLocationless
268+
? isDark ? "rgba(147,51,234,0.4)" : "#d8b4fe"
269+
: "transparent",
270+
},
271+
]}
272+
onPress={() => {
273+
setIsLocationless(!isLocationless);
274+
if (!isLocationless) {
275+
// Switching to locationless: clear position data
276+
setIsSecret(false);
277+
}
278+
}}
279+
activeOpacity={0.7}
280+
>
281+
<View
282+
style={[
283+
styles.secretIconCircle,
284+
{
285+
backgroundColor: isLocationless
286+
? isDark ? "rgba(147,51,234,0.3)" : "#e9d5ff"
287+
: colors.border,
288+
},
289+
]}
290+
>
291+
<Ionicons
292+
name="location-outline"
293+
size={20}
294+
color={isLocationless ? (isDark ? "#d8b4fe" : "#6b21a8") : colors.muted}
295+
/>
296+
</View>
297+
<View style={styles.secretTextContainer}>
298+
<View style={styles.secretLabelRow}>
299+
<Text style={[styles.secretLabel, { color: isLocationless ? (isDark ? "#f3e8ff" : "#581c87") : colors.text }]}>
300+
{t("addCragModal.locationless.label", "No Location")}
301+
</Text>
302+
{isLocationless && (
303+
<View style={[styles.secretBadge, { backgroundColor: isDark ? "rgba(147,51,234,0.3)" : "#e9d5ff" }]}>
304+
<Text style={[styles.secretBadgeText, { color: isDark ? "#d8b4fe" : "#6b21a8" }]}>ON</Text>
305+
</View>
306+
)}
307+
</View>
308+
<Text style={[styles.secretHint, { color: colors.muted }]}>
309+
{t("addCragModal.locationless.hint", "For crags with sensitive access. No weather data — only community reports.")}
310+
</Text>
311+
</View>
312+
</TouchableOpacity>
313+
314+
{/* Map Location Picker (hidden for locationless crags) */}
315+
{!isLocationless && (
254316
<View style={styles.fieldGroup}>
255317
<Text style={[styles.sectionLabel, { color: colors.textSecondary }]}>
256318
{isSecret
@@ -266,9 +328,10 @@ export default function AddCragScreen() {
266328
isSecret={isSecret}
267329
/>
268330
</View>
331+
)}
269332

270-
{/* Nearby Crags Warning */}
271-
{nearbyCrags.length > 0 && (
333+
{/* Nearby Crags Warning (hidden for locationless crags) */}
334+
{!isLocationless && nearbyCrags.length > 0 && (
272335
<View
273336
style={[
274337
styles.warningBox,
@@ -352,7 +415,7 @@ export default function AddCragScreen() {
352415
<View style={styles.rowFields}>
353416
<View style={[styles.fieldGroup, { flex: 1 }]}>
354417
<Text style={[styles.label, { color: colors.textSecondary }]}>
355-
{t("addCragModal.form.country", "Country")} *
418+
{t("addCragModal.form.country", "Country")}{isLocationless ? "" : " *"}
356419
</Text>
357420
<TextInput
358421
style={[styles.input, { color: colors.text, backgroundColor: colors.surface, borderColor: colors.border }]}

0 commit comments

Comments
 (0)