28 Commits

Author SHA1 Message Date
billisdead 1437e99cbb fix: title increment based on DB max, not stored template only
Release APK / build (push) Has been cancelled
After each incident save, query existing titles with the same prefix
to find the real max number, then use max(dbMax, tplNum) + 1.
Prevents duplicate counters when incidents were created before the
template was configured.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 15:26:10 +02:00
billisdead a74d27085b feat: multi-provider AI assistant, repo URL simplification
Release APK / build (push) Has been cancelled
- lib/ai.ts: unified AI client supporting Anthropic (native format) and
  OpenAI-compatible providers (OpenAI, Perplexity, Ollama, OpenRouter, custom)
- settings: 6-provider chip selector, base URL auto-filled on preset select,
  Anthropic URL hidden (fixed), model/key placeholders per provider
- settings: git repo config replaced by single Repo URL field (.git paste)
  auto-parsed to instance URL + owner + repo at save time
- new.tsx: Suggest button between Symptom and Root Cause, visible when AI
  enabled and title+symptom filled; pre-fills rootCause and fix inline

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 14:32:44 +02:00
billisdead fe1fdb9f9c fix: Gitea push 404 — add branch:main payload, handle 409 empty repo, clearer settings
- lib/git.ts: add branch:"main" to Gitea POST payload (required when repo has no
  branch yet or is freshly initialized); treat 409 on GET same as 404 (empty repo
  has no tree, Gitea returns 409 Conflict — proceed to create first file)
- lib/git.ts: improve 404 error hint with actionable checklist (URL format,
  owner/repo names, token scope, repo existence)
- settings.tsx: add Gitea info card clarifying URL format (no /api/v1), token
  format (raw value only, no "Bearer" prefix), repo initialization requirement

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 14:12:09 +02:00
billisdead c915d5f2ad feat: delete incident, fix git push, title template, clarify AI settings
Release APK / build (push) Has been cancelled
- IncidentDetailScreen: add Delete button (confirm dialog) wired to deleteIncident()
- git.ts: fix Gitea POST vs PUT (use PUT when file exists/sha present); add
  pre-flight validation for missing URL/token/owner/repo; add 15s fetch timeout
  with AbortError handling; improve error messages with actionable hints
- [id].tsx: guard URL for Gitea/GitLab before calling pushIncidentToGit
- settings.tsx: move ToggleRow/SegmentRow/Field outside SettingsScreen to fix
  TextInput focus loss on each keystroke (component remount on rerender)
- settings.tsx: add Title Template setting with auto-increment info card
- settings.tsx: add AI base URL + model fields; expand AI section description
- new.tsx: pre-fill title from pref_title_template; increment counter after save

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 13:34:41 +02:00
billisdead fee283b5d6 feat: apply Charbon·Lime design system, add gear icon, multi-provider git settings
Release APK / build (push) Has been cancelled
- Replace app icon with new Charbon·Lime brand assets (icon.png + adaptive-icon.png)
- Apply design tokens to theme.ts: bg #1B2433, primary/success #88D656 (lime), text1 #EEF2F5 (cream)
- Add ⚙ gear button (lime) in HomeScreen header → navigates to /settings
- Expand settings: git provider selector (Gitea / GitHub / GitLab) with per-provider fields
- Add lib/git.ts: unified pushIncidentToGit() for Gitea, GitHub (PUT), and GitLab APIs
- Update incident detail screen to use lib/git.ts and dynamic provider routing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 10:00:49 +02:00
billisdead 67c132d88b fix: add mic to service field, refresh list on screen focus
Release APK / build (push) Has been cancelled
new.tsx: service field was missing its mic button — VoiceField type
excluded "service" and the label used a plain Text instead of labelRow().

index.tsx: incident list only loaded on mount; router.back() does not
remount the screen so new incidents never appeared. useFocusEffect
replaces the one-shot useEffect so the list reloads on every focus.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 18:51:43 +02:00
billisdead f1a4b58234 refactor: remove crash reporter from _layout.tsx
Release APK / build (push) Has been cancelled
CrashBoundary and ErrorUtils debug handler were temporary scaffolding
to identify the launch crash (react version mismatch). Removing now
that the root cause is fixed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 18:50:15 +02:00
billisdead dca76ea394 fix: add npm overrides to align react + react-dom to 19.2.3
Release APK / build (push) Has been cancelled
react-dom@19.2.7 has a hard peer dep on react@^19.2.7, breaking
npm ci when react is pinned to 19.2.3. npm overrides force both
packages to 19.2.3 so the lock file resolves cleanly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 18:20:49 +02:00
billisdead af8ec98bb4 fix: pin react to 19.2.3 to match react-native-renderer
Release APK / build (push) Has been cancelled
react-native@0.85.3 bundles react-native-renderer@19.2.3. Having
react@19.2.7 in package.json caused a version mismatch error at
runtime ("Incompatible React versions") that killed the process
before any JS could render, appearing as a native crash.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 18:18:03 +02:00
billisdead c43b913dde fix: exclude reanimated/worklets native build, disable New Architecture
Release APK / build (push) Has been cancelled
react-native.config.js excludes react-native-reanimated and
react-native-worklets from Android native linking — neither is used in
the app and react-native-gesture-handler has a dedicated noreanimated
codepath for exactly this case.

