diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
index 65515e8..9e36ae9 100644
--- a/.github/workflows/codeql.yml
+++ b/.github/workflows/codeql.yml
@@ -112,6 +112,11 @@ jobs:
echo "Prebuilding iOS project..."
npx expo prebuild --platform ios --clean --no-install
+ - name: Set iOS deployment target for pods
+ if: matrix.language == 'swift'
+ run: |
+ node -e "const fs=require('fs'); const p='ios/Podfile.properties.json'; const d=fs.existsSync(p)?JSON.parse(fs.readFileSync(p,'utf8')):{}; d['ios.deploymentTarget']='15.5'; fs.writeFileSync(p, JSON.stringify(d, null, 2) + '\n'); console.log('Configured ios.deploymentTarget=15.5 in ' + p);"
+
- name: Install iOS dependencies
if: matrix.language == 'swift'
run: |
@@ -166,14 +171,25 @@ jobs:
run: |
echo "Attempting fallback iOS build with minimal configuration..."
cd ios
-
- # Try a minimal build using the same approach as main.yml
- xcodebuild -workspace pulse.xcworkspace \
- -scheme pulse \
- -configuration Debug \
- -sdk iphonesimulator \
- -destination 'platform=iOS Simulator,name=iPhone SE (3rd generation),OS=18.2' \
- build-for-testing
+
+ # Guard against missing workspace when pod install failed earlier.
+ if [ ! -d "pulse.xcworkspace" ]; then
+ echo "pulse.xcworkspace is missing; retrying pod install before fallback build..."
+ pod install --repo-update
+ fi
+
+ if [ ! -d "pulse.xcworkspace" ]; then
+ echo "❌ pulse.xcworkspace still missing after pod install. Fallback build cannot continue."
+ exit 1
+ fi
+
+ # Try a minimal build using the same approach as main.yml.
+ xcodebuild -workspace pulse.xcworkspace \
+ -scheme pulse \
+ -configuration Debug \
+ -sdk iphonesimulator \
+ -destination 'platform=iOS Simulator,name=iPhone SE (3rd generation),OS=18.2' \
+ build-for-testing
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index 0560393..482e705 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -180,6 +180,10 @@ jobs:
echo "Prebuilding iOS project..."
npx expo prebuild --platform ios --clean
+ - name: Set iOS deployment target for pods
+ run: |
+ node -e "const fs=require('fs'); const p='ios/Podfile.properties.json'; const d=fs.existsSync(p)?JSON.parse(fs.readFileSync(p,'utf8')):{}; d['ios.deploymentTarget']='15.5'; fs.writeFileSync(p, JSON.stringify(d, null, 2) + '\n'); console.log('Configured ios.deploymentTarget=15.5 in ' + p);"
+
- name: Install iOS dependencies
run: |
cd ios
diff --git a/.github/workflows/testflight-deploy.yml b/.github/workflows/testflight-deploy.yml
index a030d86..cfc3230 100644
--- a/.github/workflows/testflight-deploy.yml
+++ b/.github/workflows/testflight-deploy.yml
@@ -49,6 +49,10 @@ jobs:
- name: Prebuild iOS project
run: npx expo prebuild --clean
+ - name: Set iOS deployment target for pods
+ run: |
+ node -e "const fs=require('fs'); const p='ios/Podfile.properties.json'; const d=fs.existsSync(p)?JSON.parse(fs.readFileSync(p,'utf8')):{}; d['ios.deploymentTarget']='15.5'; fs.writeFileSync(p, JSON.stringify(d, null, 2) + '\n'); console.log('Configured ios.deploymentTarget=15.5 in ' + p);"
+
- name: Install iOS dependencies
run: |
cd ios
diff --git a/app.json b/app.json
index 5fb3a93..a7ebbfe 100644
--- a/app.json
+++ b/app.json
@@ -2,7 +2,7 @@
"expo": {
"name": "pulse",
"slug": "pulse",
- "version": "1.1.2",
+ "version": "1.1.3",
"orientation": "portrait",
"icon": "./assets/images/pulse-logo.png",
"scheme": "pulsecam",
@@ -76,4 +76,4 @@
},
"owner": "morepriyam"
}
-}
\ No newline at end of file
+}
diff --git a/app/_layout.tsx b/app/_layout.tsx
index d692309..b09d2cf 100644
--- a/app/_layout.tsx
+++ b/app/_layout.tsx
@@ -8,12 +8,18 @@ import { useRouter } from "expo-router";
import { Stack } from "expo-router";
import * as Linking from "expo-linking";
import { StatusBar } from "expo-status-bar";
-import { useEffect } from "react";
+import { useEffect, useState } from "react";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import "react-native-reanimated";
import { PermissionMonitor } from "@/components/PermissionMonitor";
+import { ReportIssueFab } from "@/components/ReportIssueFab";
+import { ReportIssueModal } from "@/components/ReportIssueModal";
import { useColorScheme } from "@/hooks/useColorScheme";
+import {
+ BUG_REPORT_NOTIFICATION_OPEN_ACTION,
+ openBugReportNotificationUrl,
+} from "@/utils/localNotification";
import { storeUploadConfigForDraft } from "@/utils/uploadConfig";
import { addDestination } from "@/utils/uploadDestinations";
@@ -23,6 +29,7 @@ const isUUIDv4 = (uuid: string) =>
export default function RootLayout() {
const colorScheme = useColorScheme();
const router = useRouter();
+ const [isReportIssueModalVisible, setIsReportIssueModalVisible] = useState(false);
const [loaded] = useFonts({
"Roboto-Regular": require("../assets/fonts/Roboto-Regular.ttf"),
"Roboto-Bold": require("../assets/fonts/Roboto-Bold.ttf"),
@@ -112,6 +119,56 @@ export default function RootLayout() {
};
}, [router]);
+ useEffect(() => {
+ let cancelled = false;
+ let subscription: { remove: () => void } | null = null;
+
+ const setupNotifications = async () => {
+ const Notifications = await import("expo-notifications");
+
+ const handleResponse = async (response: {
+ actionIdentifier: string;
+ notification: {
+ request: {
+ content: {
+ data?: {
+ issueUrl?: string | null;
+ };
+ };
+ };
+ };
+ }) => {
+ if (cancelled) return;
+
+ const issueUrl = response.notification.request.content.data?.issueUrl;
+ if (!issueUrl) return;
+
+ if (
+ response.actionIdentifier === Notifications.DEFAULT_ACTION_IDENTIFIER ||
+ response.actionIdentifier === BUG_REPORT_NOTIFICATION_OPEN_ACTION
+ ) {
+ await openBugReportNotificationUrl(issueUrl);
+ }
+ };
+
+ subscription = Notifications.addNotificationResponseReceivedListener((response) => {
+ void handleResponse(response);
+ });
+
+ const lastResponse = await Notifications.getLastNotificationResponseAsync();
+ if (lastResponse) {
+ await handleResponse(lastResponse as never);
+ }
+ };
+
+ void setupNotifications();
+
+ return () => {
+ cancelled = true;
+ subscription?.remove();
+ };
+ }, []);
+
if (!loaded) {
// Async font loading only occurs in development.
return null;
@@ -184,6 +241,11 @@ export default function RootLayout() {
/>
+ setIsReportIssueModalVisible(true)} />
+ setIsReportIssueModalVisible(false)}
+ />
diff --git a/components/ReportIssueFab.tsx b/components/ReportIssueFab.tsx
new file mode 100644
index 0000000..23222e4
--- /dev/null
+++ b/components/ReportIssueFab.tsx
@@ -0,0 +1,59 @@
+import MaterialIcons from "@expo/vector-icons/MaterialIcons";
+import { usePathname } from "expo-router";
+import * as React from "react";
+import { Pressable, PressableStateCallbackType, StyleSheet } from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+
+interface ReportIssueFabProps {
+ onPress: () => void;
+}
+
+export function ReportIssueFab({ onPress }: ReportIssueFabProps) {
+ const pathname = usePathname();
+ const insets = useSafeAreaInsets();
+ const keepLowerOffset =
+ pathname.includes("shorts") || pathname.includes("trim-segment");
+ const topOffset = keepLowerOffset
+ ? insets.top + 104
+ : insets.top + 15;
+
+ return (
+ [
+ styles.button,
+ {
+ top: topOffset,
+ opacity: pressed ? 0.85 : 1,
+ transform: [{ scale: pressed ? 0.97 : 1 }],
+ },
+ ]}
+ >
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ button: {
+ position: "absolute",
+ right: 25,
+ width: 40,
+ height: 40,
+ borderRadius: 25,
+ alignItems: "center",
+ justifyContent: "center",
+ backgroundColor: "rgba(0, 0, 0, 0.78)",
+ borderWidth: 1.5,
+ borderColor: "rgba(255, 255, 255, 0.65)",
+ zIndex: 950,
+ elevation: 10,
+ shadowColor: "#000",
+ shadowOffset: { width: 0, height: 3 },
+ shadowOpacity: 0.4,
+ shadowRadius: 5,
+ },
+});
diff --git a/components/ReportIssueModal.tsx b/components/ReportIssueModal.tsx
new file mode 100644
index 0000000..bd58bb7
--- /dev/null
+++ b/components/ReportIssueModal.tsx
@@ -0,0 +1,529 @@
+import MaterialIcons from "@expo/vector-icons/MaterialIcons";
+import * as React from "react";
+import {
+ ActivityIndicator,
+ GestureResponderEvent,
+ Linking,
+ Modal,
+ Pressable,
+ StyleSheet,
+ TextInput,
+ View,
+} from "react-native";
+
+import { ThemedText } from "@/components/ThemedText";
+import { Colors } from "@/constants/Colors";
+import { useColorScheme } from "@/hooks/useColorScheme";
+import {
+ SubmitIssueReportProgress,
+ SubmitIssueReportResult,
+ submitIssueReport,
+} from "@/utils/reportIssue";
+import {
+ notifyBackgroundBugReportResult,
+} from "@/utils/localNotification";
+
+interface ReportIssueModalProps {
+ visible: boolean;
+ onClose: () => void;
+}
+
+export function ReportIssueModal({ visible, onClose }: ReportIssueModalProps) {
+ const colorScheme = useColorScheme();
+ const colors = colorScheme === "dark" ? Colors.dark : Colors.light;
+
+ const [summary, setSummary] = React.useState("");
+ const [description, setDescription] = React.useState("");
+ const [includeDraftFolder, setIncludeDraftFolder] = React.useState(false);
+ const [isSubmitting, setIsSubmitting] = React.useState(false);
+ const [errorMessage, setErrorMessage] = React.useState(null);
+ const [submittedIssue, setSubmittedIssue] = React.useState(null);
+ const [submitProgress, setSubmitProgress] = React.useState(0);
+ const [submitProgressMessage, setSubmitProgressMessage] = React.useState("");
+ const [showBackgroundUploadButton, setShowBackgroundUploadButton] = React.useState(false);
+ const [activeSubmissionToken, setActiveSubmissionToken] = React.useState(null);
+ const backgroundEnabledByTokenRef = React.useRef