mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
Update icons, screenshots, and UX improvements
- Refactor icon picker to use full Ionicons set dynamically - Add date sort toggle to My Answers screen - Fix match deduplication and show total count on home - Clear stale data when switching chats - Update app store screenshots - Bump build number to 25 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c48936686e
commit
b60b72a289
64 files changed
+381
-298
No files matched your search
@@ -16,7 +16,7 @@
|
||||
"ios": {
|
||||
"supportsTablet": false,
|
||||
"bundleIdentifier": "com.sauci.app",
|
||||
"buildNumber": "24",
|
||||
"buildNumber": "25",
|
||||
"usesAppleSignIn": true,
|
||||
"infoPlist": {
|
||||
"UIBackgroundModes": [
|
||||
|
||||
@@ -22,7 +22,7 @@ const ACCENT_COLOR = colors.primary;
|
||||
|
||||
export default function HomeScreen() {
|
||||
const { user, partner, couple } = useAuthStore();
|
||||
const { matches, newMatchesCount, fetchMatches } = useMatchStore();
|
||||
const { matches, newMatchesCount, totalCount, fetchMatches } = useMatchStore();
|
||||
const { enabledPackIds, fetchPacks } = usePacksStore();
|
||||
const { width } = useWindowDimensions();
|
||||
|
||||
@@ -146,7 +146,7 @@ export default function HomeScreen() {
|
||||
color={matches.length > 0 ? colors.text : ACCENT_COLOR}
|
||||
/>
|
||||
</View>
|
||||
<Text style={styles.statNumber}>{matches.length}</Text>
|
||||
<Text style={styles.statNumber}>{totalCount ?? matches.length}</Text>
|
||||
<Text style={styles.statLabel}>Matches</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
@@ -47,7 +47,7 @@ const AnimatedSectionList = Animated.createAnimatedComponent(SectionList);
|
||||
type GroupByOption = "pack" | "date" | "answer";
|
||||
|
||||
export default function MyAnswersScreen() {
|
||||
const { responses, isLoading, isLoadingMore, hasMore, groupBy, fetchResponses, setGroupBy, totalCount } = useResponsesStore();
|
||||
const { responses, isLoading, isLoadingMore, hasMore, groupBy, dateSortOrder, fetchResponses, setGroupBy, toggleDateSortOrder, totalCount } = useResponsesStore();
|
||||
const { user } = useAuthStore();
|
||||
const router = useRouter();
|
||||
const params = useLocalSearchParams();
|
||||
@@ -123,7 +123,7 @@ export default function MyAnswersScreen() {
|
||||
}
|
||||
}, [isLoading, isLoadingMore, hasMore]);
|
||||
|
||||
const sections = groupResponses(responses, groupBy);
|
||||
const sections = groupResponses(responses, groupBy, dateSortOrder);
|
||||
|
||||
const handleEditPress = (response: ResponseWithQuestion) => {
|
||||
setSelectedResponse(response);
|
||||
@@ -164,16 +164,39 @@ export default function MyAnswersScreen() {
|
||||
);
|
||||
};
|
||||
|
||||
const GroupByButton = ({ option, label }: { option: GroupByOption; label: string }) => (
|
||||
<TouchableOpacity
|
||||
style={[styles.groupByButton, groupBy === option && styles.groupByButtonActive]}
|
||||
onPress={() => setGroupBy(option)}
|
||||
>
|
||||
<Text style={[styles.groupByText, groupBy === option && styles.groupByTextActive]}>
|
||||
{label}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
const GroupByButton = ({ option, label }: { option: GroupByOption; label: string }) => {
|
||||
const isActive = groupBy === option;
|
||||
const isDateButton = option === "date";
|
||||
const showChevron = isDateButton && isActive;
|
||||
|
||||
const handlePress = () => {
|
||||
if (isDateButton && isActive) {
|
||||
// Toggle sort order when date is already selected
|
||||
toggleDateSortOrder();
|
||||
} else {
|
||||
setGroupBy(option);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[styles.groupByButton, isActive && styles.groupByButtonActive]}
|
||||
onPress={handlePress}
|
||||
>
|
||||
<Text style={[styles.groupByText, isActive && styles.groupByTextActive]}>
|
||||
{label}
|
||||
</Text>
|
||||
{showChevron && (
|
||||
<Ionicons
|
||||
name={dateSortOrder === "newest" ? "chevron-down" : "chevron-up"}
|
||||
size={14}
|
||||
color={ACCENT}
|
||||
style={styles.sortChevron}
|
||||
/>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
@@ -441,6 +464,8 @@ const styles = StyleSheet.create({
|
||||
marginTop: spacing.md,
|
||||
},
|
||||
groupByButton: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radius.full,
|
||||
@@ -461,6 +486,9 @@ const styles = StyleSheet.create({
|
||||
color: ACCENT,
|
||||
fontWeight: "600",
|
||||
},
|
||||
sortChevron: {
|
||||
marginLeft: spacing.xs,
|
||||
},
|
||||
// Section header
|
||||
sectionHeader: {
|
||||
paddingVertical: spacing.sm,
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>20</string>
|
||||
<string>25</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>12.0</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
@@ -60,7 +60,7 @@
|
||||
<true/>
|
||||
</dict>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>Sauci uses your camera to take photos and videos that you can share privately with your partner in encrypted chat messages.</string>
|
||||
<string>Sauci uses your camera to take photos and videos that you can share privately with your partner.</string>
|
||||
<key>NSFaceIDUsageDescription</key>
|
||||
<string>Sauci uses Face ID to unlock the app and keep your conversations private.</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
@@ -68,7 +68,7 @@
|
||||
<key>NSPhotoLibraryAddUsageDescription</key>
|
||||
<string>Sauci saves photos and videos from your partner's messages to your photo library when you tap the save button.</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>Sauci uses your photo library so you can select photos and videos to share privately with your partner in encrypted chat messages.</string>
|
||||
<string>Sauci uses your photo library so you can select photos and videos to share privately with your partner.</string>
|
||||
<key>NSUserActivityTypes</key>
|
||||
<array>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER).expo.index_route</string>
|
||||
|
||||
@@ -149,6 +149,7 @@ export const ChatScreen: React.FC = () => {
|
||||
if (!matchId) return;
|
||||
|
||||
const fetchMatch = async () => {
|
||||
setMatch(null); // Clear previous match data immediately
|
||||
const { data } = await supabase
|
||||
.from("matches")
|
||||
.select("*, question:questions(*)")
|
||||
|
||||
@@ -104,6 +104,7 @@ export const useMessageSubscription = (
|
||||
|
||||
const fetchMessagesAndMarkRead = async () => {
|
||||
setLoading(true);
|
||||
setAllMessages([]); // Clear previous chat's messages immediately
|
||||
|
||||
const { data } = await supabase
|
||||
.from('messages')
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import type { Ionicons } from "@expo/vector-icons";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
|
||||
type IoniconsName = keyof typeof Ionicons.glyphMap;
|
||||
|
||||
/**
|
||||
* Checks if a string is a valid Ionicons name.
|
||||
* Uses the actual Ionicons.glyphMap for runtime validation.
|
||||
*/
|
||||
function isValidIonicon(name: string): name is IoniconsName {
|
||||
return name in Ionicons.glyphMap;
|
||||
}
|
||||
|
||||
// Legacy emoji mappings (for backwards compatibility with existing packs)
|
||||
const EMOJI_TO_IONICON: Record<string, IoniconsName> = {
|
||||
// Career & Professional
|
||||
@@ -49,63 +57,20 @@ export const PACK_ICON_MAP = EMOJI_TO_IONICON;
|
||||
|
||||
export const DEFAULT_PACK_ICON: IoniconsName = "layers-outline";
|
||||
|
||||
// List of valid Ionicon names (subset we support)
|
||||
const VALID_IONICONS = new Set([
|
||||
"heart-outline",
|
||||
"heart-half-outline",
|
||||
"heart-circle-outline",
|
||||
"chatbubbles-outline",
|
||||
"chatbox-outline",
|
||||
"mail-outline",
|
||||
"flower-outline",
|
||||
"wine-outline",
|
||||
"restaurant-outline",
|
||||
"cafe-outline",
|
||||
"airplane-outline",
|
||||
"car-outline",
|
||||
"compass-outline",
|
||||
"map-outline",
|
||||
"home-outline",
|
||||
"people-outline",
|
||||
"person-outline",
|
||||
"eye-off-outline",
|
||||
"key-outline",
|
||||
"lock-closed-outline",
|
||||
"dice-outline",
|
||||
"gift-outline",
|
||||
"sparkles-outline",
|
||||
"star-outline",
|
||||
"flame-outline",
|
||||
"flash-outline",
|
||||
"moon-outline",
|
||||
"sunny-outline",
|
||||
"flag-outline",
|
||||
"calendar-outline",
|
||||
"checkbox-outline",
|
||||
"trophy-outline",
|
||||
"layers-outline",
|
||||
"cube-outline",
|
||||
"folder-outline",
|
||||
"bookmark-outline",
|
||||
"bulb-outline",
|
||||
"color-wand-outline",
|
||||
"sync-outline",
|
||||
"refresh-outline",
|
||||
"briefcase-outline",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Converts an icon value to an Ionicons name.
|
||||
* Handles both:
|
||||
* - Ionicon names directly (new packs): "briefcase-outline" -> "briefcase-outline"
|
||||
* - Any valid Ionicon name directly: "briefcase-outline" -> "briefcase-outline"
|
||||
* - Legacy emojis (existing packs): "💼" -> "briefcase-outline"
|
||||
*/
|
||||
export function getPackIconName(icon: string | null | undefined): IoniconsName {
|
||||
if (!icon) return DEFAULT_PACK_ICON;
|
||||
|
||||
if (VALID_IONICONS.has(icon)) {
|
||||
return icon as IoniconsName;
|
||||
// Check if it's a valid Ionicon name (supports the full Ionicons set)
|
||||
if (isValidIonicon(icon)) {
|
||||
return icon;
|
||||
}
|
||||
|
||||
// Fall back to emoji mapping for legacy packs
|
||||
return EMOJI_TO_IONICON[icon] || DEFAULT_PACK_ICON;
|
||||
}
|
||||
@@ -5,4 +5,4 @@ export { usePacksStore } from "./packsStore";
|
||||
export { useMessageStore } from "./messageStore";
|
||||
export { useSubscriptionStore } from "./subscriptionStore";
|
||||
export { useResponsesStore, groupResponses } from "./responsesStore";
|
||||
export type { ResponseWithQuestion, UpdateResponseResult, GroupByOption } from "./responsesStore";
|
||||
export type { ResponseWithQuestion, UpdateResponseResult, GroupByOption, DateSortOrder } from "./responsesStore";
|
||||
@@ -147,15 +147,27 @@ export const useMatchStore = create<MatchState>((set, get) => ({
|
||||
// Ideally we should have a separate query for count, but we'll just count in current batch
|
||||
const newMatchesInBatch = sortedData.filter((m) => m.is_new).length;
|
||||
|
||||
set((state) => ({
|
||||
matches: refresh ? sortedData : [...state.matches, ...sortedData],
|
||||
set((state) => {
|
||||
// Deduplicate matches by ID when appending new pages
|
||||
const mergedMatches = refresh
|
||||
? sortedData
|
||||
: [...state.matches, ...sortedData].reduce<Match[]>((acc, match) => {
|
||||
if (!acc.some((m: Match) => m.id === match.id)) {
|
||||
acc.push(match);
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
return {
|
||||
matches: mergedMatches,
|
||||
newMatchesCount: refresh ? newMatchesInBatch : state.newMatchesCount + newMatchesInBatch,
|
||||
totalCount: refresh ? totalCount : state.totalCount,
|
||||
isLoading: false,
|
||||
isLoadingMore: false,
|
||||
page: currentPage + 1,
|
||||
hasMore: matches.length === BATCH_SIZE
|
||||
}));
|
||||
};
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Error fetching matches:", err);
|
||||
set({ error: "Failed to load matches", isLoading: false, isLoadingMore: false });
|
||||
|
||||
@@ -32,6 +32,7 @@ export interface UpdateResponseResult {
|
||||
}
|
||||
|
||||
export type GroupByOption = "pack" | "date" | "answer";
|
||||
export type DateSortOrder = "newest" | "oldest";
|
||||
|
||||
const BATCH_SIZE = 20;
|
||||
|
||||
@@ -40,6 +41,7 @@ interface ResponsesState {
|
||||
isLoading: boolean;
|
||||
isLoadingMore: boolean;
|
||||
groupBy: GroupByOption;
|
||||
dateSortOrder: DateSortOrder;
|
||||
hasMore: boolean;
|
||||
page: number;
|
||||
totalCount: number | null;
|
||||
@@ -50,6 +52,8 @@ interface ResponsesState {
|
||||
confirmDelete?: boolean
|
||||
) => Promise<UpdateResponseResult>;
|
||||
setGroupBy: (groupBy: GroupByOption) => void;
|
||||
setDateSortOrder: (order: DateSortOrder) => void;
|
||||
toggleDateSortOrder: () => void;
|
||||
clearResponses: () => void;
|
||||
}
|
||||
|
||||
@@ -57,7 +61,8 @@ export const useResponsesStore = create<ResponsesState>((set, get) => ({
|
||||
responses: [],
|
||||
isLoading: false,
|
||||
isLoadingMore: false,
|
||||
groupBy: "pack",
|
||||
groupBy: "date",
|
||||
dateSortOrder: "newest",
|
||||
hasMore: true,
|
||||
page: 0,
|
||||
totalCount: null,
|
||||
@@ -270,15 +275,26 @@ export const useResponsesStore = create<ResponsesState>((set, get) => ({
|
||||
set({ groupBy });
|
||||
},
|
||||
|
||||
clearResponses: () => {
|
||||
set({ responses: [], isLoading: false, groupBy: "pack", page: 0, hasMore: true, isLoadingMore: false, totalCount: null });
|
||||
setDateSortOrder: (order: DateSortOrder) => {
|
||||
set({ dateSortOrder: order });
|
||||
},
|
||||
|
||||
toggleDateSortOrder: () => {
|
||||
set((state) => ({
|
||||
dateSortOrder: state.dateSortOrder === "newest" ? "oldest" : "newest",
|
||||
}));
|
||||
},
|
||||
|
||||
clearResponses: () => {
|
||||
set({ responses: [], isLoading: false, groupBy: "date", dateSortOrder: "newest", page: 0, hasMore: true, isLoadingMore: false, totalCount: null });
|
||||
},
|
||||
}));
|
||||
|
||||
// Helper function to group responses
|
||||
export function groupResponses(
|
||||
responses: ResponseWithQuestion[],
|
||||
groupBy: GroupByOption
|
||||
groupBy: GroupByOption,
|
||||
dateSortOrder: DateSortOrder = "newest"
|
||||
): { title: string; data: ResponseWithQuestion[] }[] {
|
||||
if (groupBy === "pack") {
|
||||
// Group by pack name
|
||||
@@ -307,9 +323,16 @@ export function groupResponses(
|
||||
}
|
||||
|
||||
if (groupBy === "date") {
|
||||
// Sort responses by date based on sort order
|
||||
const sortedResponses = [...responses].sort((a, b) => {
|
||||
const dateA = new Date(a.created_at).getTime();
|
||||
const dateB = new Date(b.created_at).getTime();
|
||||
return dateSortOrder === "newest" ? dateB - dateA : dateA - dateB;
|
||||
});
|
||||
|
||||
// Group by date (day)
|
||||
const groups = new Map<string, ResponseWithQuestion[]>();
|
||||
responses.forEach((r) => {
|
||||
sortedResponses.forEach((r) => {
|
||||
const date = new Date(r.created_at).toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
|
||||
Reference in new issue
Block a user