build-apk.sh step 4.6 forces newArchEnabled=false in gradle.properties
after expo prebuild, as a fallback for New Architecture init issues
on Android 15.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 15:16:37 +02:00
billisdead 256b338ae3 fix: patch worklets and reanimated for Android 15 16KB page size support
Release APK / build (push) Has been cancelled
react-native-worklets and react-native-reanimated compile .so files
without -Wl,-z,max-page-size=16384, causing a native crash at startup
on Android 15 devices that use 16KB memory pages (Pixel 9+, Galaxy S25+).
Patch applied in build-apk.sh and CI workflow after npm install, before
the Gradle build. Guard prevents double-patching on repeated runs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 14:23:38 +02:00
billisdead ec95406305 debug: add crash boundary and global error handler to surface release crashes
Release APK / build (push) Has been cancelled
Catches React render errors (ErrorBoundary) and fatal JS errors
(ErrorUtils.setGlobalHandler) and displays the stacktrace on-screen
instead of crashing to desktop. Temporary — remove after crash identified.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 13:10:01 +02:00
billisdead 6bd1dc8358 revert: remove all splash screen overrides, revert app.json to v0.1.5 state
Release APK / build (push) Has been cancelled
SplashScreen.hideAsync() and the splash section in app.json were both
introduced after v0.1.5 and are common to every crashing build.
Reverting to the exact v0.1.5 baseline to isolate the crash source.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 12:21:10 +02:00
billisdead d3d73c255e feat: add CHANGELOG.md and auto-create Gitea release on tag push
Release APK / build (push) Has been cancelled
Release body now includes conventional-commit bullet points, a compare
link (prev_tag...current_tag) and a link to CHANGELOG.md for both the
GitHub release and a new Gitea release created via API.
GITEA_TOKEN secret must be added to GitHub repo secrets to enable
the Gitea release step.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 11:27:28 +02:00
billisdead 72cfbf41f3 fix: revert splash screen overrides, remove redundant SafeAreaProvider
expo-router 56 already calls _internal_preventAutoHideAsync() internally.
Calling preventAutoHideAsync() at module level created a second user lock
that blocked the native splash indefinitely on Android, causing a crash.
SafeAreaProvider removed as expo-router's ExpoRoot already wraps with it.
Keep SplashScreen.hideAsync() to release the splash after SecureStore reads.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 11:26:24 +02:00
billisdead 86303e5c84 fix: prevent black screen on launch by holding splash until settings ready
Release APK / build (push) Has been cancelled
Add SplashScreen.preventAutoHideAsync() at module level and hideAsync()
once SecureStore reads complete. Add splash backgroundColor to app.json
to match app theme. Wrap layout in SafeAreaProvider.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 09:00:39 +02:00
billisdead c414ddc4fa fix: use printf %q to escape special chars in signing.env values
Release APK / build (push) Has been cancelled
2026-06-19 18:26:13 +02:00
billisdead f0fda1947d ci: add signing.env verification step to debug secret injection
Release APK / build (push) Has been cancelled
2026-06-19 18:24:59 +02:00
billisdead 8a75cffbd1 fix: inject signing secrets via env vars, write signing.env with printf
Release APK / build (push) Has been cancelled
2026-06-19 18:22:08 +02:00
billisdead 0b0226368b fix: quote heredoc delimiter to prevent shell expansion of secret values
Release APK / build (push) Has been cancelled
2026-06-19 18:18:13 +02:00
billisdead 8f42971187 ci: switch to signed release APK via build-apk.sh
Release APK / build (push) Has been cancelled
Debug APK required Metro bundler at runtime. Switch to a signed release
build using a local keystore (same pattern as Postiz-Android): keystore
decoded from KEYSTORE_B64 secret, build-apk.sh patches build.gradle and
assembles a standalone APK.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 18:07:22 +02:00
billisdead 9e820dbee6 ci: grant contents:write permission for release creation
Build Android APK / build (push) Has been cancelled
GITHUB_TOKEN needs explicit write access to create releases.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 13:28:28 +02:00
billisdead f185e5faec ci: free disk space before build to avoid runner out-of-space
Build Android APK / build (push) Has been cancelled
Gradle + NDK + node_modules exhaust the 14 GB on ubuntu-latest runners.
Remove .NET, Haskell, Chromium, Swift, PowerShell preinstalled tools (~6 GB).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 12:52:01 +02:00
billisdead c6a71ceeba ci: add concurrency control and GitHub Release creation
- cancel-in-progress for same ref prevents parallel duplicate builds
- post-build step creates a GitHub Release with APK as named asset

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 12:29:27 +02:00
billisdead 3e762f70e4 Add placeholder assets (adaptive-icon.png)
Build Android APK / build (push) Has been cancelled
Required by expo prebuild for Android manifest generation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 12:14:57 +02:00
billisdead f311f9f7c5 Fix: sync package-lock.json with package.json
Build Android APK / build (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 12:13:05 +02:00
billisdead 7b4b7c7a36 ci: trigger build on version tags only (v*)
Build Android APK / build (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 12:06:39 +02:00
billisdead 418eb1daa1 Add MVP features: edit mode, voice input, Gitea push, service autocomplete
- new.tsx: edit mode via editId param, per-field voice dictation (MIC/STOP),
  service autocomplete dropdown from DB history
- incident/[id].tsx: Push to Gitea action reads SecureStore config + md template
- index.tsx: homeView=capture redirects to /new on first mount
- _layout.tsx: onboarding gate wired (useSettings + router.replace)
- ci: GitHub Actions workflow builds debug APK via expo prebuild + Gradle

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 12:03:44 +02:00
18 changed files with 2142 additions and 397 deletions
+185
View File
@@ -0,0 +1,185 @@
name: Release APK
on:
push:
tags:
- 'v*.*.*'
workflow_dispatch:
concurrency:
group: build-android-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
if: github.server_url == 'https://github.com'
runs-on: ubuntu-latest
timeout-minutes: 60
permissions:
contents: write
steps:
- name: Free disk space
run: |
sudo rm -rf /usr/share/dotnet /usr/local/share/boost /opt/ghc
sudo rm -rf /usr/local/share/chromium /usr/local/share/powershell
sudo rm -rf /usr/share/swift /usr/local/julia*
sudo apt-get autoremove -y > /dev/null 2>&1
sudo apt-get clean
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '17'
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Set up Android SDK
uses: android-actions/setup-android@v3
- name: Accept Android SDK licenses
run: yes | sdkmanager --licenses || true
- name: Install SDK components
run: |
sdkmanager "platform-tools" "platforms;android-35" "build-tools;35.0.0"
- name: Cache Gradle
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('package.json') }}
restore-keys: |
${{ runner.os }}-gradle-
- name: Decode keystore
env:
KEYSTORE_B64: ${{ secrets.KEYSTORE_B64 }}
KEYSTORE_ALIAS: ${{ secrets.KEYSTORE_ALIAS }}
KEYSTORE_STORE_PASSWORD: ${{ secrets.KEYSTORE_STORE_PASSWORD }}
KEYSTORE_KEY_PASSWORD: ${{ secrets.KEYSTORE_KEY_PASSWORD }}
run: |
mkdir -p ~/.config/sheethappens
echo "$KEYSTORE_B64" | base64 -d > ~/.config/sheethappens/sheethappens.jks
{
printf 'KEYSTORE_PATH=%s\n' "$HOME/.config/sheethappens/sheethappens.jks"
printf 'KEYSTORE_ALIAS=%q\n' "$KEYSTORE_ALIAS"
printf 'KEYSTORE_STORE_PASSWORD=%q\n' "$KEYSTORE_STORE_PASSWORD"
printf 'KEYSTORE_KEY_PASSWORD=%q\n' "$KEYSTORE_KEY_PASSWORD"
} > ~/.config/sheethappens/signing.env
chmod 600 ~/.config/sheethappens/signing.env ~/.config/sheethappens/sheethappens.jks
- name: Verify signing.env
run: |
echo "Keys present in signing.env:"
grep -o '^[^=]*' ~/.config/sheethappens/signing.env
source ~/.config/sheethappens/signing.env
[ -n "$KEYSTORE_PATH" ] && echo "KEYSTORE_PATH: set" || echo "KEYSTORE_PATH: EMPTY"
[ -n "$KEYSTORE_ALIAS" ] && echo "KEYSTORE_ALIAS: set" || echo "KEYSTORE_ALIAS: EMPTY"
[ -n "$KEYSTORE_STORE_PASSWORD" ] && echo "KEYSTORE_STORE_PASSWORD: set" || echo "KEYSTORE_STORE_PASSWORD: EMPTY"
[ -n "$KEYSTORE_KEY_PASSWORD" ] && echo "KEYSTORE_KEY_PASSWORD: set" || echo "KEYSTORE_KEY_PASSWORD: EMPTY"
- name: Patch native modules for Android 15 16KB page size
run: |
WORKLETS="node_modules/react-native-worklets/android/CMakeLists.txt"
REANIMATED="node_modules/react-native-reanimated/android/CMakeLists.txt"
if [ -f "$WORKLETS" ] && ! grep -q "max-page-size" "$WORKLETS"; then
printf '\ntarget_link_options(worklets PRIVATE "-Wl,-z,max-page-size=16384")\n' >> "$WORKLETS"
fi
if [ -f "$REANIMATED" ] && ! grep -q "max-page-size" "$REANIMATED"; then
printf '\ntarget_link_options(reanimated PRIVATE "-Wl,-z,max-page-size=16384")\n' >> "$REANIMATED"
fi
- name: Build signed APK
run: ./build-apk.sh
- name: Find built APK
id: apk
run: |
APK=$(ls dist/*.apk | sort | tail -1)
echo "path=$APK" >> "$GITHUB_OUTPUT"
- name: Generate changelog
id: changelog
run: |
PREV_TAG=$(git tag --sort=-version:refname | grep -v "^${{ github.ref_name }}$" | head -1)
echo "Previous tag: $PREV_TAG"
echo "prev_tag=$PREV_TAG" >> "$GITHUB_OUTPUT"
FEATS=$(git log "${PREV_TAG}..HEAD" --pretty=format:"%s" --no-merges \
| grep -E "^feat(\([^)]+\))?: " \
| sed -E 's/^feat(\([^)]+\))?: //' \
| sed 's/^/- /')
FIXES=$(git log "${PREV_TAG}..HEAD" --pretty=format:"%s" --no-merges \
| grep -E "^fix(\([^)]+\))?: " \
| sed -E 's/^fix(\([^)]+\))?: //' \
| sed 's/^/- /')
{
echo "changelog<<CEOF"
[ -n "$FEATS" ] && printf "### What's New\n%s\n\n" "$FEATS"
[ -n "$FIXES" ] && printf "### Bug Fixes\n%s\n\n" "$FIXES"
echo "CEOF"
} >> "$GITHUB_OUTPUT"
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
name: "SheetHappens ${{ github.ref_name }}"
body: |
## SheetHappens ${{ github.ref_name }}
${{ steps.changelog.outputs.changelog }}
[Full changelog](https://github.com/${{ github.repository }}/compare/${{ steps.changelog.outputs.prev_tag }}...${{ github.ref_name }}) · [CHANGELOG.md](https://github.com/${{ github.repository }}/blob/main/CHANGELOG.md)
### Installation
1. Enable "Unknown sources" on the device
2. Transfer the APK to the device and open it to install
files: ${{ steps.apk.outputs.path }}
draft: false
prerelease: ${{ contains(github.ref_name, '-') }}
- name: Create Gitea Release
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
TAG: ${{ github.ref_name }}
PREV_TAG: ${{ steps.changelog.outputs.prev_tag }}
CHANGELOG: ${{ steps.changelog.outputs.changelog }}
GH_REPO: ${{ github.repository }}
run: |
if [ -z "$GITEA_TOKEN" ]; then
echo "GITEA_TOKEN not set — skipping Gitea release"
exit 0
fi
BODY="## SheetHappens ${TAG}
${CHANGELOG}
[Full changelog](https://homegit.gyozamancave.fr/billisdead/SheetHappens/compare/${PREV_TAG}...${TAG}) · [CHANGELOG.md](https://homegit.gyozamancave.fr/billisdead/SheetHappens/raw/branch/main/CHANGELOG.md)
### Installation
Download the APK from the [GitHub release](https://github.com/${GH_REPO}/releases/tag/${TAG}), enable \"Unknown sources\" on the device and install."
curl -sf -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
"https://homegit.gyozamancave.fr/api/v1/repos/billisdead/SheetHappens/releases" \
-d "{
\"tag_name\": \"${TAG}\",
\"name\": \"SheetHappens ${TAG}\",
\"body\": $(printf '%s' "$BODY" | jq -Rs .),
\"draft\": false,
\"prerelease\": $([ "${TAG#*-}" != "${TAG}" ] && echo true || echo false)
}"
+46
View File
@@ -0,0 +1,46 @@
# Changelog
All notable changes to SheetHappens are documented here.
Each release links to the full diff for detailed context.
---
## [v0.1.7] — 2026-06-22
### Bug Fixes
- Revert splash screen overrides that caused Android crash-to-desktop — expo-router already manages the splash lifecycle internally
- Remove redundant `SafeAreaProvider` wrapper (expo-router's `ExpoRoot` already injects it)
[View changes](https://github.com/pirona/SheetHappens/compare/v0.1.6...v0.1.7)
---
## [v0.1.6] — 2026-06-22
### Bug Fixes
- Prevent black screen on launch by holding splash until SecureStore settings load
- Add `splash.backgroundColor` to `app.json` to match app theme on Android
[View changes](https://github.com/pirona/SheetHappens/compare/v0.1.5...v0.1.6)
---
## [v0.1.5] — 2026-06-22
### Bug Fixes
- Fix signed APK release: inject signing secrets via env vars, write `signing.env` with `printf %q` to handle special characters
- Add `signing.env` verification step in CI
[View changes](https://github.com/pirona/SheetHappens/compare/v0.1.0...v0.1.5)
---
## [v0.1.0] — Initial release
### Features
- Incident log with SQLite persistence
- Voice input via `expo-speech-recognition`
- Edit mode, service autocomplete
- Dark theme (NativeWind + expo-router)
- Onboarding flow
- Signed APK via GitHub Actions CI
+3 -2
View File
@@ -5,12 +5,13 @@
"version": "0.1.0", "version": "0.1.0",
"orientation": "portrait", "orientation": "portrait",
"userInterfaceStyle": "dark", "userInterfaceStyle": "dark",
"backgroundColor": "#0f1117", "icon": "./assets/icon.png",
"backgroundColor": "#1B2433",
"scheme": "sheethappens", "scheme": "sheethappens",
"android": { "android": {
"adaptiveIcon": { "adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png", "foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#0f1117" "backgroundColor": "#1B2433"
}, },
"package": "fr.gyozamancave.sheethappens", "package": "fr.gyozamancave.sheethappens",
"permissions": [ "permissions": [
+27 -2
View File
@@ -1,10 +1,22 @@
import { Stack } from "expo-router"; import { useEffect } from "react";
import { Pressable, Text } from "react-native";
import { Stack, router } from "expo-router";
import { StatusBar } from "expo-status-bar"; import { StatusBar } from "expo-status-bar";
import { GestureHandlerRootView } from "react-native-gesture-handler"; import { GestureHandlerRootView } from "react-native-gesture-handler";
import "../global.css"; import "../global.css";
import { Colors } from "@/constants/theme"; import { Colors } from "@/constants/theme";
import { useSettings } from "@/hooks/useSettings";
export default function RootLayout() { export default function RootLayout() {
const { settings, ready } = useSettings();
useEffect(() => {
if (!ready) return;
if (!settings.onboardingDone) {
router.replace("/onboarding");
}
}, [ready, settings.onboardingDone]);
return ( return (
<GestureHandlerRootView style={{ flex: 1, backgroundColor: Colors.bg }}> <GestureHandlerRootView style={{ flex: 1, backgroundColor: Colors.bg }}>
<StatusBar style="light" /> <StatusBar style="light" />
@@ -17,7 +29,20 @@ export default function RootLayout() {
animation: "slide_from_right", animation: "slide_from_right",
}} }}
> >
<Stack.Screen name="index" options={{ title: "SheetHappens" }} /> <Stack.Screen
name="index"
options={{
title: "SheetHappens",
headerRight: () => (
<Pressable
onPress={() => router.push("/settings")}
style={{ paddingLeft: 12, paddingRight: 4, paddingVertical: 6 }}
>
<Text style={{ color: Colors.primary, fontSize: 22, lineHeight: 26 }}></Text>
</Pressable>
),
}}
/>
<Stack.Screen name="new" options={{ title: "New Incident", presentation: "modal" }} /> <Stack.Screen name="new" options={{ title: "New Incident", presentation: "modal" }} />
<Stack.Screen name="incident/[id]" options={{ title: "Incident" }} /> <Stack.Screen name="incident/[id]" options={{ title: "Incident" }} />
<Stack.Screen name="settings" options={{ title: "Settings" }} /> <Stack.Screen name="settings" options={{ title: "Settings" }} />
+128 -3
View File
@@ -9,8 +9,11 @@ import {
} from "react-native"; } from "react-native";
import { useLocalSearchParams, router, useNavigation } from "expo-router"; import { useLocalSearchParams, router, useNavigation } from "expo-router";
import * as Clipboard from "expo-clipboard"; import * as Clipboard from "expo-clipboard";
import { getIncidentById, updateIncident } from "@/lib/db"; import * as SecureStore from "expo-secure-store";
import { getIncidentById, updateIncident, deleteIncident } from "@/lib/db";
import { renderMarkdown } from "@/lib/markdown"; import { renderMarkdown } from "@/lib/markdown";
import { pushIncidentToGit } from "@/lib/git";
import type { GitProvider } from "@/lib/git";
import type { Incident } from "@/types/incident"; import type { Incident } from "@/types/incident";
import { Colors } from "@/constants/theme"; import { Colors } from "@/constants/theme";
@@ -38,6 +41,7 @@ export default function IncidentDetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>(); const { id } = useLocalSearchParams<{ id: string }>();
const navigation = useNavigation(); const navigation = useNavigation();
const [incident, setIncident] = useState<Incident | null>(null); const [incident, setIncident] = useState<Incident | null>(null);
const [pushing, setPushing] = useState(false);
useEffect(() => { useEffect(() => {
if (id) { if (id) {
@@ -50,12 +54,93 @@ export default function IncidentDetailScreen() {
} }
}, [id]); }, [id]);
async function handleDelete() {
if (!incident) return;
Alert.alert(
"Delete incident?",
"This action cannot be undone.",
[
{ text: "Cancel", style: "cancel" },
{
text: "Delete",
style: "destructive",
onPress: async () => {
await deleteIncident(incident.id);
router.replace("/");
},
},
]
);
}
async function handleResolve() { async function handleResolve() {
if (!incident) return; if (!incident) return;
await updateIncident(incident.id, { status: "resolved" }); await updateIncident(incident.id, { status: "resolved" });
setIncident({ ...incident, status: "resolved" }); setIncident({ ...incident, status: "resolved" });
} }
async function handleGitPush() {
if (!incident) return;
const [provider, template, giteaUrl, giteaToken, giteaOwner, giteaRepo, githubToken, githubOwner, githubRepo, gitlabUrl, gitlabToken, gitlabOwner, gitlabRepo] =
await Promise.all([
SecureStore.getItemAsync("git_provider"),
SecureStore.getItemAsync("md_template"),
SecureStore.getItemAsync("gitea_url"),
SecureStore.getItemAsync("gitea_token"),
SecureStore.getItemAsync("gitea_owner"),
SecureStore.getItemAsync("gitea_repo"),
SecureStore.getItemAsync("github_token"),
SecureStore.getItemAsync("github_owner"),
SecureStore.getItemAsync("github_repo"),
SecureStore.getItemAsync("gitlab_url"),
SecureStore.getItemAsync("gitlab_token"),
SecureStore.getItemAsync("gitlab_owner"),
SecureStore.getItemAsync("gitlab_repo"),
]);
const p = (provider ?? "gitea") as GitProvider;
let url: string | undefined;
let token: string | null;
let owner: string | null;
let repo: string | null;
if (p === "gitea") {
url = giteaUrl ?? undefined;
token = giteaToken;
owner = giteaOwner;
repo = giteaRepo;
} else if (p === "github") {
token = githubToken;
owner = githubOwner;
repo = githubRepo;
} else {
url = gitlabUrl ?? undefined;
token = gitlabToken;
owner = gitlabOwner;
repo = gitlabRepo;
}
const needsUrl = p === "gitea" || p === "gitlab";
if (!token || !owner || !repo || (needsUrl && !url)) {
Alert.alert("Git not configured", `Set up ${p.charAt(0).toUpperCase() + p.slice(1)} in Settings first.`);
return;
}
setPushing(true);
const result = await pushIncidentToGit(
incident,
{ provider: p, url, token, owner, repo },
template ?? undefined
);
setPushing(false);
if (result.success) {
Alert.alert("Pushed", result.url ?? "Push successful.");
} else {
Alert.alert("Push failed", result.error ?? "Unknown error");
}
}
async function handleExport() { async function handleExport() {
if (!incident) return; if (!incident) return;
const md = renderMarkdown(incident); const md = renderMarkdown(incident);
@@ -87,7 +172,7 @@ export default function IncidentDetailScreen() {
<View <View
style={{ style={{
backgroundColor: backgroundColor:
incident.status === "open" ? "#f59e0b20" : "#22c55e20", incident.status === "open" ? "#f59e0b20" : "#88D65620",
borderRadius: 6, borderRadius: 6,
paddingHorizontal: 10, paddingHorizontal: 10,
paddingVertical: 4, paddingVertical: 4,
@@ -158,7 +243,7 @@ export default function IncidentDetailScreen() {
<Pressable <Pressable
onPress={handleResolve} onPress={handleResolve}
style={{ style={{
backgroundColor: "#22c55e20", backgroundColor: "#88D65620",
borderWidth: 1, borderWidth: 1,
borderColor: Colors.success, borderColor: Colors.success,
borderRadius: 10, borderRadius: 10,
@@ -188,6 +273,29 @@ export default function IncidentDetailScreen() {
</Text> </Text>
</Pressable> </Pressable>
<Pressable
onPress={handleGitPush}
disabled={pushing}
style={{
backgroundColor: pushing ? Colors.surface2 : Colors.surface,
borderWidth: 1,
borderColor: pushing ? Colors.border : Colors.primary,
borderRadius: 10,
padding: 14,
alignItems: "center",
}}
>
<Text
style={{
color: pushing ? Colors.textDim : Colors.primary,
fontWeight: "600",
fontSize: 15,
}}
>
{pushing ? "Pushing…" : "Push to Git"}
</Text>
</Pressable>
<Pressable <Pressable
onPress={() => router.push({ pathname: "/new", params: { editId: incident.id } })} onPress={() => router.push({ pathname: "/new", params: { editId: incident.id } })}
style={{ style={{
@@ -203,6 +311,23 @@ export default function IncidentDetailScreen() {
Edit Edit
</Text> </Text>
</Pressable> </Pressable>
<Pressable
onPress={handleDelete}
style={{
backgroundColor: "#ef444410",
borderWidth: 1,
borderColor: "#ef4444",
borderRadius: 10,
padding: 14,
alignItems: "center",
marginTop: 8,
}}
>
<Text style={{ color: "#ef4444", fontWeight: "600", fontSize: 15 }}>
Delete Incident
</Text>
</Pressable>
</View> </View>
</ScrollView> </ScrollView>
); );
+17 -4
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { import {
View, View,
Text, Text,
@@ -6,18 +6,31 @@ import {
Pressable, Pressable,
ActivityIndicator, ActivityIndicator,
} from "react-native"; } from "react-native";
import { router } from "expo-router"; import { router, useFocusEffect } from "expo-router";
import { getIncidents } from "@/lib/db"; import { getIncidents } from "@/lib/db";
import { useSettings } from "@/hooks/useSettings";
import type { Incident } from "@/types/incident"; import type { Incident } from "@/types/incident";
import { Colors } from "@/constants/theme"; import { Colors } from "@/constants/theme";
export default function HomeScreen() { export default function HomeScreen() {
const [incidents, setIncidents] = useState<Incident[]>([]); const [incidents, setIncidents] = useState<Incident[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const { settings, ready } = useSettings();
const didRedirect = useRef(false);
useEffect(() => { useEffect(() => {
if (!ready || didRedirect.current) return;
if (settings.homeView === "capture") {
didRedirect.current = true;
router.push("/new");
}
}, [ready, settings.homeView]);
useFocusEffect(
useCallback(() => {
loadIncidents(); loadIncidents();
}, []); }, [])
);
async function loadIncidents() { async function loadIncidents() {
setLoading(true); setLoading(true);
@@ -87,7 +100,7 @@ export default function HomeScreen() {
backgroundColor: backgroundColor:
item.status === "open" item.status === "open"
? "#f59e0b20" ? "#f59e0b20"
: "#22c55e20", : "#88D65620",
borderRadius: 4, borderRadius: 4,
paddingHorizontal: 8, paddingHorizontal: 8,
paddingVertical: 2, paddingVertical: 2,
+233 -16
View File
@@ -1,4 +1,4 @@
import { useState } from "react"; import { useState, useEffect, useRef } from "react";
import { import {
View, View,
Text, Text,
@@ -9,9 +9,18 @@ import {
Platform, Platform,
Alert, Alert,
} from "react-native"; } from "react-native";
import { router } from "expo-router"; import { router, useLocalSearchParams, useNavigation } from "expo-router";
import { createIncident } from "@/lib/db"; import * as SecureStore from "expo-secure-store";
import {
createIncident,
updateIncident,
getIncidentById,
getDistinctServices,
getMaxTitleNumber,
} from "@/lib/db";
import { Colors } from "@/constants/theme"; import { Colors } from "@/constants/theme";
import { useVoice } from "@/hooks/useVoice";
import { callAI } from "@/lib/ai";
import type { IncidentDraft } from "@/types/incident"; import type { IncidentDraft } from "@/types/incident";
const FIELD_STYLE = { const FIELD_STYLE = {
@@ -38,10 +47,15 @@ const LABEL_STYLE = {
fontWeight: "600" as const, fontWeight: "600" as const,
textTransform: "uppercase" as const, textTransform: "uppercase" as const,
letterSpacing: 0.5, letterSpacing: 0.5,
marginBottom: 6,
} as const; } as const;
type VoiceField = "title" | "service" | "symptom" | "rootCause" | "fix";
export default function NewIncidentScreen() { export default function NewIncidentScreen() {
const { editId } = useLocalSearchParams<{ editId?: string }>();
const navigation = useNavigation();
const [form, setForm] = useState<IncidentDraft>({ const [form, setForm] = useState<IncidentDraft>({
title: "", title: "",
service: "", service: "",
@@ -53,6 +67,70 @@ export default function NewIncidentScreen() {
}); });
const [tagInput, setTagInput] = useState(""); const [tagInput, setTagInput] = useState("");
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [suggesting, setSuggesting] = useState(false);
const [suggestError, setSuggestError] = useState("");
const [aiEnabled, setAiEnabled] = useState(false);
const [knownServices, setKnownServices] = useState<string[]>([]);
const [serviceFocused, setServiceFocused] = useState(false);
const { state: voiceState, transcript, start: startVoice, stop: stopVoice } = useVoice();
const voiceFieldRef = useRef<VoiceField | null>(null);
const [listeningField, setListeningField] = useState<VoiceField | null>(null);
useEffect(() => {
getDistinctServices().then(setKnownServices);
SecureStore.getItemAsync("pref_ai_enabled").then((v) => setAiEnabled(v === "true"));
}, []);
useEffect(() => {
if (editId) return;
SecureStore.getItemAsync("pref_title_template").then((tpl) => {
if (tpl) setForm((f) => ({ ...f, title: tpl }));
});
}, [editId]);
useEffect(() => {
if (!editId) return;
navigation.setOptions({ title: "Edit Incident" });
getIncidentById(editId).then((inc) => {
if (!inc) return;
setForm({
title: inc.title,
service: inc.service,
symptom: inc.symptom,
rootCause: inc.rootCause,
fix: inc.fix,
status: inc.status,
tags: inc.tags,
});
});
}, [editId]);
// Live-fill the active voice field as transcript updates
useEffect(() => {
const field = voiceFieldRef.current;
if (transcript && field) {
setForm((f) => ({ ...f, [field]: transcript }));
}
}, [transcript]);
// Clear listening state when recognition ends
useEffect(() => {
if ((voiceState === "idle" || voiceState === "error") && voiceFieldRef.current !== null) {
voiceFieldRef.current = null;
setListeningField(null);
}
}, [voiceState]);
function handleMic(field: VoiceField) {
if (listeningField !== null) {
stopVoice();
return;
}
voiceFieldRef.current = field;
setListeningField(field);
startVoice("fr-FR");
}
function set(key: keyof IncidentDraft) { function set(key: keyof IncidentDraft) {
return (value: string) => setForm((f) => ({ ...f, [key]: value })); return (value: string) => setForm((f) => ({ ...f, [key]: value }));
@@ -70,6 +148,19 @@ export default function NewIncidentScreen() {
setForm((f) => ({ ...f, tags: f.tags.filter((t) => t !== tag) })); setForm((f) => ({ ...f, tags: f.tags.filter((t) => t !== tag) }));
} }
async function handleSuggest() {
setSuggesting(true);
setSuggestError("");
try {
const result = await callAI(form.title, form.symptom);
setForm((f) => ({ ...f, rootCause: result.rootCause, fix: result.fix }));
} catch (e) {
setSuggestError(e instanceof Error ? e.message : "AI call failed");
} finally {
setSuggesting(false);
}
}
async function handleSave() { async function handleSave() {
if (!form.title.trim()) { if (!form.title.trim()) {
Alert.alert("Required", "Title is required."); Alert.alert("Required", "Title is required.");
@@ -77,15 +168,82 @@ export default function NewIncidentScreen() {
} }
setSaving(true); setSaving(true);
try { try {
if (editId) {
await updateIncident(editId, form);
} else {
await createIncident(form); await createIncident(form);
const tpl = await SecureStore.getItemAsync("pref_title_template");
if (tpl) {
const match = tpl.match(/^([\s\S]*?)(\d+)$/);
if (match) {
const [, prefix, numStr] = match;
const dbMax = await getMaxTitleNumber(prefix);
const tplNum = parseInt(numStr, 10);
const next = (Math.max(dbMax, tplNum) + 1).toString().padStart(numStr.length, "0");
await SecureStore.setItemAsync("pref_title_template", prefix + next);
}
}
}
router.back(); router.back();
} catch (e) { } catch {
Alert.alert("Error", "Failed to save incident."); Alert.alert("Error", "Failed to save incident.");
} finally { } finally {
setSaving(false); setSaving(false);
} }
} }
const filteredServices =
serviceFocused && form.service.length > 0
? knownServices.filter(
(s) =>
s.toLowerCase().includes(form.service.toLowerCase()) &&
s !== form.service
)
: [];
function micButton(field: VoiceField) {
const active = listeningField === field;
return (
<Pressable
onPress={() => handleMic(field)}
style={{
paddingHorizontal: 8,
paddingVertical: 3,
borderRadius: 4,
backgroundColor: active ? "#ef444420" : Colors.surface2,
borderWidth: 1,
borderColor: active ? Colors.danger : Colors.border,
}}
>
<Text
style={{
color: active ? Colors.danger : Colors.text2,
fontSize: 11,
fontWeight: "700",
}}
>
{active ? "STOP" : "MIC"}
</Text>
</Pressable>
);
}
function labelRow(label: string, field: VoiceField) {
return (
<View
style={{
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 6,
}}
>
<Text style={LABEL_STYLE}>{label}</Text>
{micButton(field)}
</View>
);
}
return ( return (
<KeyboardAvoidingView <KeyboardAvoidingView
style={{ flex: 1, backgroundColor: Colors.bg }} style={{ flex: 1, backgroundColor: Colors.bg }}
@@ -95,7 +253,7 @@ export default function NewIncidentScreen() {
contentContainerStyle={{ padding: 16, paddingBottom: 32 }} contentContainerStyle={{ padding: 16, paddingBottom: 32 }}
keyboardShouldPersistTaps="handled" keyboardShouldPersistTaps="handled"
> >
<Text style={LABEL_STYLE}>Title *</Text> {labelRow("Title *", "title")}
<TextInput <TextInput
style={FIELD_STYLE} style={FIELD_STYLE}
value={form.title} value={form.title}
@@ -105,18 +263,50 @@ export default function NewIncidentScreen() {
returnKeyType="next" returnKeyType="next"
/> />
<Text style={LABEL_STYLE}>Service</Text> {labelRow("Service", "service")}
<TextInput <TextInput
style={FIELD_STYLE} style={FIELD_STYLE}
value={form.service} value={form.service}
onChangeText={set("service")} onChangeText={set("service")}
onFocus={() => setServiceFocused(true)}
onBlur={() => setTimeout(() => setServiceFocused(false), 150)}
placeholder="e.g. ghost-blog, k3s-cluster, haproxy" placeholder="e.g. ghost-blog, k3s-cluster, haproxy"
placeholderTextColor={Colors.textDim} placeholderTextColor={Colors.textDim}
autoCapitalize="none" autoCapitalize="none"
returnKeyType="next" returnKeyType="next"
/> />
{filteredServices.length > 0 && (
<View
style={{
backgroundColor: Colors.surface2,
borderRadius: 8,
borderWidth: 1,
borderColor: Colors.border,
marginTop: -12,
marginBottom: 16,
overflow: "hidden",
}}
>
{filteredServices.slice(0, 5).map((s, i) => (
<Pressable
key={s}
onPress={() => {
setForm((f) => ({ ...f, service: s }));
setServiceFocused(false);
}}
style={{
padding: 12,
borderTopWidth: i === 0 ? 0 : 1,
borderTopColor: Colors.border,
}}
>
<Text style={{ color: Colors.text1, fontSize: 14 }}>{s}</Text>
</Pressable>
))}
</View>
)}
<Text style={LABEL_STYLE}>Symptom</Text> {labelRow("Symptom", "symptom")}
<TextInput <TextInput
style={MONO_FIELD_STYLE} style={MONO_FIELD_STYLE}
value={form.symptom} value={form.symptom}
@@ -127,7 +317,36 @@ export default function NewIncidentScreen() {
returnKeyType="next" returnKeyType="next"
/> />
<Text style={LABEL_STYLE}>Root Cause</Text> {aiEnabled && form.title.trim() && form.symptom.trim() && (
<View style={{ marginBottom: 16 }}>
<Pressable
onPress={handleSuggest}
disabled={suggesting}
style={{
backgroundColor: suggesting ? Colors.surface2 : Colors.surface,
borderRadius: 8,
padding: 12,
alignItems: "center",
borderWidth: 1,
borderColor: Colors.primary,
flexDirection: "row",
justifyContent: "center",
gap: 8,
}}
>
<Text style={{ color: Colors.primary, fontSize: 14, fontWeight: "700" }}>
{suggesting ? "Analyzing…" : "✦ Suggest root cause & fix"}
</Text>
</Pressable>
{suggestError !== "" && (
<Text style={{ color: Colors.danger, fontSize: 12, marginTop: 6 }}>
{suggestError}
</Text>
)}
</View>
)}
{labelRow("Root Cause", "rootCause")}
<TextInput <TextInput
style={MONO_FIELD_STYLE} style={MONO_FIELD_STYLE}
value={form.rootCause} value={form.rootCause}
@@ -138,7 +357,7 @@ export default function NewIncidentScreen() {
returnKeyType="next" returnKeyType="next"
/> />
<Text style={LABEL_STYLE}>Fix Applied</Text> {labelRow("Fix Applied", "fix")}
<TextInput <TextInput
style={MONO_FIELD_STYLE} style={MONO_FIELD_STYLE}
value={form.fix} value={form.fix}
@@ -149,7 +368,7 @@ export default function NewIncidentScreen() {
returnKeyType="done" returnKeyType="done"
/> />
<Text style={LABEL_STYLE}>Tags</Text> <Text style={[LABEL_STYLE, { marginBottom: 6 }]}>Tags</Text>
<TextInput <TextInput
style={FIELD_STYLE} style={FIELD_STYLE}
value={tagInput} value={tagInput}
@@ -181,7 +400,7 @@ export default function NewIncidentScreen() {
}} }}
> >
<Text style={{ color: Colors.text2, fontSize: 13 }}>{tag}</Text> <Text style={{ color: Colors.text2, fontSize: 13 }}>{tag}</Text>
<Text style={{ color: Colors.textDim, fontSize: 12 }}></Text> <Text style={{ color: Colors.textDim, fontSize: 12 }}>x</Text>
</Pressable> </Pressable>
))} ))}
</View> </View>
@@ -198,10 +417,8 @@ export default function NewIncidentScreen() {
marginTop: 8, marginTop: 8,
}} }}
> >
<Text <Text style={{ color: "#fff", fontSize: 16, fontWeight: "700" }}>
style={{ color: "#fff", fontSize: 16, fontWeight: "700" }} {saving ? "Saving…" : editId ? "Save Changes" : "Save Incident"}
>
{saving ? "Saving…" : "Save Incident"}
</Text> </Text>
</Pressable> </Pressable>
</ScrollView> </ScrollView>
+342 -100
View File
@@ -6,22 +6,36 @@ import {
ScrollView, ScrollView,
Switch, Switch,
Pressable, Pressable,
Alert,
} from "react-native"; } from "react-native";
import * as SecureStore from "expo-secure-store"; import * as SecureStore from "expo-secure-store";
import { Colors } from "@/constants/theme"; import { Colors } from "@/constants/theme";
import { DEFAULT_TEMPLATE } from "@/lib/markdown"; import { DEFAULT_TEMPLATE } from "@/lib/markdown";
import type { GitProvider } from "@/lib/git";
import type { AIProvider } from "@/lib/ai";
import { AI_PROVIDER_BASE_URLS, AI_PROVIDER_MODEL_PLACEHOLDERS } from "@/lib/ai";
const KEYS = { const KEYS = {
INPUT_MODE: "pref_input_mode", INPUT_MODE: "pref_input_mode",
HOME_VIEW: "pref_home_view", HOME_VIEW: "pref_home_view",
AI_ENABLED: "pref_ai_enabled", AI_ENABLED: "pref_ai_enabled",
AI_PROVIDER: "pref_ai_provider",
AI_KEY: "pref_ai_key", AI_KEY: "pref_ai_key",
AI_BASE_URL: "pref_ai_base_url",
AI_MODEL: "pref_ai_model",
TITLE_TEMPLATE: "pref_title_template",
MD_TEMPLATE: "md_template",
GIT_PROVIDER: "git_provider",
GITEA_URL: "gitea_url", GITEA_URL: "gitea_url",
GITEA_TOKEN: "gitea_token", GITEA_TOKEN: "gitea_token",
GITEA_OWNER: "gitea_owner", GITEA_OWNER: "gitea_owner",
GITEA_REPO: "gitea_repo", GITEA_REPO: "gitea_repo",
MD_TEMPLATE: "md_template", GITHUB_TOKEN: "github_token",
GITHUB_OWNER: "github_owner",
GITHUB_REPO: "github_repo",
GITLAB_URL: "gitlab_url",
GITLAB_TOKEN: "gitlab_token",
GITLAB_OWNER: "gitlab_owner",
GITLAB_REPO: "gitlab_repo",
} as const; } as const;
const LABEL = { const LABEL = {
@@ -53,58 +67,36 @@ const INPUT = {
fontFamily: "monospace", fontFamily: "monospace",
} as const; } as const;
export default function SettingsScreen() { const PROVIDER_LABELS: Record<GitProvider, string> = {
const [inputMode, setInputMode] = useState<"voice" | "form">("form"); gitea: "Gitea",
const [homeView, setHomeView] = useState<"dashboard" | "capture">("dashboard"); github: "GitHub",
const [aiEnabled, setAiEnabled] = useState(false); gitlab: "GitLab",
const [aiKey, setAiKey] = useState(""); };
const [giteaUrl, setGiteaUrl] = useState("");
const [giteaToken, setGiteaToken] = useState("");
const [giteaOwner, setGiteaOwner] = useState("");
const [giteaRepo, setGiteaRepo] = useState("");
const [mdTemplate, setMdTemplate] = useState(DEFAULT_TEMPLATE);
const [saved, setSaved] = useState(false);
useEffect(() => { function parseRepoUrl(raw: string, provider: GitProvider): { instanceUrl?: string; owner?: string; repo?: string } {
(async () => { try {
const [im, hv, ai, key, gu, gt, go, gr, tpl] = await Promise.all([ const cleaned = raw.trim().replace(/\.git$/, "");
SecureStore.getItemAsync(KEYS.INPUT_MODE), const u = new URL(cleaned);
SecureStore.getItemAsync(KEYS.HOME_VIEW), const parts = u.pathname.split("/").filter(Boolean);
SecureStore.getItemAsync(KEYS.AI_ENABLED), if (parts.length < 2) return {};
SecureStore.getItemAsync(KEYS.AI_KEY), const repo = parts[parts.length - 1];
SecureStore.getItemAsync(KEYS.GITEA_URL), const owner = parts[parts.length - 2];
SecureStore.getItemAsync(KEYS.GITEA_TOKEN), const instanceUrl = provider !== "github" ? `${u.protocol}//${u.host}` : undefined;
SecureStore.getItemAsync(KEYS.GITEA_OWNER), return { instanceUrl, owner, repo };
SecureStore.getItemAsync(KEYS.GITEA_REPO), } catch {
SecureStore.getItemAsync(KEYS.MD_TEMPLATE), return {};
]);
if (im) setInputMode(im as "voice" | "form");
if (hv) setHomeView(hv as "dashboard" | "capture");
if (ai) setAiEnabled(ai === "true");
if (key) setAiKey(key);
if (gu) setGiteaUrl(gu);
if (gt) setGiteaToken(gt);
if (go) setGiteaOwner(go);
if (gr) setGiteaRepo(gr);
if (tpl) setMdTemplate(tpl);
})();
}, []);
async function handleSave() {
await Promise.all([
SecureStore.setItemAsync(KEYS.INPUT_MODE, inputMode),
SecureStore.setItemAsync(KEYS.HOME_VIEW, homeView),
SecureStore.setItemAsync(KEYS.AI_ENABLED, String(aiEnabled)),
SecureStore.setItemAsync(KEYS.AI_KEY, aiKey),
SecureStore.setItemAsync(KEYS.GITEA_URL, giteaUrl),
SecureStore.setItemAsync(KEYS.GITEA_TOKEN, giteaToken),
SecureStore.setItemAsync(KEYS.GITEA_OWNER, giteaOwner),
SecureStore.setItemAsync(KEYS.GITEA_REPO, giteaRepo),
SecureStore.setItemAsync(KEYS.MD_TEMPLATE, mdTemplate),
]);
setSaved(true);
setTimeout(() => setSaved(false), 2000);
} }
}
function buildRepoUrl(instanceUrl: string | null, owner: string | null, repo: string | null, provider: GitProvider): string {
if (!owner || !repo) return "";
const base = provider === "github" ? "https://github.com" : (instanceUrl ?? "");
if (!base) return "";
return `${base}/${owner}/${repo}.git`;
}
// Defined outside SettingsScreen — prevents remount on every rerender, which
// would cause TextInput to lose focus after each keystroke.
function ToggleRow({ function ToggleRow({
label, label,
@@ -143,22 +135,24 @@ export default function SettingsScreen() {
options, options,
value, value,
onChange, onChange,
wrap,
}: { }: {
label: string; label: string;
options: { key: string; label: string }[]; options: { key: string; label: string }[];
value: string; value: string;
onChange: (v: string) => void; onChange: (v: string) => void;
wrap?: boolean;
}) { }) {
return ( return (
<View style={{ marginBottom: 16 }}> <View style={{ marginBottom: 16 }}>
<Text style={LABEL}>{label}</Text> <Text style={LABEL}>{label}</Text>
<View style={{ flexDirection: "row", gap: 8 }}> <View style={{ flexDirection: "row", gap: 8, flexWrap: wrap ? "wrap" : "nowrap" }}>
{options.map((opt) => ( {options.map((opt) => (
<Pressable <Pressable
key={opt.key} key={opt.key}
onPress={() => onChange(opt.key)} onPress={() => onChange(opt.key)}
style={{ style={{
flex: 1, ...(wrap ? { width: "31%" } : { flex: 1 }),
backgroundColor: backgroundColor:
value === opt.key ? Colors.primary : Colors.surface, value === opt.key ? Colors.primary : Colors.surface,
borderRadius: 8, borderRadius: 8,
@@ -171,8 +165,8 @@ export default function SettingsScreen() {
> >
<Text <Text
style={{ style={{
color: value === opt.key ? "#fff" : Colors.text2, color: value === opt.key ? Colors.bg : Colors.text2,
fontWeight: "600", fontWeight: "700",
fontSize: 13, fontSize: 13,
}} }}
> >
@@ -185,6 +179,139 @@ export default function SettingsScreen() {
); );
} }
function Field({
label,
value,
onChange,
placeholder,
secure,
url,
}: {
label: string;
value: string;
onChange: (v: string) => void;
placeholder?: string;
secure?: boolean;
url?: boolean;
}) {
return (
<>
<Text style={LABEL}>{label}</Text>
<TextInput
style={INPUT}
value={value}
onChangeText={onChange}
placeholder={placeholder}
placeholderTextColor={Colors.textDim}
secureTextEntry={secure}
autoCapitalize="none"
autoCorrect={false}
keyboardType={url ? "url" : "default"}
/>
</>
);
}
export default function SettingsScreen() {
const [inputMode, setInputMode] = useState<"voice" | "form">("form");
const [homeView, setHomeView] = useState<"dashboard" | "capture">("dashboard");
const [aiEnabled, setAiEnabled] = useState(false);
const [aiProvider, setAiProvider] = useState<AIProvider>("openai");
const [aiKey, setAiKey] = useState("");
const [aiBaseUrl, setAiBaseUrl] = useState("");
const [aiModel, setAiModel] = useState("");
const [titleTemplate, setTitleTemplate] = useState("");
const [mdTemplate, setMdTemplate] = useState(DEFAULT_TEMPLATE);
const [saved, setSaved] = useState(false);
const [gitProvider, setGitProvider] = useState<GitProvider>("gitea");
const [giteaRepoUrl, setGiteaRepoUrl] = useState("");
const [giteaToken, setGiteaToken] = useState("");
const [githubRepoUrl, setGithubRepoUrl] = useState("");
const [githubToken, setGithubToken] = useState("");
const [gitlabRepoUrl, setGitlabRepoUrl] = useState("");
const [gitlabToken, setGitlabToken] = useState("");
useEffect(() => {
(async () => {
const [
im, hv, ai, aip, key, aiUrl, aiMdl, titleTpl, tpl,
gp, gu, gt, go, gr,
ghu, ghown, ghrepo,
glu, glt, glo, glr,
] = await Promise.all([
SecureStore.getItemAsync(KEYS.INPUT_MODE),
SecureStore.getItemAsync(KEYS.HOME_VIEW),
SecureStore.getItemAsync(KEYS.AI_ENABLED),
SecureStore.getItemAsync(KEYS.AI_PROVIDER),
SecureStore.getItemAsync(KEYS.AI_KEY),
SecureStore.getItemAsync(KEYS.AI_BASE_URL),
SecureStore.getItemAsync(KEYS.AI_MODEL),
SecureStore.getItemAsync(KEYS.TITLE_TEMPLATE),
SecureStore.getItemAsync(KEYS.MD_TEMPLATE),
SecureStore.getItemAsync(KEYS.GIT_PROVIDER),
SecureStore.getItemAsync(KEYS.GITEA_URL),
SecureStore.getItemAsync(KEYS.GITEA_TOKEN),
SecureStore.getItemAsync(KEYS.GITEA_OWNER),
SecureStore.getItemAsync(KEYS.GITEA_REPO),
SecureStore.getItemAsync(KEYS.GITHUB_TOKEN),
SecureStore.getItemAsync(KEYS.GITHUB_OWNER),
SecureStore.getItemAsync(KEYS.GITHUB_REPO),
SecureStore.getItemAsync(KEYS.GITLAB_URL),
SecureStore.getItemAsync(KEYS.GITLAB_TOKEN),
SecureStore.getItemAsync(KEYS.GITLAB_OWNER),
SecureStore.getItemAsync(KEYS.GITLAB_REPO),
]);
if (im) setInputMode(im as "voice" | "form");
if (hv) setHomeView(hv as "dashboard" | "capture");
if (ai) setAiEnabled(ai === "true");
if (aip) setAiProvider(aip as AIProvider);
if (key) setAiKey(key);
if (aiUrl) setAiBaseUrl(aiUrl);
if (aiMdl) setAiModel(aiMdl);
if (titleTpl) setTitleTemplate(titleTpl);
if (tpl) setMdTemplate(tpl);
if (gp) setGitProvider(gp as GitProvider);
if (gt) setGiteaToken(gt);
setGiteaRepoUrl(buildRepoUrl(gu, go, gr, "gitea"));
if (ghu) setGithubToken(ghu);
setGithubRepoUrl(buildRepoUrl(null, ghown, ghrepo, "github"));
if (glt) setGitlabToken(glt);
setGitlabRepoUrl(buildRepoUrl(glu, glo, glr, "gitlab"));
})();
}, []);
async function handleSave() {
const gitea = parseRepoUrl(giteaRepoUrl, "gitea");
const github = parseRepoUrl(githubRepoUrl, "github");
const gitlab = parseRepoUrl(gitlabRepoUrl, "gitlab");
await Promise.all([
SecureStore.setItemAsync(KEYS.INPUT_MODE, inputMode),
SecureStore.setItemAsync(KEYS.HOME_VIEW, homeView),
SecureStore.setItemAsync(KEYS.AI_ENABLED, String(aiEnabled)),
SecureStore.setItemAsync(KEYS.AI_PROVIDER, aiProvider),
SecureStore.setItemAsync(KEYS.AI_KEY, aiKey),
SecureStore.setItemAsync(KEYS.AI_BASE_URL, aiBaseUrl),
SecureStore.setItemAsync(KEYS.AI_MODEL, aiModel),
SecureStore.setItemAsync(KEYS.TITLE_TEMPLATE, titleTemplate),
SecureStore.setItemAsync(KEYS.MD_TEMPLATE, mdTemplate),
SecureStore.setItemAsync(KEYS.GIT_PROVIDER, gitProvider),
SecureStore.setItemAsync(KEYS.GITEA_URL, gitea.instanceUrl ?? ""),
SecureStore.setItemAsync(KEYS.GITEA_TOKEN, giteaToken),
SecureStore.setItemAsync(KEYS.GITEA_OWNER, gitea.owner ?? ""),
SecureStore.setItemAsync(KEYS.GITEA_REPO, gitea.repo ?? ""),
SecureStore.setItemAsync(KEYS.GITHUB_TOKEN, githubToken),
SecureStore.setItemAsync(KEYS.GITHUB_OWNER, github.owner ?? ""),
SecureStore.setItemAsync(KEYS.GITHUB_REPO, github.repo ?? ""),
SecureStore.setItemAsync(KEYS.GITLAB_URL, gitlab.instanceUrl ?? ""),
SecureStore.setItemAsync(KEYS.GITLAB_TOKEN, gitlabToken),
SecureStore.setItemAsync(KEYS.GITLAB_OWNER, gitlab.owner ?? ""),
SecureStore.setItemAsync(KEYS.GITLAB_REPO, gitlab.repo ?? ""),
]);
setSaved(true);
setTimeout(() => setSaved(false), 2000);
}
return ( return (
<ScrollView <ScrollView
style={{ flex: 1, backgroundColor: Colors.bg }} style={{ flex: 1, backgroundColor: Colors.bg }}
@@ -213,7 +340,48 @@ export default function SettingsScreen() {
]} ]}
/> />
<Text style={SECTION_TITLE}>AI (optional)</Text> <Field
label="Title Template"
value={titleTemplate}
onChange={setTitleTemplate}
placeholder="INC-001 (leave empty to disable)"
/>
<View
style={{
backgroundColor: Colors.surface,
borderRadius: 8,
padding: 10,
marginTop: -8,
marginBottom: 14,
borderLeftWidth: 3,
borderLeftColor: Colors.text2,
}}
>
<Text style={{ color: Colors.text2, fontSize: 12, lineHeight: 18 }}>
Pre-fills the Title field on new incidents. If the value ends with
digits, the number is auto-incremented after each save.{"\n"}
e.g. INC-001 INC-002 INC-003
</Text>
</View>
<Text style={SECTION_TITLE}>AI Root Cause Assistant</Text>
<View
style={{
backgroundColor: Colors.surface,
borderRadius: 8,
padding: 12,
marginBottom: 14,
borderLeftWidth: 3,
borderLeftColor: Colors.text2,
}}
>
<Text style={{ color: Colors.text2, fontSize: 13, lineHeight: 20 }}>
When enabled, a Suggest button appears between Symptom and Root Cause.
It sends the title and symptom to your AI provider and pre-fills the
analysis fields. Supports Anthropic natively; all others use the
OpenAI-compatible API.
</Text>
</View>
<ToggleRow <ToggleRow
label="Enable AI assistance" label="Enable AI assistance"
value={aiEnabled} value={aiEnabled}
@@ -221,58 +389,132 @@ export default function SettingsScreen() {
/> />
{aiEnabled && ( {aiEnabled && (
<> <>
<Text style={LABEL}>API Key</Text> <SegmentRow
<TextInput label="Provider"
style={INPUT} value={aiProvider}
wrap
onChange={(v) => {
const p = v as AIProvider;
setAiProvider(p);
setAiBaseUrl(AI_PROVIDER_BASE_URLS[p]);
}}
options={[
{ key: "anthropic", label: "Anthropic" },
{ key: "openai", label: "OpenAI" },
{ key: "perplexity", label: "Perplexity" },
{ key: "ollama", label: "Ollama" },
{ key: "openrouter", label: "OpenRouter" },
{ key: "custom", label: "Custom" },
]}
/>
{aiProvider !== "anthropic" && (
<Field
label="Base URL"
value={aiBaseUrl}
onChange={setAiBaseUrl}
placeholder={AI_PROVIDER_BASE_URLS[aiProvider] || "https://your-api.example.com/v1"}
url
/>
)}
<Field
label="Model"
value={aiModel}
onChange={setAiModel}
placeholder={AI_PROVIDER_MODEL_PLACEHOLDERS[aiProvider]}
/>
<Field
label="API Key"
value={aiKey} value={aiKey}
onChangeText={setAiKey} onChange={setAiKey}
placeholder="sk-..." placeholder={aiProvider === "ollama" ? "no key required" : "paste your API key"}
placeholderTextColor={Colors.textDim} secure
secureTextEntry
autoCapitalize="none"
/> />
</> </>
)} )}
<Text style={SECTION_TITLE}>Gitea</Text> <Text style={SECTION_TITLE}>Git Repository</Text>
<Text style={LABEL}>Instance URL</Text>
<TextInput <SegmentRow
style={INPUT} label="Provider"
value={giteaUrl} value={gitProvider}
onChangeText={setGiteaUrl} onChange={(v) => setGitProvider(v as GitProvider)}
placeholder="https://homegit.gyozamancave.fr" options={(["gitea", "github", "gitlab"] as GitProvider[]).map((p) => ({
placeholderTextColor={Colors.textDim} key: p,
autoCapitalize="none" label: PROVIDER_LABELS[p],
keyboardType="url" }))}
/> />
<Text style={LABEL}>Token</Text>
<TextInput {gitProvider === "gitea" && (
style={INPUT} <>
<Field
label="Repo URL"
value={giteaRepoUrl}
onChange={setGiteaRepoUrl}
placeholder="https://homegit.example.com/username/incidents.git"
url
/>
<Field
label="Token (PAT, write:repository)"
value={giteaToken} value={giteaToken}
onChangeText={setGiteaToken} onChange={setGiteaToken}
placeholder="Bearer token" placeholder="paste token value — no Bearer prefix"
placeholderTextColor={Colors.textDim} secure
secureTextEntry
autoCapitalize="none"
/> />
<Text style={LABEL}>Owner</Text> <View
<TextInput style={{
style={INPUT} backgroundColor: Colors.surface,
value={giteaOwner} borderRadius: 8,
onChangeText={setGiteaOwner} padding: 10,
placeholder="username or org" marginTop: -8,
placeholderTextColor={Colors.textDim} marginBottom: 14,
autoCapitalize="none" borderLeftWidth: 3,
borderLeftColor: Colors.text2,
}}
>
<Text style={{ color: Colors.text2, fontSize: 12, lineHeight: 18 }}>
The repo must have at least one commit initialize it with a README if empty.
</Text>
</View>
</>
)}
{gitProvider === "github" && (
<>
<Field
label="Repo URL"
value={githubRepoUrl}
onChange={setGithubRepoUrl}
placeholder="https://github.com/username/incidents.git"
url
/> />
<Text style={LABEL}>Repository</Text> <Field
<TextInput label="Personal Access Token"
style={INPUT} value={githubToken}
value={giteaRepo} onChange={setGithubToken}
onChangeText={setGiteaRepo} placeholder="ghp_..."
placeholder="incidents" secure
placeholderTextColor={Colors.textDim}
autoCapitalize="none"
/> />
</>
)}
{gitProvider === "gitlab" && (
<>
<Field
label="Repo URL"
value={gitlabRepoUrl}
onChange={setGitlabRepoUrl}
placeholder="https://gitlab.com/username/incidents.git"
url
/>
<Field
label="Personal Access Token"
value={gitlabToken}
onChange={setGitlabToken}
placeholder="glpat-..."
secure
/>
</>
)}
<Text style={SECTION_TITLE}>Markdown Template</Text> <Text style={SECTION_TITLE}>Markdown Template</Text>
<TextInput <TextInput
@@ -294,7 +536,7 @@ export default function SettingsScreen() {
marginTop: 8, marginTop: 8,
}} }}
> >
<Text style={{ color: "#fff", fontSize: 16, fontWeight: "700" }}> <Text style={{ color: Colors.bg, fontSize: 16, fontWeight: "700" }}>
{saved ? "Saved ✓" : "Save Settings"} {saved ? "Saved ✓" : "Save Settings"}
</Text> </Text>
</Pressable> </Pressable>
Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Executable
+189
View File
@@ -0,0 +1,189 @@
#!/usr/bin/env bash
# build-apk.sh — Build a signed release APK locally without expo.dev / EAS.
#
# Prerequisites (first time only):
# 1. Generate a keystore:
# keytool -genkey -v -keystore ~/.config/sheethappens/sheethappens.jks \
# -alias sheethappens -keyalg RSA -keysize 2048 -validity 10000
# 2. Fill in signing credentials:
# mkdir -p ~/.config/sheethappens
# cat > ~/.config/sheethappens/signing.env <<EOF
# KEYSTORE_PATH=~/.config/sheethappens/sheethappens.jks
# KEYSTORE_ALIAS=sheethappens
# KEYSTORE_STORE_PASSWORD=your_store_password
# KEYSTORE_KEY_PASSWORD=your_key_password
# EOF
# chmod 600 ~/.config/sheethappens/signing.env
#
# Usage:
# ./build-apk.sh # → dist/sheethappens-YYYYMMDD-HHMM.apk
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SIGNING_ENV="$HOME/.config/sheethappens/signing.env"
DIST_DIR="$SCRIPT_DIR/dist"
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m'
info() { echo -e "${GREEN}[build]${NC} $*"; }
warn() { echo -e "${YELLOW}[warn]${NC} $*"; }
abort() { echo -e "${RED}[error]${NC} $*" >&2; exit 1; }
# ─── 1. Signing credentials ────────────────────────────────────────────────
if [ ! -f "$SIGNING_ENV" ]; then
abort "Missing signing.env — see comments at top of this script."
fi
# shellcheck source=/dev/null
source "$SIGNING_ENV"
[ -z "${KEYSTORE_PATH:-}" ] && abort "KEYSTORE_PATH not set in signing.env"
[ -z "${KEYSTORE_ALIAS:-}" ] && abort "KEYSTORE_ALIAS not set in signing.env"
[ -z "${KEYSTORE_STORE_PASSWORD:-}" ] && abort "KEYSTORE_STORE_PASSWORD not set in signing.env"
[ -z "${KEYSTORE_KEY_PASSWORD:-}" ] && abort "KEYSTORE_KEY_PASSWORD not set in signing.env"
KEYSTORE_PATH_EXPANDED="${KEYSTORE_PATH/#\$HOME/$HOME}"
KEYSTORE_PATH_EXPANDED="${KEYSTORE_PATH_EXPANDED/#~/$HOME}"
[ ! -f "$KEYSTORE_PATH_EXPANDED" ] && \
abort "Keystore not found: $KEYSTORE_PATH_EXPANDED"
# ─── 2. Java check ─────────────────────────────────────────────────────────
if [ -z "${JAVA_HOME:-}" ] || "$JAVA_HOME/bin/java" -version 2>&1 | grep -qE '"(2[5-9]|[3-9][0-9])\.'; then
if [ -d "$HOME/jdk21" ]; then
export JAVA_HOME="$HOME/jdk21"
export PATH="$JAVA_HOME/bin:$PATH"
info "Using local JDK 21: $JAVA_HOME"
else
abort "Java 25+ detected but Gradle only supports ≤ Java 24.\nInstall JDK 21 and set JAVA_HOME."
fi
fi
info "Java: $("$JAVA_HOME/bin/java" -version 2>&1 | head -1)"
# ─── 3. Android SDK ────────────────────────────────────────────────────────
if [ -z "${ANDROID_HOME:-}" ]; then
for candidate in "$HOME/android-sdk" "$HOME/Android/Sdk" "/opt/android-sdk"; do
if [ -d "$candidate/platform-tools" ]; then
export ANDROID_HOME="$candidate"
break
fi
done
fi
[ -z "${ANDROID_HOME:-}" ] && abort "Android SDK not found. Set ANDROID_HOME."
export PATH="$PATH:$ANDROID_HOME/platform-tools:$ANDROID_HOME/cmdline-tools/latest/bin"
info "Android SDK: $ANDROID_HOME"
# ─── 4. expo prebuild ──────────────────────────────────────────────────────
info "Running expo prebuild (Android)…"
cd "$SCRIPT_DIR"
npx expo prebuild --platform android --clean --no-install
# ─── 4.5. Patch native modules — 16KB page size (Android 15) ──────────────
info "Patching native modules for 16KB page size support (Android 15)…"
WORKLETS_CMAKE="$SCRIPT_DIR/node_modules/react-native-worklets/android/CMakeLists.txt"
REANIMATED_CMAKE="$SCRIPT_DIR/node_modules/react-native-reanimated/android/CMakeLists.txt"
if [ -f "$WORKLETS_CMAKE" ] && ! grep -q "max-page-size" "$WORKLETS_CMAKE"; then
printf '\ntarget_link_options(worklets PRIVATE "-Wl,-z,max-page-size=16384")\n' >> "$WORKLETS_CMAKE"
info "Patched react-native-worklets for 16KB pages"
fi
if [ -f "$REANIMATED_CMAKE" ] && ! grep -q "max-page-size" "$REANIMATED_CMAKE"; then
printf '\ntarget_link_options(reanimated PRIVATE "-Wl,-z,max-page-size=16384")\n' >> "$REANIMATED_CMAKE"
info "Patched react-native-reanimated for 16KB pages"
fi
# ─── 4.6. Disable New Architecture (Android 15 compat) ────────────────────
GRADLE_PROPS_NEWARCH="$SCRIPT_DIR/android/gradle.properties"
if [ -f "$GRADLE_PROPS_NEWARCH" ]; then
sed -i 's/^newArchEnabled=true/newArchEnabled=false/' "$GRADLE_PROPS_NEWARCH"
if ! grep -q "^newArchEnabled=" "$GRADLE_PROPS_NEWARCH"; then
echo "newArchEnabled=false" >> "$GRADLE_PROPS_NEWARCH"
fi
info "New Architecture disabled in gradle.properties"
fi
# ─── 5. Patch build.gradle — inject release signingConfig ──────────────────
info "Patching android/app/build.gradle for release signing…"
python3 - "$SCRIPT_DIR/android/app/build.gradle" << 'PYEOF'
import sys, re
path = sys.argv[1]
with open(path, 'r') as f:
content = f.read()
if 'MYAPP_UPLOAD_STORE_FILE' in content:
print('[patch] build.gradle already patched, skipping.')
sys.exit(0)
release_signing = """\n release {
if (project.hasProperty('MYAPP_UPLOAD_STORE_FILE')) {
storeFile file(MYAPP_UPLOAD_STORE_FILE)
storePassword MYAPP_UPLOAD_STORE_PASSWORD
keyAlias MYAPP_UPLOAD_KEY_ALIAS
keyPassword MYAPP_UPLOAD_KEY_PASSWORD
}
}"""
content = re.sub(
r'(signingConfigs\s*\{[\s\S]*?debug\s*\{[\s\S]*?\})',
r'\1' + release_signing,
content,
count=1
)
def replace_release_signing(m):
return m.group(0).replace('signingConfig signingConfigs.debug',
'signingConfig signingConfigs.release', 1)
content = re.sub(
r'release\s*\{[^}]*signingConfig\s+signingConfigs\.debug',
replace_release_signing,
content,
count=1,
flags=re.DOTALL
)
with open(path, 'w') as f:
f.write(content)
print('[patch] build.gradle patched for release signing.')
PYEOF
# ─── 6. Inject signing props into gradle.properties ────────────────────────
info "Injecting signing credentials into gradle.properties…"
GRADLE_PROPS="$SCRIPT_DIR/android/gradle.properties"
sed -i '/^# --- sheethappens release signing/,/^# --- end signing/d' "$GRADLE_PROPS" 2>/dev/null || true
cat >> "$GRADLE_PROPS" << EOF
# --- sheethappens release signing (injected by build-apk.sh — wiped after build)
MYAPP_UPLOAD_STORE_FILE=$KEYSTORE_PATH_EXPANDED
MYAPP_UPLOAD_STORE_PASSWORD=$KEYSTORE_STORE_PASSWORD
MYAPP_UPLOAD_KEY_ALIAS=$KEYSTORE_ALIAS
MYAPP_UPLOAD_KEY_PASSWORD=$KEYSTORE_KEY_PASSWORD
# --- end signing
EOF
# ─── 7. Gradle build ───────────────────────────────────────────────────────
cd "$SCRIPT_DIR/android"
chmod +x gradlew
info "Building APK (release)…"
./gradlew assembleRelease --no-daemon
# ─── 8. Wipe credentials from gradle.properties ───────────────────────────
sed -i '/^# --- sheethappens release signing/,/^# --- end signing/d' "$GRADLE_PROPS"
info "Signing credentials wiped from gradle.properties."
# ─── 9. Copy to dist/ ─────────────────────────────────────────────────────
mkdir -p "$DIST_DIR"
TIMESTAMP="$(date +%Y%m%d-%H%M)"
OUTPUT="$DIST_DIR/sheethappens-$TIMESTAMP.apk"
cp "app/build/outputs/apk/release/app-release.apk" "$OUTPUT"
echo ""
info "Build complete!"
echo -e " ${GREEN}$OUTPUT${NC}"
echo ""
+12 -12
View File
@@ -1,16 +1,16 @@
export const Colors = { export const Colors = {
bg: "#0f1117", bg: "#1B2433",
surface: "#1a1d27", surface: "#222D3D",
surface2: "#252836", surface2: "#293648",
primary: "#6366f1", primary: "#88D656",
primaryDim: "#4338ca", primaryDim: "#5FA33A",
success: "#22c55e", success: "#88D656",
warning: "#f59e0b", warning: "#F5A623",
danger: "#ef4444", danger: "#EF4444",
text1: "#f1f5f9", text1: "#EEF2F5",
text2: "#94a3b8", text2: "#8FA5BF",
textDim: "#64748b", textDim: "#4E6480",
border: "#2d3147", border: "#2E3F55",
} as const; } as const;
export const FontFamily = { export const FontFamily = {
+128
View File
@@ -0,0 +1,128 @@
import * as SecureStore from "expo-secure-store";
export type AIProvider =
| "anthropic"
| "openai"
| "perplexity"
| "ollama"
| "openrouter"
| "custom";
const KEYS = {
AI_PROVIDER: "pref_ai_provider",
AI_BASE_URL: "pref_ai_base_url",
AI_MODEL: "pref_ai_model",
AI_KEY: "pref_ai_key",
};
export const AI_PROVIDER_BASE_URLS: Record<AIProvider, string> = {
anthropic: "https://api.anthropic.com",
openai: "https://api.openai.com/v1",
perplexity: "https://api.perplexity.ai",
ollama: "http://localhost:11434/v1",
openrouter: "https://openrouter.ai/api/v1",
custom: "",
};
export const AI_PROVIDER_MODEL_PLACEHOLDERS: Record<AIProvider, string> = {
anthropic: "claude-haiku-4-5-20251001",
openai: "gpt-4o-mini",
perplexity: "llama-3.1-sonar-small-128k-online",
ollama: "llama3.2",
openrouter: "openai/gpt-4o-mini",
custom: "model-name",
};
const SYSTEM_PROMPT =
"You are a systems reliability engineer. Analyze the incident and respond with JSON ONLY " +
"(no prose, no markdown fences):\n" +
'{"root_cause":"...","fix":"..."}\n' +
"Be concise and technical. Max 200 chars per field.";
async function getConfig() {
const [provider, baseUrl, model, key] = await Promise.all([
SecureStore.getItemAsync(KEYS.AI_PROVIDER),
SecureStore.getItemAsync(KEYS.AI_BASE_URL),
SecureStore.getItemAsync(KEYS.AI_MODEL),
SecureStore.getItemAsync(KEYS.AI_KEY),
]);
const p = (provider ?? "openai") as AIProvider;
return {
provider: p,
baseUrl: baseUrl || AI_PROVIDER_BASE_URLS[p],
model: model || AI_PROVIDER_MODEL_PLACEHOLDERS[p],
key: key ?? "",
};
}
export async function callAI(
title: string,
symptom: string
): Promise<{ rootCause: string; fix: string }> {
const config = await getConfig();
const userMsg = `Title: ${title}\nSymptom: ${symptom}`;
const raw =
config.provider === "anthropic"
? await callAnthropic(config, userMsg)
: await callOpenAICompat(config, userMsg);
try {
const parsed = JSON.parse(raw) as { root_cause?: string; fix?: string };
return {
rootCause: parsed.root_cause ?? "",
fix: parsed.fix ?? "",
};
} catch {
throw new Error(`Unexpected AI response: ${raw.slice(0, 120)}`);
}
}
async function callAnthropic(
config: { baseUrl: string; model: string; key: string },
userMsg: string
): Promise<string> {
const res = await fetch(`${config.baseUrl}/v1/messages`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": config.key,
"anthropic-version": "2023-06-01",
},
body: JSON.stringify({
model: config.model,
max_tokens: 512,
system: SYSTEM_PROMPT,
messages: [{ role: "user", content: userMsg }],
}),
});
if (!res.ok) throw new Error(`Anthropic ${res.status}: ${await res.text()}`);
const data = (await res.json()) as { content: { text: string }[] };
return data.content[0].text;
}
async function callOpenAICompat(
config: { baseUrl: string; model: string; key: string },
userMsg: string
): Promise<string> {
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (config.key) headers["Authorization"] = `Bearer ${config.key}`;
const res = await fetch(`${config.baseUrl}/chat/completions`, {
method: "POST",
headers,
body: JSON.stringify({
model: config.model,
max_tokens: 512,
messages: [
{ role: "system", content: SYSTEM_PROMPT },
{ role: "user", content: userMsg },
],
}),
});
if (!res.ok) throw new Error(`AI ${res.status}: ${await res.text()}`);
const data = (await res.json()) as {
choices: { message: { content: string } }[];
};
return data.choices[0].message.content;
}
+14
View File
@@ -163,6 +163,20 @@ export async function deleteIncident(id: string): Promise<void> {
await db.runAsync("DELETE FROM incidents WHERE id = ?", [id]); await db.runAsync("DELETE FROM incidents WHERE id = ?", [id]);
} }
export async function getMaxTitleNumber(prefix: string): Promise<number> {
const db = await getDb();
const rows = await db.getAllAsync<{ title: string }>(
"SELECT title FROM incidents WHERE title LIKE ?",
[`${prefix}%`]
);
let max = 0;
for (const row of rows) {
const n = parseInt(row.title.slice(prefix.length), 10);
if (!isNaN(n) && n > max) max = n;
}
return max;
}
export async function getDistinctServices(): Promise<string[]> { export async function getDistinctServices(): Promise<string[]> {
const db = await getDb(); const db = await getDb();
const rows = await db.getAllAsync<{ service: string }>( const rows = await db.getAllAsync<{ service: string }>(
+235
View File
@@ -0,0 +1,235 @@
import { incidentToFilename, renderMarkdown } from "./markdown";
import type { Incident } from "@/types/incident";
export type GitProvider = "gitea" | "github" | "gitlab";
export interface GitConfig {
provider: GitProvider;
url?: string; // required for gitea and self-hosted gitlab
token: string;
owner: string;
repo: string;
}
export interface PushResult {
success: boolean;
url?: string;
error?: string;
}
const TIMEOUT_MS = 15_000;
function fetchWithTimeout(url: string, init: RequestInit): Promise<Response> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
return fetch(url, { ...init, signal: controller.signal }).finally(() =>
clearTimeout(timer)
);
}
export async function pushIncidentToGit(
incident: Incident,
config: GitConfig,
markdownTemplate?: string
): Promise<PushResult> {
switch (config.provider) {
case "gitea":
return pushGitea(incident, config, markdownTemplate);
case "github":
return pushGitHub(incident, config, markdownTemplate);
case "gitlab":
return pushGitLab(incident, config, markdownTemplate);
}
}
async function pushGitea(
incident: Incident,
config: GitConfig,
markdownTemplate?: string
): Promise<PushResult> {
if (!config.url) {
return { success: false, error: "Gitea instance URL is not set. Check Settings → Git Repository." };
}
if (!config.token) {
return { success: false, error: "Gitea token is not set. Check Settings → Git Repository." };
}
if (!config.owner || !config.repo) {
return { success: false, error: "Gitea owner or repository is not set. Check Settings → Git Repository." };
}
const filepath = incidentToFilename(incident);
const content = renderMarkdown(incident, markdownTemplate);
const base64Content = btoa(unescape(encodeURIComponent(content)));
const apiBase = config.url.replace(/\/$/, "");
const endpoint = `${apiBase}/api/v1/repos/${config.owner}/${config.repo}/contents/${filepath}`;
try {
const existing = await fetchWithTimeout(endpoint, {
headers: { Authorization: `token ${config.token}` },
});
let sha: string | undefined;
if (existing.ok) {
const data = await existing.json() as { sha?: string };
sha = data.sha;
} else if (existing.status === 404 || existing.status === 409) {
// 404 = file not found (normal for first push)
// 409 = repo is empty / no branch yet — proceed, we'll create with branch:"main"
} else {
const body = await existing.text();
return { success: false, error: `GET ${existing.status}: ${body.slice(0, 200)}` };
}
const payload: Record<string, string> = {
message: `incident: ${incident.title}`,
content: base64Content,
branch: "main",
};
if (sha) payload.sha = sha;
// POST creates, PUT updates — Gitea requires the correct verb
const res = await fetchWithTimeout(endpoint, {
method: sha ? "PUT" : "POST",
headers: {
Authorization: `token ${config.token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
if (!res.ok) {
const body = await res.text();
let hint = "";
if (res.status === 404) {
hint = "\n\nCheck: instance URL (no /api/v1), owner and repo names are exact, token has write:repository scope, and the repo exists on Gitea.";
}
return { success: false, error: `HTTP ${res.status}: ${body.slice(0, 300)}${hint}` };
}
const data = await res.json() as { content?: { html_url?: string } };
return { success: true, url: data.content?.html_url };
} catch (e) {
if (e instanceof Error && e.name === "AbortError") {
return { success: false, error: `Request timed out after ${TIMEOUT_MS / 1000}s. Check that the Gitea URL is reachable.` };
}
return { success: false, error: e instanceof Error ? e.message : String(e) };
}
}
async function pushGitHub(
incident: Incident,
config: GitConfig,
markdownTemplate?: string
): Promise<PushResult> {
if (!config.token) {
return { success: false, error: "GitHub token is not set. Check Settings → Git Repository." };
}
if (!config.owner || !config.repo) {
return { success: false, error: "GitHub owner or repository is not set. Check Settings → Git Repository." };
}
const filepath = incidentToFilename(incident);
const content = renderMarkdown(incident, markdownTemplate);
const base64Content = btoa(unescape(encodeURIComponent(content)));
const endpoint = `https://api.github.com/repos/${config.owner}/${config.repo}/contents/${filepath}`;
try {
const existing = await fetchWithTimeout(endpoint, {
headers: {
Authorization: `Bearer ${config.token}`,
Accept: "application/vnd.github+json",
},
});
let sha: string | undefined;
if (existing.ok) {
const data = await existing.json() as { sha?: string };
sha = data.sha;
} else if (existing.status !== 404) {
const body = await existing.text();
return { success: false, error: `GET ${existing.status}: ${body.slice(0, 200)}` };
}
const payload: Record<string, string> = {
message: `incident: ${incident.title}`,
content: base64Content,
};
if (sha) payload.sha = sha;
const res = await fetchWithTimeout(endpoint, {
method: "PUT",
headers: {
Authorization: `Bearer ${config.token}`,
Accept: "application/vnd.github+json",
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
if (!res.ok) {
const body = await res.text();
return { success: false, error: `HTTP ${res.status}: ${body.slice(0, 300)}` };
}
const data = await res.json() as { content?: { html_url?: string } };
return { success: true, url: data.content?.html_url };
} catch (e) {
if (e instanceof Error && e.name === "AbortError") {
return { success: false, error: `Request timed out after ${TIMEOUT_MS / 1000}s.` };
}
return { success: false, error: e instanceof Error ? e.message : String(e) };
}
}
async function pushGitLab(
incident: Incident,
config: GitConfig,
markdownTemplate?: string
): Promise<PushResult> {
if (!config.token) {
return { success: false, error: "GitLab token is not set. Check Settings → Git Repository." };
}
if (!config.owner || !config.repo) {
return { success: false, error: "GitLab namespace or repository is not set. Check Settings → Git Repository." };
}
const filepath = incidentToFilename(incident);
const content = renderMarkdown(incident, markdownTemplate);
const base64Content = btoa(unescape(encodeURIComponent(content)));
const apiBase = (config.url ?? "https://gitlab.com").replace(/\/$/, "");
const projectId = encodeURIComponent(`${config.owner}/${config.repo}`);
const encodedPath = encodeURIComponent(filepath);
const endpoint = `${apiBase}/api/v4/projects/${projectId}/repository/files/${encodedPath}`;
try {
const existing = await fetchWithTimeout(`${endpoint}?ref=HEAD`, {
headers: { "PRIVATE-TOKEN": config.token },
});
const method = existing.ok ? "PUT" : "POST";
const res = await fetchWithTimeout(endpoint, {
method,
headers: {
"PRIVATE-TOKEN": config.token,
"Content-Type": "application/json",
},
body: JSON.stringify({
branch: "main",
content: base64Content,
encoding: "base64",
commit_message: `incident: ${incident.title}`,
}),
});
if (!res.ok) {
const body = await res.text();
return { success: false, error: `HTTP ${res.status}: ${body.slice(0, 300)}` };
}
const data = await res.json() as { file_path?: string };
const webUrl = data.file_path
? `${apiBase}/${config.owner}/${config.repo}/-/blob/main/${data.file_path}`
: undefined;
return { success: true, url: webUrl };
} catch (e) {
if (e instanceof Error && e.name === "AbortError") {
return { success: false, error: `Request timed out after ${TIMEOUT_MS / 1000}s.` };
}
return { success: false, error: e instanceof Error ? e.message : String(e) };
}
}
+520 -212
View File
@@ -19,8 +19,8 @@
"expo-sqlite": "~56.0.5", "expo-sqlite": "~56.0.5",
"expo-status-bar": "~56.0.4", "expo-status-bar": "~56.0.4",
"nativewind": "~4.2.5", "nativewind": "~4.2.5",
"react": "19.2.7", "react": "19.2.3",
"react-native": "^0.85.3", "react-native": "0.85.3",
"react-native-gesture-handler": "~3.0.2", "react-native-gesture-handler": "~3.0.2",
"react-native-reanimated": "~4.4.1", "react-native-reanimated": "~4.4.1",
"react-native-safe-area-context": "~5.8.0", "react-native-safe-area-context": "~5.8.0",
@@ -535,6 +535,22 @@
"@babel/core": "^7.0.0-0" "@babel/core": "^7.0.0-0"
} }
}, },
"node_modules/@babel/plugin-transform-arrow-functions": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.29.7.tgz",
"integrity": "sha512-N7zArUXWzAMzm+/N0uPBeVB3Fam5lMxtUwMmDK5f/IBBS7a7p1qeUoxd/6CckXoxUdgsntq1Dh8xNW06maZbDQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/helper-plugin-utils": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/plugin-transform-async-generator-functions": { "node_modules/@babel/plugin-transform-async-generator-functions": {
"version": "7.29.7", "version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.29.7.tgz", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.29.7.tgz",
@@ -908,6 +924,38 @@
"@babel/core": "^7.0.0-0" "@babel/core": "^7.0.0-0"
} }
}, },
"node_modules/@babel/plugin-transform-react-jsx-self": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz",
"integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/helper-plugin-utils": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/plugin-transform-react-jsx-source": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz",
"integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/helper-plugin-utils": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/plugin-transform-react-pure-annotations": { "node_modules/@babel/plugin-transform-react-pure-annotations": {
"version": "7.29.7", "version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-pure-annotations/-/plugin-transform-react-pure-annotations-7.29.7.tgz", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-pure-annotations/-/plugin-transform-react-pure-annotations-7.29.7.tgz",
@@ -924,6 +972,22 @@
"@babel/core": "^7.0.0-0" "@babel/core": "^7.0.0-0"
} }
}, },
"node_modules/@babel/plugin-transform-regenerator": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.29.7.tgz",
"integrity": "sha512-rNNFV0DBAJp988xW2DOntfDoYn1eR8GGF5AT5vYc+rjyfaQkM242c9tZUHHPe7KYaiJizXPWhQTzzdbXySyhBw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/helper-plugin-utils": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/plugin-transform-runtime": { "node_modules/@babel/plugin-transform-runtime": {
"version": "7.29.7", "version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.29.7.tgz", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.29.7.tgz",
@@ -944,6 +1008,38 @@
"@babel/core": "^7.0.0-0" "@babel/core": "^7.0.0-0"
} }
}, },
"node_modules/@babel/plugin-transform-shorthand-properties": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.29.7.tgz",
"integrity": "sha512-I+WYbGBAiCn7nA6xBrlgPH+MB7HWb4u8pv5S0Pv7OtwNvIFvCCb24YlttKEeUFVurfBCEaOTnuhlqsb7f0Z5Dg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/helper-plugin-utils": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/plugin-transform-template-literals": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.29.7.tgz",
"integrity": "sha512-NCSEJ4sLFU2gqAub45HYh4fus2yQ36rr6ei6vpU7NdoJqCpxvEG8E6eJpscGyXP3VHD2Ny+fSXr04k1hoUrFqA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/helper-plugin-utils": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/plugin-transform-typescript": { "node_modules/@babel/plugin-transform-typescript": {
"version": "7.29.7", "version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.29.7.tgz", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.29.7.tgz",
@@ -1058,99 +1154,6 @@
"integrity": "sha512-IJkBtN1o8u9BW5fvSii1MyHPQ7Q0HxbWcVBvOrOzgMLpVtZw7R2w94wBTVR7kZwv3w1JNTESMmLA5Sqn1+Z36A==", "integrity": "sha512-IJkBtN1o8u9BW5fvSii1MyHPQ7Q0HxbWcVBvOrOzgMLpVtZw7R2w94wBTVR7kZwv3w1JNTESMmLA5Sqn1+Z36A==",
"license": "MIT AND Apache-2.0" "license": "MIT AND Apache-2.0"
}, },
"node_modules/@expo/cli": {
"version": "56.1.16",
"resolved": "https://registry.npmjs.org/@expo/cli/-/cli-56.1.16.tgz",
"integrity": "sha512-VBQn0mqAwc67b9Cn0RVXyeodghomAx5xGRhA/bXaQzuxDjMQk0zIOb6pXMZX7yiIwJW66UZt/zQiJNSv6aWJYw==",
"license": "MIT",
"dependencies": {
"@expo/code-signing-certificates": "^0.0.6",
"@expo/config": "~56.0.9",
"@expo/config-plugins": "~56.0.9",
"@expo/devcert": "^1.2.1",
"@expo/env": "~2.3.0",
"@expo/image-utils": "^0.10.1",
"@expo/inline-modules": "^0.0.12",
"@expo/json-file": "^10.2.0",
"@expo/log-box": "^56.0.13",
"@expo/metro": "~56.0.0",
"@expo/metro-config": "~56.0.14",
"@expo/metro-file-map": "^56.0.3",
"@expo/osascript": "^2.6.0",
"@expo/package-manager": "^1.12.1",
"@expo/plist": "^0.7.0",
"@expo/prebuild-config": "^56.0.16",
"@expo/require-utils": "^56.1.3",
"@expo/router-server": "^56.0.14",
"@expo/schema-utils": "^56.0.0",
"@expo/spawn-async": "^1.8.0",
"@expo/ws-tunnel": "^2.0.0",
"@expo/xcpretty": "^4.4.4",
"@react-native/dev-middleware": "0.85.3",
"accepts": "^1.3.8",
"arg": "^5.0.2",
"bplist-creator": "0.1.0",
"bplist-parser": "^0.3.1",
"chalk": "^4.0.0",
"ci-info": "^3.3.0",
"compression": "^1.7.4",
"connect": "^3.7.0",
"debug": "^4.3.4",
"dnssd-advertise": "^1.1.4",
"expo-server": "^56.0.5",
"fetch-nodeshim": "^0.4.10",
"getenv": "^2.0.0",
"glob": "^13.0.0",
"lan-network": "^0.2.1",
"multitars": "^1.0.0",
"node-forge": "^1.3.3",
"npm-package-arg": "^11.0.0",
"ora": "^3.4.0",
"picomatch": "^4.0.4",
"pretty-format": "^29.7.0",
"progress": "^2.0.3",
"prompts": "^2.3.2",
"resolve-from": "^5.0.0",
"semver": "^7.6.0",
"send": "^0.19.0",
"slugify": "^1.3.4",
"stacktrace-parser": "^0.1.10",
"structured-headers": "^0.4.1",
"terminal-link": "^2.1.1",
"toqr": "^0.1.1",
"wrap-ansi": "^7.0.0",
"ws": "^8.12.1",
"zod": "^3.25.76"
},
"bin": {
"expo-internal": "main.js"
},
"peerDependencies": {
"expo": "*",
"expo-router": "*",
"react-native": "*"
},
"peerDependenciesMeta": {
"expo-router": {
"optional": true
},
"react-native": {
"optional": true
}
}
},
"node_modules/@expo/cli/node_modules/semver": {
"version": "7.8.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz",
"integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/@expo/code-signing-certificates": { "node_modules/@expo/code-signing-certificates": {
"version": "0.0.6", "version": "0.0.6",
"resolved": "https://registry.npmjs.org/@expo/code-signing-certificates/-/code-signing-certificates-0.0.6.tgz", "resolved": "https://registry.npmjs.org/@expo/code-signing-certificates/-/code-signing-certificates-0.0.6.tgz",
@@ -1563,9 +1566,9 @@
} }
}, },
"node_modules/@expo/prebuild-config/node_modules/semver": { "node_modules/@expo/prebuild-config/node_modules/semver": {
"version": "7.8.4", "version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"license": "ISC", "license": "ISC",
"bin": { "bin": {
"semver": "bin/semver.js" "semver": "bin/semver.js"
@@ -1593,40 +1596,6 @@
} }
} }
}, },
"node_modules/@expo/router-server": {
"version": "56.0.14",
"resolved": "https://registry.npmjs.org/@expo/router-server/-/router-server-56.0.14.tgz",
"integrity": "sha512-2UCTtZfcq1ZPgp3wk8/+sq9DvFI9UxrPr1jcEKMAF2DGAJLosnpc8GWNNg2hkjt6SHUOdFHIPxujWPYyho2y3A==",
"license": "MIT",
"dependencies": {
"debug": "^4.3.4"
},
"peerDependencies": {
"@expo/metro-runtime": "^56.0.15",
"expo": "*",
"expo-constants": "^56.0.18",
"expo-font": "^56.0.6",
"expo-router": "*",
"expo-server": "^56.0.5",
"react": "*",
"react-dom": "*",
"react-server-dom-webpack": "~19.0.1 || ~19.1.2 || ~19.2.1"
},
"peerDependenciesMeta": {
"@expo/metro-runtime": {
"optional": true
},
"expo-router": {
"optional": true
},
"react-dom": {
"optional": true
},
"react-server-dom-webpack": {
"optional": true
}
}
},
"node_modules/@expo/schema-utils": { "node_modules/@expo/schema-utils": {
"version": "56.0.1", "version": "56.0.1",
"resolved": "https://registry.npmjs.org/@expo/schema-utils/-/schema-utils-56.0.1.tgz", "resolved": "https://registry.npmjs.org/@expo/schema-utils/-/schema-utils-56.0.1.tgz",
@@ -2292,6 +2261,88 @@
"node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
} }
}, },
"node_modules/@react-native/babel-plugin-codegen": {
"version": "0.85.3",
"resolved": "https://registry.npmjs.org/@react-native/babel-plugin-codegen/-/babel-plugin-codegen-0.85.3.tgz",
"integrity": "sha512-Wc94zGfeFG8Njf9SHMPfYZP04kjigkOps6F1TYTvd7ZVXuGxqseCDgxc50LWcOhOCLypI9n3oVVqz81C3p44ZA==",
"license": "MIT",
"dependencies": {
"@babel/traverse": "^7.29.0",
"@react-native/codegen": "0.85.3"
},
"engines": {
"node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
}
},
"node_modules/@react-native/babel-preset": {
"version": "0.85.3",
"resolved": "https://registry.npmjs.org/@react-native/babel-preset/-/babel-preset-0.85.3.tgz",
"integrity": "sha512-fD7fxEhkJB/aF57tWoXjaAWpklfrExYZS3k6aXPP3BQ77DZY7gvf/b7dbirwjID6NVnP1JDRJyTuPBGr0K/vlw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/core": "^7.25.2",
"@babel/plugin-proposal-export-default-from": "^7.24.7",
"@babel/plugin-syntax-dynamic-import": "^7.8.3",
"@babel/plugin-syntax-export-default-from": "^7.24.7",
"@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3",
"@babel/plugin-syntax-optional-chaining": "^7.8.3",
"@babel/plugin-transform-async-generator-functions": "^7.25.4",
"@babel/plugin-transform-async-to-generator": "^7.24.7",
"@babel/plugin-transform-block-scoping": "^7.25.0",
"@babel/plugin-transform-class-properties": "^7.25.4",
"@babel/plugin-transform-classes": "^7.25.4",
"@babel/plugin-transform-destructuring": "^7.24.8",
"@babel/plugin-transform-flow-strip-types": "^7.25.2",
"@babel/plugin-transform-for-of": "^7.24.7",
"@babel/plugin-transform-modules-commonjs": "^7.24.8",
"@babel/plugin-transform-named-capturing-groups-regex": "^7.24.7",
"@babel/plugin-transform-nullish-coalescing-operator": "^7.24.7",
"@babel/plugin-transform-optional-catch-binding": "^7.24.7",
"@babel/plugin-transform-optional-chaining": "^7.24.8",
"@babel/plugin-transform-private-methods": "^7.24.7",
"@babel/plugin-transform-private-property-in-object": "^7.24.7",
"@babel/plugin-transform-react-display-name": "^7.24.7",
"@babel/plugin-transform-react-jsx": "^7.25.2",
"@babel/plugin-transform-react-jsx-self": "^7.24.7",
"@babel/plugin-transform-react-jsx-source": "^7.24.7",
"@babel/plugin-transform-regenerator": "^7.24.7",
"@babel/plugin-transform-runtime": "^7.24.7",
"@babel/plugin-transform-typescript": "^7.25.2",
"@babel/plugin-transform-unicode-regex": "^7.24.7",
"@react-native/babel-plugin-codegen": "0.85.3",
"babel-plugin-syntax-hermes-parser": "0.33.3",
"babel-plugin-transform-flow-enums": "^0.0.2",
"react-refresh": "^0.14.0"
},
"engines": {
"node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
},
"peerDependencies": {
"@babel/core": "*"
}
},
"node_modules/@react-native/codegen": {
"version": "0.85.3",
"resolved": "https://registry.npmjs.org/@react-native/codegen/-/codegen-0.85.3.tgz",
"integrity": "sha512-/JkS1lGLyzBWP1FbgDwaqEf7qShIC6pUC1M0a/YMAd/v4iqR24MRkQWe7jkYvcBQ2LpEhs5NGE9InhxSv21zCA==",
"license": "MIT",
"dependencies": {
"@babel/core": "^7.25.2",
"@babel/parser": "^7.29.0",
"hermes-parser": "0.33.3",
"invariant": "^2.2.4",
"nullthrows": "^1.1.1",
"tinyglobby": "^0.2.15",
"yargs": "^17.6.2"
},
"engines": {
"node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
},
"peerDependencies": {
"@babel/core": "*"
}
},
"node_modules/@react-native/community-cli-plugin": { "node_modules/@react-native/community-cli-plugin": {
"version": "0.85.3", "version": "0.85.3",
"resolved": "https://registry.npmjs.org/@react-native/community-cli-plugin/-/community-cli-plugin-0.85.3.tgz", "resolved": "https://registry.npmjs.org/@react-native/community-cli-plugin/-/community-cli-plugin-0.85.3.tgz",
@@ -2410,6 +2461,50 @@
"node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
} }
}, },
"node_modules/@react-native/js-polyfills": {
"version": "0.85.3",
"resolved": "https://registry.npmjs.org/@react-native/js-polyfills/-/js-polyfills-0.85.3.tgz",
"integrity": "sha512-U2+aMshIXf1uFn77tpBb/xhHWB9vkVrMpt7kkucAugF8hJKYTDGB587X7WwelHduK2KBfhl4giSv0rzZGoef9A==",
"license": "MIT",
"engines": {
"node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
}
},
"node_modules/@react-native/metro-babel-transformer": {
"version": "0.85.3",
"resolved": "https://registry.npmjs.org/@react-native/metro-babel-transformer/-/metro-babel-transformer-0.85.3.tgz",
"integrity": "sha512-omuKq+r7jM4XvCMIlNMPP7Up3SyB8o5EAdZtF7YXniKyq7UOMBqhYHFqgsdOXr0lT+3ADf7VCJG3sb82jlBrrQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/core": "^7.25.2",
"@react-native/babel-preset": "0.85.3",
"hermes-parser": "0.33.3",
"nullthrows": "^1.1.1"
},
"engines": {
"node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
},
"peerDependencies": {
"@babel/core": "*"
}
},
"node_modules/@react-native/metro-config": {
"version": "0.85.3",
"resolved": "https://registry.npmjs.org/@react-native/metro-config/-/metro-config-0.85.3.tgz",
"integrity": "sha512-sVo6HepUmCcpdfozEf91lA0FjpLNNZYu/Zi9FiYiAQTK8pzATXDVTqhvdxpFrQn435p5eUTSbllvbH/KN+bnyA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@react-native/js-polyfills": "0.85.3",
"@react-native/metro-babel-transformer": "0.85.3",
"metro-config": "^0.84.3",
"metro-runtime": "^0.84.3"
},
"engines": {
"node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
}
},
"node_modules/@react-native/normalize-colors": { "node_modules/@react-native/normalize-colors": {
"version": "0.85.3", "version": "0.85.3",
"resolved": "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.85.3.tgz", "resolved": "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.85.3.tgz",
@@ -2445,6 +2540,61 @@
"integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@testing-library/dom": {
"version": "10.4.1",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.10.4",
"@babel/runtime": "^7.12.5",
"@types/aria-query": "^5.0.1",
"aria-query": "5.3.0",
"dom-accessibility-api": "^0.5.9",
"lz-string": "^1.5.0",
"picocolors": "1.1.1",
"pretty-format": "^27.0.2"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@testing-library/dom/node_modules/ansi-styles": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/@testing-library/dom/node_modules/pretty-format": {
"version": "27.5.1",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"ansi-regex": "^5.0.1",
"ansi-styles": "^5.0.0",
"react-is": "^17.0.1"
},
"engines": {
"node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
}
},
"node_modules/@testing-library/dom/node_modules/react-is": {
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"license": "MIT",
"peer": true
},
"node_modules/@testing-library/jest-dom": { "node_modules/@testing-library/jest-dom": {
"version": "6.9.1", "version": "6.9.1",
"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz",
@@ -2483,6 +2633,13 @@
"@testing-library/dom": ">=7.21.4" "@testing-library/dom": ">=7.21.4"
} }
}, },
"node_modules/@types/aria-query": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
"license": "MIT",
"peer": true
},
"node_modules/@types/istanbul-lib-coverage": { "node_modules/@types/istanbul-lib-coverage": {
"version": "2.0.6", "version": "2.0.6",
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
@@ -2803,6 +2960,15 @@
"integrity": "sha512-SPD0J6qjJn8231i0HZhlAGH6NORe+QvRSQM2mwQEzJ2Fb3E4ruWTiiicPlHjmeWShDXLcvoorOCXjeR7k/lyWA==", "integrity": "sha512-SPD0J6qjJn8231i0HZhlAGH6NORe+QvRSQM2mwQEzJ2Fb3E4ruWTiiicPlHjmeWShDXLcvoorOCXjeR7k/lyWA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/babel-plugin-syntax-hermes-parser": {
"version": "0.33.3",
"resolved": "https://registry.npmjs.org/babel-plugin-syntax-hermes-parser/-/babel-plugin-syntax-hermes-parser-0.33.3.tgz",
"integrity": "sha512-/Z9xYdaJ1lC0pT9do6TqCqhOSLfZ5Ot8D5za1p+feEfWYupCOfGbhhEXN9r2ZgJtDNUNRw/Z+T2CvAGKBqtqWA==",
"license": "MIT",
"dependencies": {
"hermes-parser": "0.33.3"
}
},
"node_modules/babel-plugin-transform-flow-enums": { "node_modules/babel-plugin-transform-flow-enums": {
"version": "0.0.2", "version": "0.0.2",
"resolved": "https://registry.npmjs.org/babel-plugin-transform-flow-enums/-/babel-plugin-transform-flow-enums-0.0.2.tgz", "resolved": "https://registry.npmjs.org/babel-plugin-transform-flow-enums/-/babel-plugin-transform-flow-enums-0.0.2.tgz",
@@ -2879,49 +3045,6 @@
} }
} }
}, },
"node_modules/babel-preset-expo/node_modules/@react-native/babel-plugin-codegen": {
"version": "0.85.3",
"resolved": "https://registry.npmjs.org/@react-native/babel-plugin-codegen/-/babel-plugin-codegen-0.85.3.tgz",
"integrity": "sha512-Wc94zGfeFG8Njf9SHMPfYZP04kjigkOps6F1TYTvd7ZVXuGxqseCDgxc50LWcOhOCLypI9n3oVVqz81C3p44ZA==",
"license": "MIT",
"dependencies": {
"@babel/traverse": "^7.29.0",
"@react-native/codegen": "0.85.3"
},
"engines": {
"node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
}
},
"node_modules/babel-preset-expo/node_modules/@react-native/codegen": {
"version": "0.85.3",
"resolved": "https://registry.npmjs.org/@react-native/codegen/-/codegen-0.85.3.tgz",
"integrity": "sha512-/JkS1lGLyzBWP1FbgDwaqEf7qShIC6pUC1M0a/YMAd/v4iqR24MRkQWe7jkYvcBQ2LpEhs5NGE9InhxSv21zCA==",
"license": "MIT",
"dependencies": {
"@babel/core": "^7.25.2",
"@babel/parser": "^7.29.0",
"hermes-parser": "0.33.3",
"invariant": "^2.2.4",
"nullthrows": "^1.1.1",
"tinyglobby": "^0.2.15",
"yargs": "^17.6.2"
},
"engines": {
"node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
},
"peerDependencies": {
"@babel/core": "*"
}
},
"node_modules/babel-preset-expo/node_modules/babel-plugin-syntax-hermes-parser": {
"version": "0.33.3",
"resolved": "https://registry.npmjs.org/babel-plugin-syntax-hermes-parser/-/babel-plugin-syntax-hermes-parser-0.33.3.tgz",
"integrity": "sha512-/Z9xYdaJ1lC0pT9do6TqCqhOSLfZ5Ot8D5za1p+feEfWYupCOfGbhhEXN9r2ZgJtDNUNRw/Z+T2CvAGKBqtqWA==",
"license": "MIT",
"dependencies": {
"hermes-parser": "0.33.3"
}
},
"node_modules/balanced-match": { "node_modules/balanced-match": {
"version": "4.0.4", "version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
@@ -3602,6 +3725,13 @@
"integrity": "sha512-Ndrrf6BMPalkQPd/zubL+4YghH2J9NspapQ09uDXwYbvOPkP0oaqf5CkcwJ0b50kS2O3ul6yVu+jz+RY62Cejg==", "integrity": "sha512-Ndrrf6BMPalkQPd/zubL+4YghH2J9NspapQ09uDXwYbvOPkP0oaqf5CkcwJ0b50kS2O3ul6yVu+jz+RY62Cejg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/dom-accessibility-api": {
"version": "0.5.16",
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
"license": "MIT",
"peer": true
},
"node_modules/ee-first": { "node_modules/ee-first": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@@ -4049,6 +4179,121 @@
"react-native": "*" "react-native": "*"
} }
}, },
"node_modules/expo/node_modules/@expo/cli": {
"version": "56.1.16",
"resolved": "https://registry.npmjs.org/@expo/cli/-/cli-56.1.16.tgz",
"integrity": "sha512-VBQn0mqAwc67b9Cn0RVXyeodghomAx5xGRhA/bXaQzuxDjMQk0zIOb6pXMZX7yiIwJW66UZt/zQiJNSv6aWJYw==",
"license": "MIT",
"dependencies": {
"@expo/code-signing-certificates": "^0.0.6",
"@expo/config": "~56.0.9",
"@expo/config-plugins": "~56.0.9",
"@expo/devcert": "^1.2.1",
"@expo/env": "~2.3.0",
"@expo/image-utils": "^0.10.1",
"@expo/inline-modules": "^0.0.12",
"@expo/json-file": "^10.2.0",
"@expo/log-box": "^56.0.13",
"@expo/metro": "~56.0.0",
"@expo/metro-config": "~56.0.14",
"@expo/metro-file-map": "^56.0.3",
"@expo/osascript": "^2.6.0",
"@expo/package-manager": "^1.12.1",
"@expo/plist": "^0.7.0",
"@expo/prebuild-config": "^56.0.16",
"@expo/require-utils": "^56.1.3",
"@expo/router-server": "^56.0.14",
"@expo/schema-utils": "^56.0.0",
"@expo/spawn-async": "^1.8.0",
"@expo/ws-tunnel": "^2.0.0",
"@expo/xcpretty": "^4.4.4",
"@react-native/dev-middleware": "0.85.3",
"accepts": "^1.3.8",
"arg": "^5.0.2",
"bplist-creator": "0.1.0",
"bplist-parser": "^0.3.1",
"chalk": "^4.0.0",
"ci-info": "^3.3.0",
"compression": "^1.7.4",
"connect": "^3.7.0",
"debug": "^4.3.4",
"dnssd-advertise": "^1.1.4",
"expo-server": "^56.0.5",
"fetch-nodeshim": "^0.4.10",
"getenv": "^2.0.0",
"glob": "^13.0.0",
"lan-network": "^0.2.1",
"multitars": "^1.0.0",
"node-forge": "^1.3.3",
"npm-package-arg": "^11.0.0",
"ora": "^3.4.0",
"picomatch": "^4.0.4",
"pretty-format": "^29.7.0",
"progress": "^2.0.3",
"prompts": "^2.3.2",
"resolve-from": "^5.0.0",
"semver": "^7.6.0",
"send": "^0.19.0",
"slugify": "^1.3.4",
"stacktrace-parser": "^0.1.10",
"structured-headers": "^0.4.1",
"terminal-link": "^2.1.1",
"toqr": "^0.1.1",
"wrap-ansi": "^7.0.0",
"ws": "^8.12.1",
"zod": "^3.25.76"
},
"bin": {
"expo-internal": "main.js"
},
"peerDependencies": {
"expo": "*",
"expo-router": "*",
"react-native": "*"
},
"peerDependenciesMeta": {
"expo-router": {
"optional": true
},
"react-native": {
"optional": true
}
}
},
"node_modules/expo/node_modules/@expo/cli/node_modules/@expo/router-server": {
"version": "56.0.14",
"resolved": "https://registry.npmjs.org/@expo/router-server/-/router-server-56.0.14.tgz",
"integrity": "sha512-2UCTtZfcq1ZPgp3wk8/+sq9DvFI9UxrPr1jcEKMAF2DGAJLosnpc8GWNNg2hkjt6SHUOdFHIPxujWPYyho2y3A==",
"license": "MIT",
"dependencies": {
"debug": "^4.3.4"
},
"peerDependencies": {
"@expo/metro-runtime": "^56.0.15",
"expo": "*",
"expo-constants": "^56.0.18",
"expo-font": "^56.0.6",
"expo-router": "*",
"expo-server": "^56.0.5",
"react": "*",
"react-dom": "*",
"react-server-dom-webpack": "~19.0.1 || ~19.1.2 || ~19.2.1"
},
"peerDependenciesMeta": {
"@expo/metro-runtime": {
"optional": true
},
"expo-router": {
"optional": true
},
"react-dom": {
"optional": true
},
"react-server-dom-webpack": {
"optional": true
}
}
},
"node_modules/expo/node_modules/expo-modules-core": { "node_modules/expo/node_modules/expo-modules-core": {
"version": "56.0.17", "version": "56.0.17",
"resolved": "https://registry.npmjs.org/expo-modules-core/-/expo-modules-core-56.0.17.tgz", "resolved": "https://registry.npmjs.org/expo-modules-core/-/expo-modules-core-56.0.17.tgz",
@@ -4070,6 +4315,45 @@
} }
} }
}, },
"node_modules/expo/node_modules/react-native-worklets": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.8.3.tgz",
"integrity": "sha512-oCBJROyLU7yG/1R8s0INMflygTH71bx+5XcYkH0CM938TlhSoVbiunE1WVW5FZa51vwYqfLie/IXMX2s1Kh3eg==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@babel/plugin-transform-arrow-functions": "^7.27.1",
"@babel/plugin-transform-class-properties": "^7.27.1",
"@babel/plugin-transform-classes": "^7.28.4",
"@babel/plugin-transform-nullish-coalescing-operator": "^7.27.1",
"@babel/plugin-transform-optional-chaining": "^7.27.1",
"@babel/plugin-transform-shorthand-properties": "^7.27.1",
"@babel/plugin-transform-template-literals": "^7.27.1",
"@babel/plugin-transform-unicode-regex": "^7.27.1",
"@babel/preset-typescript": "^7.27.1",
"convert-source-map": "^2.0.0",
"semver": "^7.7.3"
},
"peerDependencies": {
"@babel/core": "*",
"@react-native/metro-config": "*",
"react": "*",
"react-native": "0.81 - 0.85"
}
},
"node_modules/expo/node_modules/semver": {
"version": "7.8.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz",
"integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/exponential-backoff": { "node_modules/exponential-backoff": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz",
@@ -5180,6 +5464,16 @@
"yallist": "^3.0.2" "yallist": "^3.0.2"
} }
}, },
"node_modules/lz-string": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"license": "MIT",
"peer": true,
"bin": {
"lz-string": "bin/bin.js"
}
},
"node_modules/makeerror": { "node_modules/makeerror": {
"version": "1.0.12", "version": "1.0.12",
"resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz",
@@ -5816,9 +6110,9 @@
} }
}, },
"node_modules/npm-package-arg/node_modules/semver": { "node_modules/npm-package-arg/node_modules/semver": {
"version": "7.8.4", "version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"license": "ISC", "license": "ISC",
"bin": { "bin": {
"semver": "bin/semver.js" "semver": "bin/semver.js"
@@ -6414,9 +6708,9 @@
} }
}, },
"node_modules/react": { "node_modules/react": {
"version": "19.2.7", "version": "19.2.3",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
"integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
@@ -6453,6 +6747,19 @@
} }
} }
}, },
"node_modules/react-dom": {
"version": "19.2.3",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
"peerDependencies": {
"react": "^19.2.3"
}
},
"node_modules/react-fast-compare": { "node_modules/react-fast-compare": {
"version": "3.2.2", "version": "3.2.2",
"resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz", "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz",
@@ -6910,43 +7217,44 @@
"react-native": ">=0.82.0" "react-native": ">=0.82.0"
} }
}, },
"node_modules/react-native/node_modules/@react-native/codegen": { "node_modules/react-native-worklets": {
"version": "0.85.3", "version": "0.9.2",
"resolved": "https://registry.npmjs.org/@react-native/codegen/-/codegen-0.85.3.tgz", "resolved": "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.9.2.tgz",
"integrity": "sha512-/JkS1lGLyzBWP1FbgDwaqEf7qShIC6pUC1M0a/YMAd/v4iqR24MRkQWe7jkYvcBQ2LpEhs5NGE9InhxSv21zCA==", "integrity": "sha512-bCBV4dwcoLnqpk0bbL92nNuv6km37DRpamCX/nqNxsRs6LmLeFoFe/a7OKL1pVDi3PY97C0BEhmq8Qq/iNvpVg==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/core": "^7.25.2", "@babel/plugin-transform-arrow-functions": "^7.27.1",
"@babel/parser": "^7.29.0", "@babel/plugin-transform-class-properties": "^7.28.6",
"hermes-parser": "0.33.3", "@babel/plugin-transform-classes": "^7.28.6",
"invariant": "^2.2.4", "@babel/plugin-transform-nullish-coalescing-operator": "^7.28.6",
"nullthrows": "^1.1.1", "@babel/plugin-transform-optional-chaining": "^7.28.6",
"tinyglobby": "^0.2.15", "@babel/plugin-transform-shorthand-properties": "^7.27.1",
"yargs": "^17.6.2" "@babel/plugin-transform-template-literals": "^7.27.1",
}, "@babel/plugin-transform-unicode-regex": "^7.27.1",
"engines": { "@babel/preset-typescript": "^7.28.5",
"node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" "@babel/types": "^7.27.1",
"convert-source-map": "^2.0.0",
"semver": "^7.7.4"
}, },
"peerDependencies": { "peerDependencies": {
"@babel/core": "*" "@babel/core": "*",
"@react-native/metro-config": "*",
"react": "*",
"react-native": "0.83 - 0.86"
} }
}, },
"node_modules/react-native/node_modules/@react-native/js-polyfills": { "node_modules/react-native-worklets/node_modules/semver": {
"version": "0.85.3", "version": "7.8.4",
"resolved": "https://registry.npmjs.org/@react-native/js-polyfills/-/js-polyfills-0.85.3.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz",
"integrity": "sha512-U2+aMshIXf1uFn77tpBb/xhHWB9vkVrMpt7kkucAugF8hJKYTDGB587X7WwelHduK2KBfhl4giSv0rzZGoef9A==", "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==",
"license": "MIT", "license": "ISC",
"peer": true,
"bin": {
"semver": "bin/semver.js"
},
"engines": { "engines": {
"node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" "node": ">=10"
}
},
"node_modules/react-native/node_modules/babel-plugin-syntax-hermes-parser": {
"version": "0.33.3",
"resolved": "https://registry.npmjs.org/babel-plugin-syntax-hermes-parser/-/babel-plugin-syntax-hermes-parser-0.33.3.tgz",
"integrity": "sha512-/Z9xYdaJ1lC0pT9do6TqCqhOSLfZ5Ot8D5za1p+feEfWYupCOfGbhhEXN9r2ZgJtDNUNRw/Z+T2CvAGKBqtqWA==",
"license": "MIT",
"dependencies": {
"hermes-parser": "0.33.3"
} }
}, },
"node_modules/react-native/node_modules/commander": { "node_modules/react-native/node_modules/commander": {
@@ -7932,7 +8240,7 @@
"version": "5.8.3", "version": "5.8.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"dev": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
+5 -1
View File
@@ -19,7 +19,7 @@
"expo-sqlite": "~56.0.5", "expo-sqlite": "~56.0.5",
"expo-status-bar": "~56.0.4", "expo-status-bar": "~56.0.4",
"nativewind": "~4.2.5", "nativewind": "~4.2.5",
"react": "19.2.7", "react": "19.2.3",
"react-native": "0.85.3", "react-native": "0.85.3",
"react-native-gesture-handler": "~3.0.2", "react-native-gesture-handler": "~3.0.2",
"react-native-reanimated": "~4.4.1", "react-native-reanimated": "~4.4.1",
@@ -27,6 +27,10 @@
"react-native-screens": "~4.25.2", "react-native-screens": "~4.25.2",
"tailwindcss": "~3.4.19" "tailwindcss": "~3.4.19"
}, },
"overrides": {
"react": "19.2.3",
"react-dom": "19.2.3"
},
"devDependencies": { "devDependencies": {
"@babel/core": "^7.25.0", "@babel/core": "^7.25.0",
"@expo/metro-config": "~56.0.14", "@expo/metro-config": "~56.0.14",
+13
View File
@@ -0,0 +1,13 @@
// Exclude native build for packages not used in this project.
// react-native-gesture-handler detects their absence and falls back to its
// noreanimated codepath, so GestureHandlerRootView continues to work.
module.exports = {
dependencies: {
'react-native-reanimated': {
platforms: { android: null },
},
'react-native-worklets': {
platforms: { android: null },
},
},
};