diff --git a/.cursor/rules/animation-guidelines.mdc b/.cursor/rules/animation-guidelines.mdc
index c89d21f8e..ab273a8a5 100644
--- a/.cursor/rules/animation-guidelines.mdc
+++ b/.cursor/rules/animation-guidelines.mdc
@@ -61,23 +61,28 @@ The goal is to make complex products accessible to newcomers without sacrificing
A system of components housed within trays that expand, contract, and adapt in response to user actions:
**Tray Initiation Rules:**
+
- Trays are initiated by the user (tapping buttons, icons, or opening notifications)
- They can appear standalone on top of any content, or emerge from within other components like buttons
**Height Variation Rule:**
+
- Each subsequent tray varies in height to make progression unmistakably clear
- This constraint may require rewriting content or tweaking designs to make transitions apparent
**Single Focus Rule:**
+
- Each tray is dedicated to a singular piece of content (like educational text) or a primary action (like completing a checklist)
- Every tray has a title capturing its function and an icon for dismissal/navigation
**Context Preservation:**
+
- Unlike full screen transitions that displace users, trays overlay content directly onto the current interface
- Users aren't veering off course—they're diving deeper into their current context
- Contextual continuity keeps flows feeling integrated rather than disjointed
**When to Use Trays vs Full Screens:**
+
- Use trays for transient actions that don't need permanent display
- Especially helpful for confirmation steps and warnings that appear at the right time
- Trays can serve as starting points for elaborate flows that transition to full screen
@@ -118,6 +123,7 @@ While speed is important, applying motion thoughtfully can enhance clarity and f
**If a component is visible and will persist in the next phase, it should remain consistent.** Components should "travel" between screens rather than disappear and reappear.
Examples:
+
- Wallet cards move seamlessly between screens
- Empty states keep unchanged text constant when only a portion needs updating
- The same element animates into its new position rather than fading out/in
@@ -125,6 +131,7 @@ Examples:
### Connected State Transitions
Create interactions where:
+
- Trays morph into full screen views
- Buttons glide across trays
- Buttons morph into trays and back again
@@ -142,6 +149,7 @@ For actions where understanding is crucial (like sending money):
### The Cost of Removing Fluidity
Without fluid animations:
+
- The sense of connection is lost
- Contextual continuity disappears
- Actions feel like "digital whiplash"
@@ -162,6 +170,7 @@ Delight is about creating moments that resonate on a personal level—making sof
### The Foundation of Delight
Before adding delightful moments:
+
- Achieve consistent polish everywhere first
- Users notice when parts of an app are less polished
- Every part of the app deserves the same holistic design approach
@@ -173,11 +182,11 @@ Before adding delightful moments:
The potential for delight increases as feature usage frequency decreases:
-| Feature Frequency | Delight Strategy |
-|------------------|------------------|
+| Feature Frequency | Delight Strategy |
+| -------------------------- | ------------------------------------------------------------ |
| Daily use (sending tokens) | Focus on small, efficient touches that don't become tiresome |
-| Occasional use (settings) | Add satisfying interactions that reward exploration |
-| Rare use (wallet setup) | Create memorable, celebration-worthy moments |
+| Occasional use (settings) | Add satisfying interactions that reward exploration |
+| Rare use (wallet setup) | Create memorable, celebration-worthy moments |
**Why this works:** The "specialness of a moment" generally decreases with repeated encounters. Rare features benefit most from delightful surprises.
@@ -188,6 +197,7 @@ The potential for delight increases as feature usage frequency decreases:
- The discovery process itself creates delight
**Examples:**
+
- QR code screen: Tapping triggers a gentle ripple effect
- Swiping across QR code reveals a sequin-like transformation
- Entering an amount exceeding balance triggers a playful easter egg
@@ -197,31 +207,35 @@ The potential for delight increases as feature usage frequency decreases:
Match delight intensity to feature frequency:
**High-frequency features (daily use):**
+
- Commas visually shift place-to-place when inputting numbers
- Small, efficient touches that don't become annoying
**Medium-frequency features:**
+
- Drag-and-drop with attractive stacking animations
- Satisfying rather than tedious interactions
**Low-frequency features (rare use):**
+
- Wallet setup: Interactive animation marking the significant occasion
- Backup completion: Confetti fills the screen as reward
- Trash items: Visually tumble into a skeuomorphic trash can with sound effects
### Specific Delight Patterns
-| Feature | Delightful Touch |
-|---------|------------------|
-| First-time browser | Animated arrow guides toward creating a new tab |
-| Reordering items | Smooth drag-and-drop with stacking animations |
-| Stealth mode | Gentle shimmer effect signals hidden-but-updating values |
-| Price charts | Arrow flips direction alongside changing numbers |
-| Security tasks | Confetti rewards completion of essential tasks |
+| Feature | Delightful Touch |
+| ------------------ | -------------------------------------------------------- |
+| First-time browser | Animated arrow guides toward creating a new tab |
+| Reordering items | Smooth drag-and-drop with stacking animations |
+| Stealth mode | Gentle shimmer effect signals hidden-but-updating values |
+| Price charts | Arrow flips direction alongside changing numbers |
+| Security tasks | Confetti rewards completion of essential tasks |
### The Purpose of Delight
Delightful moments are not just entertainment—they:
+
- Value and reward the user's time and emotional investment
- Transform mundane interactions into memorable ones
- Create a familiar, friendly companion rather than just a tool
@@ -245,21 +259,7 @@ Delightful moments are not just entertainment—they:
- **Consider Ease-In-Out:** Appropriate for elements that move and scale but stay on the screen.
- **Avoid Linear:** Linear animations feel unnatural and lack energy; avoid them in 99% of cases.
-Use the following custom easing curves for a polished feel:
-
-```css
-/* Standard ease-out - good default for most animations */
---ease-out: cubic-bezier(0.16, 1, 0.3, 1);
-
-/* Smooth ease-in-out for symmetrical animations */
---ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
-
-/* Snappy ease for quick feedback */
---ease-snappy: cubic-bezier(0.2, 0, 0, 1);
-
-/* Spring-like bounce */
---ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
-```
+Use these Custom easing curves for a polished feel exported from `@constants/animations.ts` - import `easeOut`, `easeInOut`, `easeSnappy`, or `easeSpring` for use with React Native Reanimated animations.
### Button Press Feedback
@@ -426,19 +426,16 @@ For interactive elements, spring physics feel more natural than duration-based a
### Accessibility
-- **Respect User Preferences:** Use media queries like `prefers-reduced-motion` to disable or reduce animations for users who prefer it.
+- **Respect User Preferences:** Use React Native Reanimated's reduced motion functionality to respect system accessibility settings. All animations should respect the user's reduced motion preferences by default.
-```css
-@media (prefers-reduced-motion: reduce) {
- *,
- *::before,
- *::after {
- animation-duration: 0.01ms !important;
- animation-iteration-count: 1 !important;
- transition-duration: 0.01ms !important;
- }
-}
-```
+React Native Reanimated provides:
+
+- `ReduceMotion` enum (`System`, `Always`, `Never`) for configuring animation behavior
+- `reduceMotion` option in animation functions (`withTiming`, `withSpring`, `withDelay`, etc.)
+- `.reduceMotion()` method on layout animations (entering/exiting animations)
+- `useReducedMotion()` hook for conditional animation logic
+
+**Reference:** See the [React Native Reanimated Accessibility Guide](https://docs.swmansion.com/react-native-reanimated/docs/guides/accessibility/) for complete documentation, examples, and implementation details.
---
@@ -463,6 +460,7 @@ For interactive elements, spring physics feel more natural than duration-based a
### Delight Audit
When implementing a feature, ask:
+
1. How frequently will users encounter this?
2. What's the appropriate intensity of delight?
3. Is there an opportunity for surprise/discovery?
@@ -499,6 +497,7 @@ When implementing a feature, ask:
## Trade-offs and Commitment
Creating this level of experience requires:
+
- Conscious trade-offs in development speed
- Obsessive attention to detail
- Deep understanding of app navigation architecture
diff --git a/.cursor/rules/creating-components.mdc b/.cursor/rules/creating-components.mdc
index 95e4704f7..1b3ec8174 100644
--- a/.cursor/rules/creating-components.mdc
+++ b/.cursor/rules/creating-components.mdc
@@ -189,6 +189,25 @@ While React Compiler can automatically memoize components, it has limitations an
- **ESLint Disable Comments**: Never use `// eslint-disable-next-line` or similar ESLint disable comments in your code. These comments prevent React Compiler from properly analyzing and optimizing your code. If ESLint flags an issue, fix the underlying problem rather than suppressing the warning. For legitimate cases where a dependency should be excluded (like stable functions or refs), refactor the code to make the stability explicit rather than disabling the exhaustive-deps rule.
+- **React Native Reanimated Shared Values**: When working with React Native Reanimated's `useSharedValue`, use the `.get()` and `.set()` methods instead of accessing `.value` directly. This is the React Compiler-compliant API that avoids the need for refs or workarounds. Example:
+
+```tsx
+function App() {
+ const sv = useSharedValue(100);
+
+ const animatedStyle = useAnimatedStyle(() => {
+ 'worklet';
+ return { width: sv.get() * 100 };
+ });
+
+ const handlePress = () => {
+ sv.set(withTiming(200, { duration: 300 }));
+ };
+}
+```
+
+This eliminates the need for refs (`useRef`) and `useEffect` when modifying shared values in inline handlers, as React Compiler treats direct `.value` access as immutable mutations.
+
### Types and Props API
- **TypeScript**: Ship with comprehensive types for maximum safety and autocomplete
diff --git a/.eas/workflows/deploy-to-preview.yml b/.eas/workflows/deploy-to-preview.yml
index 59fb0a2c3..9c1718d9c 100644
--- a/.eas/workflows/deploy-to-preview.yml
+++ b/.eas/workflows/deploy-to-preview.yml
@@ -21,6 +21,7 @@ on:
- '!deno.json'
- '!.cursor/**'
- '!.vscode/**'
+ - '!.maestro/**'
jobs:
fingerprint:
diff --git a/.eas/workflows/deploy-to-production.yml b/.eas/workflows/deploy-to-production.yml
index 13684044e..8deb93f4a 100644
--- a/.eas/workflows/deploy-to-production.yml
+++ b/.eas/workflows/deploy-to-production.yml
@@ -21,6 +21,7 @@ on:
- '!deno.json'
- '!.cursor/**'
- '!.vscode/**'
+ - '!.maestro/**'
jobs:
fingerprint:
diff --git a/.eas/workflows/run-preview-tests.yml b/.eas/workflows/run-preview-tests.yml
index 1b7b4816d..15f3bf863 100644
--- a/.eas/workflows/run-preview-tests.yml
+++ b/.eas/workflows/run-preview-tests.yml
@@ -40,6 +40,16 @@ jobs:
profile: preview
platform: android
+ ios_get_build:
+ name: Check for existing ios build
+ needs: [fingerprint]
+ type: get-build
+ environment: preview
+ params:
+ fingerprint_hash: ${{ needs.fingerprint.outputs.ios_fingerprint_hash }}
+ profile: preview-simulator
+ platform: ios
+
android_repack:
name: Repack Android
needs: [android_get_build]
@@ -61,6 +71,27 @@ jobs:
platform: android
profile: preview
+ ios_repack:
+ name: Repack iOS
+ needs: [ios_get_build]
+ if: ${{ needs.ios_get_build.outputs.build_id }}
+ type: repack
+ environment: preview
+ runs_on: macos-medium
+ params:
+ build_id: ${{ needs.ios_get_build.outputs.build_id }}
+
+ ios_build:
+ name: Build iOS
+ needs: [ios_get_build]
+ if: ${{ !needs.ios_get_build.outputs.build_id }}
+ type: build
+ environment: preview
+ runs_on: macos-medium
+ params:
+ platform: ios
+ profile: preview-simulator
+
android_maestro:
name: Run Android Maestro Tests
after: [android_repack, android_build]
@@ -71,44 +102,19 @@ jobs:
params:
build_id: ${{ needs.android_repack.outputs.build_id || needs.android_build.outputs.build_id }}
record_screen: true
+ device_identifier: 'pixel_6'
android_system_image_package: 'system-images;android-31;default;x86_64'
- flow_path: ['maestro/register.yaml', 'maestro/sign-in.yaml']
-
- # ios_get_build:
- # name: Check for existing ios build
- # needs: [fingerprint]
- # type: get-build
- # environment: preview
- # params:
- # fingerprint_hash: ${{ needs.fingerprint.outputs.ios_fingerprint_hash }}
- # profile: preview-simulator
-
- # ios_repack:
- # name: Repack iOS
- # needs: [ios_get_build]
- # if: ${{ needs.ios_get_build.outputs.build_id }}
- # type: repack
- # environment: preview
- # params:
- # build_id: ${{ needs.ios_get_build.outputs.build_id }}
-
- # ios_build:
- # name: Build iOS
- # needs: [ios_get_build]
- # if: ${{ !needs.ios_get_build.outputs.build_id }}
- # type: build
- # environment: preview
- # params:
- # platform: ios
- # profile: preview-simulator
+ flow_path: ['.maestro/flows/register.yaml', '.maestro/flows/sign-in.yaml']
- # ios_maestro:
- # name: Run iOS Maestro Tests
- # after: [ios_repack, ios_build]
- # type: maestro
- # environment: preview
- # image: latest
- # params:
- # build_id: ${{ needs.ios_repack.outputs.build_id || needs.ios_build.outputs.build_id }}
- # flow_path: ['maestro.yaml']
- # flow_path: ['maestro.yaml']
+ ios_maestro:
+ name: Run iOS Maestro Tests
+ after: [ios_repack, ios_build]
+ type: maestro
+ environment: preview
+ image: latest
+ runs_on: macos-medium
+ params:
+ record_screen: true
+ device_identifier: 'iPhone 16e'
+ build_id: ${{ needs.ios_repack.outputs.build_id || needs.ios_build.outputs.build_id }}
+ flow_path: ['.maestro/flows/register.yaml', '.maestro/flows/sign-in.yaml']
diff --git a/.eas/workflows/run-production-tests.yml b/.eas/workflows/run-production-tests.yml
index f2fa13c48..7183b443a 100644
--- a/.eas/workflows/run-production-tests.yml
+++ b/.eas/workflows/run-production-tests.yml
@@ -40,6 +40,16 @@ jobs:
profile: production-simulator
platform: android
+ ios_get_build:
+ name: Check for existing ios build
+ needs: [fingerprint]
+ type: get-build
+ environment: production
+ params:
+ fingerprint_hash: ${{ needs.fingerprint.outputs.ios_fingerprint_hash }}
+ profile: production-simulator
+ platform: ios
+
android_repack:
name: Repack Android
needs: [android_get_build]
@@ -61,6 +71,27 @@ jobs:
platform: android
profile: production-simulator
+ ios_repack:
+ name: Repack iOS
+ needs: [ios_get_build]
+ if: ${{ needs.ios_get_build.outputs.build_id }}
+ type: repack
+ environment: production
+ runs_on: macos-medium
+ params:
+ build_id: ${{ needs.ios_get_build.outputs.build_id }}
+
+ ios_build:
+ name: Build iOS
+ needs: [ios_get_build]
+ if: ${{ !needs.ios_get_build.outputs.build_id }}
+ type: build
+ environment: production
+ runs_on: macos-medium
+ params:
+ platform: ios
+ profile: production-simulator
+
android_maestro:
name: Run Android Maestro Tests
after: [android_repack, android_build]
@@ -72,43 +103,16 @@ jobs:
build_id: ${{ needs.android_repack.outputs.build_id || needs.android_build.outputs.build_id }}
record_screen: true
android_system_image_package: 'system-images;android-31;default;x86_64'
- flow_path: ['maestro/register.yaml', 'maestro/sign-in.yaml']
-
- # ios_get_build:
- # name: Check for existing ios build
- # needs: [fingerprint]
- # type: get-build
- # environment: production
- # params:
- # fingerprint_hash: ${{ needs.fingerprint.outputs.ios_fingerprint_hash }}
- # profile: production-simulator
-
- # ios_repack:
- # name: Repack iOS
- # needs: [ios_get_build]
- # if: ${{ needs.ios_get_build.outputs.build_id }}
- # type: repack
- # environment: production
- # params:
- # build_id: ${{ needs.ios_get_build.outputs.build_id }}
-
- # ios_build:
- # name: Build iOS
- # needs: [ios_get_build]
- # if: ${{ !needs.ios_get_build.outputs.build_id }}
- # type: build
- # environment: production
- # params:
- # platform: ios
- # profile: production-simulator
+ flow_path: ['.maestro/flows/register.yaml', '.maestro/flows/sign-in.yaml']
- # ios_maestro:
- # name: Run iOS Maestro Tests
- # after: [ios_repack, ios_build]
- # type: maestro
- # environment: production
- # image: latest
- # params:
- # build_id: ${{ needs.ios_repack.outputs.build_id || needs.ios_build.outputs.build_id }}
- # flow_path: ['maestro.yaml']
- # flow_path: ['maestro.yaml']
+ ios_maestro:
+ name: Run iOS Maestro Tests
+ after: [ios_repack, ios_build]
+ type: maestro
+ environment: production
+ image: latest
+ runs_on: macos-medium
+ params:
+ record_screen: true
+ build_id: ${{ needs.ios_repack.outputs.build_id || needs.ios_build.outputs.build_id }}
+ flow_path: ['.maestro/flows/register.yaml', '.maestro/flows/sign-in.yaml']
diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 000000000..93e1febb2
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,15 @@
+root = true
+
+[*]
+charset = utf-8
+end_of_line = lf
+insert_final_newline = true
+indent_style = space
+indent_size = 2
+trim_trailing_whitespace = true
+
+[*.md]
+trim_trailing_whitespace = false
+
+[*.{bat,cmd,ps1}]
+end_of_line = crlf
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 000000000..1ec875276
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,6 @@
+* text=auto eol=lf
+
+# Keep Windows-native script endings for better local tooling compatibility.
+*.bat text eol=crlf
+*.cmd text eol=crlf
+*.ps1 text eol=crlf
diff --git a/.github/workflows/check-native-build.yml b/.github/workflows/check-native-build.yml
index ce2bc8e9d..698857895 100644
--- a/.github/workflows/check-native-build.yml
+++ b/.github/workflows/check-native-build.yml
@@ -78,6 +78,12 @@ jobs:
}
}
+ - name: Write fingerprint diff to file
+ if: github.event_name == 'pull_request' && github.event.pull_request.base.ref == 'dev'
+ run: |
+ echo '${{ steps.fingerprint.outputs.fingerprint-diff }}' > fingerprint-diff-raw.json
+ shell: bash
+
- name: Check if fingerprint compatibility changed
if: github.event_name == 'pull_request' && github.event.pull_request.base.ref == 'dev'
id: check-compatibility-change
@@ -85,7 +91,12 @@ jobs:
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
- const fingerprintDiff = ${{ steps.fingerprint.outputs.fingerprint-diff }};
+ const fs = require('fs');
+ // Read fingerprint diff from file to avoid environment variable size limits
+ let fingerprintDiff = '[]';
+ if (fs.existsSync('fingerprint-diff-raw.json')) {
+ fingerprintDiff = fs.readFileSync('fingerprint-diff-raw.json', 'utf8').trim();
+ }
// Handle fingerprint diff - it's a JSON string, check if it's an empty array
const trimmedDiff = String(fingerprintDiff).trim();
// Empty array means no changes (compatible), non-empty means changes
@@ -139,7 +150,7 @@ jobs:
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
- const message = `⚠️ Native Build Required - See the comment below for details.`;
+ const message = '⚠️ Native Build Required - See the comment below for details.';
await github.rest.pulls.createReview({
owner: context.repo.owner,
@@ -156,39 +167,169 @@ jobs:
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
- const fingerprintDiff = ${{ steps.fingerprint.outputs.fingerprint-diff }};
- const prettifiedDiff = JSON.stringify(fingerprintDiff, null, 2);
- core.setOutput('prettified_diff', prettifiedDiff);
+ const fs = require('fs');
+ // Read from file to avoid environment variable size limits
+ let fingerprintDiffStr = '[]';
+ if (fs.existsSync('fingerprint-diff-raw.json')) {
+ fingerprintDiffStr = fs.readFileSync('fingerprint-diff-raw.json', 'utf8').trim();
+ }
+ // Parse the JSON string if it's valid JSON, otherwise use as-is
+ let fingerprintDiff;
+ try {
+ fingerprintDiff = JSON.parse(fingerprintDiffStr);
+ } catch (e) {
+ fingerprintDiff = fingerprintDiffStr;
+ }
+
+ // Format as GitHub code diff
+ function formatSource(source) {
+ if (!source) return '';
+ const parts = [];
+ if (source.type === 'file' || source.type === 'dir') {
+ parts.push(`Path: ${source.filePath}`);
+ } else if (source.type === 'contents') {
+ parts.push(`ID: ${source.id}`);
+ }
+ if (source.hash) {
+ parts.push(`Hash: ${source.hash}`);
+ }
+ if (source.reasons && source.reasons.length > 0) {
+ parts.push(`Reasons: ${source.reasons.join(', ')}`);
+ }
+ return parts.join(' | ');
+ }
+
+ function formatDiffAsCode(diff) {
+ if (!Array.isArray(diff) || diff.length === 0) {
+ return 'No changes detected.';
+ }
+
+ const lines = [];
+ diff.forEach((item, index) => {
+ if (item.op === 'added') {
+ lines.push(`+ Added: ${formatSource(item.addedSource)}`);
+ } else if (item.op === 'removed') {
+ lines.push(`- Removed: ${formatSource(item.removedSource)}`);
+ } else if (item.op === 'changed') {
+ lines.push(`- Changed (before): ${formatSource(item.beforeSource)}`);
+ lines.push(`+ Changed (after): ${formatSource(item.afterSource)}`);
+ }
+ // Add separator between items (except last)
+ if (index < diff.length - 1) {
+ lines.push('');
+ }
+ });
+
+ return lines.join('\n');
+ }
+
+ const codeDiff = formatDiffAsCode(fingerprintDiff);
+ // Write to file to avoid output size limits
+ fs.writeFileSync('fingerprint-diff-formatted.txt', codeDiff);
+ // Also keep JSON for reference
+ fs.writeFileSync('fingerprint-diff.json', JSON.stringify(fingerprintDiff, null, 2));
+ // Set a flag output instead of the full diff
+ core.setOutput('has_diff_file', 'true');
- name: Comment on PR if fingerprint changed
if: github.event_name == 'pull_request' && github.event.pull_request.base.ref == 'dev' && steps.check-compatibility-change.outputs.has_changes == 'true'
- uses: thollander/actions-comment-pull-request@v3
+ uses: actions/github-script@v7
+ env:
+ HAS_DIFF_FILE: ${{ steps.format-diff.outputs.has_diff_file }}
with:
- comment-tag: fingerprint-check
- message: |
- ⚠️ Native Build Required - Manual Approval Needed
-
- This PR requires a native build because the fingerprint has changed compared to the base branch.
-
-
- Fingerprint diff
-
- ```json
- ${{ steps.format-diff.outputs.prettified_diff }}
- ```
+ github-token: ${{ secrets.GITHUB_TOKEN }}
+ script: |
+ const fs = require('fs');
+ let codeDiff = 'No changes detected.';
+ let jsonDiff = '[]';
+
+ if (process.env.HAS_DIFF_FILE === 'true') {
+ if (fs.existsSync('fingerprint-diff-formatted.txt')) {
+ codeDiff = fs.readFileSync('fingerprint-diff-formatted.txt', 'utf8');
+ }
+ if (fs.existsSync('fingerprint-diff.json')) {
+ jsonDiff = fs.readFileSync('fingerprint-diff.json', 'utf8');
+ }
+ }
+
+ const message = '\n' +
+ '⚠️ Native Build Required - Manual Approval Needed\n\n' +
+ 'This PR requires a native build because the fingerprint has changed compared to the base branch.\n\n' +
+ '\n' +
+ '📋 Fingerprint diff
\n\n' +
+ '```diff\n' +
+ codeDiff + '\n' +
+ '```\n\n' +
+ ' \n\n' +
+ '\n' +
+ '🔍 Full JSON diff (for debugging)
\n\n' +
+ '```json\n' +
+ jsonDiff + '\n' +
+ '```\n\n' +
+ ' \n\n' +
+ 'Action Required: After confirming with the team, another team member must approve this PR to proceed with merging.\n\n' +
+ '👉 [**Approve this PR**](' + context.serverUrl + '/' + context.repo.owner + '/' + context.repo.repo + '/pull/' + context.issue.number + '/files) - Click "Review changes/Submit review" → "Approve" to dismiss this review and allow merging.';
-
+ // Find existing comment with fingerprint-check marker
+ const { data: comments } = await github.rest.issues.listComments({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: context.issue.number,
+ });
- Action Required: After confirming with the team, another team member must approve this PR to proceed with merging.
+ const existingComment = comments.find(
+ comment => comment.user.type === 'Bot' &&
+ comment.user.login === 'github-actions[bot]' &&
+ comment.body.includes('')
+ );
- 👉 [**Approve this PR**](${{ github.server_url }}/${{ github.repository }}/pull/${{ github.event.pull_request.number }}/files) - Click "Review changes/Submit review" → "Approve" to dismiss this review and allow merging.
+ if (existingComment) {
+ // Update existing comment
+ await github.rest.issues.updateComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ comment_id: existingComment.id,
+ body: message
+ });
+ console.log('✅ Updated fingerprint comment');
+ } else {
+ // Create new comment
+ await github.rest.issues.createComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: context.issue.number,
+ body: message
+ });
+ console.log('✅ Created fingerprint comment');
+ }
- name: Remove comment if fingerprint compatible
if: github.event_name == 'pull_request' && github.event.pull_request.base.ref == 'dev' && steps.check-compatibility-change.outputs.has_changes == 'false'
- uses: thollander/actions-comment-pull-request@v3
+ uses: actions/github-script@v7
with:
- comment-tag: fingerprint-check
- mode: delete
+ github-token: ${{ secrets.GITHUB_TOKEN }}
+ script: |
+ // Find and delete comments with the fingerprint-check marker
+ const { data: comments } = await github.rest.issues.listComments({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: context.issue.number,
+ });
+
+ const fingerprintComment = comments.find(
+ comment => comment.user.type === 'Bot' &&
+ comment.user.login === 'github-actions[bot]' &&
+ comment.body.includes('')
+ );
+
+ if (fingerprintComment) {
+ await github.rest.issues.deleteComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ comment_id: fingerprintComment.id,
+ });
+ console.log('✅ Removed fingerprint comment');
+ }
dismiss-bot-review-on-approval:
if: github.event_name == 'pull_request_review' && github.event.review.state == 'approved' && github.event.pull_request.base.ref == 'dev'
diff --git a/.gitignore b/.gitignore
index b3378a05e..8d6575717 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,8 +10,8 @@ npm-debug.*
*.orig.*
web-build/
llm_supp_files/
-android/
-ios/
+/android
+/ios
.env
.env.*
!.env.*.example
@@ -43,7 +43,6 @@ langquest types.xlsx
*.vsix
llm_supp_files
-/docs
/.venv
# include all version db files (1.0.db, 2.0.db, etc.)
diff --git a/maestro/api.js b/.maestro/api.js
similarity index 70%
rename from maestro/api.js
rename to .maestro/api.js
index 430b26463..503e64b55 100644
--- a/maestro/api.js
+++ b/.maestro/api.js
@@ -132,7 +132,83 @@ function generatePasswordResetLink(email) {
return resetLink;
}
+function deleteProject(projectName) {
+ // Validate projectName is defined and is a non-empty string
+ if (
+ !projectName ||
+ typeof projectName !== 'string' ||
+ projectName.trim() === ''
+ ) {
+ throw new Error(
+ 'Project name is required and must be a non-empty string. Received: ' +
+ projectName
+ );
+ }
+
+ console.log('Deleting project with name:', projectName);
+
+ // First, get the project by name using Supabase REST API
+ const getProjectResponse = http.get(
+ supabaseUrl +
+ '/rest/v1/project?name=eq.' +
+ encodeURIComponent(projectName) +
+ '&select=id',
+ {
+ headers: {
+ Authorization: 'Bearer ' + serviceRoleKey,
+ apikey: serviceRoleKey,
+ 'Content-Type': 'application/json',
+ Prefer: 'return=representation'
+ }
+ }
+ );
+
+ if (getProjectResponse.status !== 200) {
+ throw new Error(
+ 'Failed to query project: ' +
+ getProjectResponse.status +
+ ' ' +
+ getProjectResponse.body
+ );
+ }
+
+ // Parse the response to get the project ID
+ const projects = JSON.parse(getProjectResponse.body);
+ if (!projects || projects.length === 0) {
+ throw new Error('Project not found with name: ' + projectName);
+ }
+
+ const projectId = projects[0].id;
+ console.log('Found project ID:', projectId);
+
+ // Delete the project by ID
+ const deleteResponse = http.delete(
+ supabaseUrl + '/rest/v1/project?id=eq.' + projectId,
+ {
+ headers: {
+ Authorization: 'Bearer ' + serviceRoleKey,
+ apikey: serviceRoleKey,
+ 'Content-Type': 'application/json',
+ Prefer: 'return=representation'
+ }
+ }
+ );
+
+ if (deleteResponse.status !== 200 && deleteResponse.status !== 204) {
+ throw new Error(
+ 'Failed to delete project: ' +
+ deleteResponse.status +
+ ' ' +
+ deleteResponse.body
+ );
+ }
+
+ console.log('Successfully deleted project:', projectName);
+ return deleteResponse;
+}
+
output.api = {
deleteUser,
- generatePasswordResetLink
+ generatePasswordResetLink,
+ deleteProject
};
diff --git a/.maestro/flows/_ios-toggle-autofill-passwords.yaml b/.maestro/flows/_ios-toggle-autofill-passwords.yaml
new file mode 100644
index 000000000..a3eed6877
--- /dev/null
+++ b/.maestro/flows/_ios-toggle-autofill-passwords.yaml
@@ -0,0 +1,33 @@
+appId: ${MAESTRO_APP_ID}
+---
+# iOS-only subflow to toggle AutoFill Passwords in Settings
+# This should only be called from iOS test flows
+- launchApp:
+ appId: 'com.apple.Preferences'
+- scrollUntilVisible:
+ element: Apps
+- tapOn: Apps
+- scrollUntilVisible:
+ element: Passwords
+- tapOn: Passwords
+- scrollUntilVisible:
+ element: View AutoFill Settings
+- tapOn: View AutoFill Settings
+- runFlow:
+ when:
+ visible:
+ text: '1'
+ rightOf: AutoFill Passwords and Passkeys
+ commands:
+ - tapOn:
+ text: '1'
+ rightOf: AutoFill Passwords and Passkeys
+- runFlow:
+ when:
+ visible:
+ text: '1'
+ rightOf: Suggest Strong Passwords
+ commands:
+ - tapOn:
+ text: '1'
+ rightOf: Suggest Strong Passwords
diff --git a/.maestro/flows/_launch-and-onboard.yaml b/.maestro/flows/_launch-and-onboard.yaml
new file mode 100644
index 000000000..209ced421
--- /dev/null
+++ b/.maestro/flows/_launch-and-onboard.yaml
@@ -0,0 +1,16 @@
+appId: ${MAESTRO_APP_ID}
+---
+- launchApp:
+ clearState: true
+- assertVisible: Terms & Privacy
+- scrollUntilVisible:
+ element: I agree
+- tapOn:
+ text: I agree
+ waitToSettleTimeoutMs: 0 # stop waiting for onboarding animation
+- assertVisible:
+ text: Every language. Every culture.
+ waitToSettleTimeoutMs: 0 # stop waiting for onboarding animation
+- tapOn:
+ id: onboarding-close-button
+ waitToSettleTimeoutMs: 0 # stop waiting for onboarding animation
diff --git a/.maestro/flows/create-records.yaml b/.maestro/flows/create-records.yaml
new file mode 100644
index 000000000..734db49f4
--- /dev/null
+++ b/.maestro/flows/create-records.yaml
@@ -0,0 +1,43 @@
+appId: ${MAESTRO_APP_ID}
+onFlowStart:
+ - runScript: ../api.js
+---
+- evalScript: ${output.projectName = "Test project"}
+- evalScript: ${output.questName = "Test quest"}
+- runFlow: sign-in.yaml
+
+# Create project
+- runFlow:
+ commands:
+ - tapOn:
+ id: app-drawer-menu-button
+ - tapOn: # go back to projects page
+ text: Projects
+ below: Connected
+ - tapOn: New Project
+ - tapOn: Project Name
+ - inputText: ${output.projectName}
+ # - inputText: Modern English
+ # - tapOn: '[Modern English]' - for some reason Maestro is not finding content in drawers
+ - assertVisible:
+ text: English
+ below: ${output.projectName}
+ - tapOn: Create
+ - assertVisible:
+ text: ${output.projectName}, English
+
+# Create quest
+- runFlow:
+ commands:
+ - tapOn: ${output.projectName}, English
+ - tapOn: Create
+ - tapOn: Quest Name
+ - inputText: ${output.questName}
+ - pressKey: Enter
+ - tapOn:
+ text: Create
+ above: Cancel
+ - assertVisible: ${output.questName}
+
+# Cleanup
+- evalScript: ${output.api.deleteProject(output.projectName)}
diff --git a/maestro/register.yaml b/.maestro/flows/register.yaml
similarity index 65%
rename from maestro/register.yaml
rename to .maestro/flows/register.yaml
index 0362a8daa..b54dbedf6 100644
--- a/maestro/register.yaml
+++ b/.maestro/flows/register.yaml
@@ -1,19 +1,14 @@
appId: ${MAESTRO_APP_ID}
onFlowStart:
- - runScript: ./api.js
-jsEngine: graaljs
+ - runScript: ../api.js
---
- evalScript: ${output.username = faker.internet().username()}
- evalScript: ${output.email = faker.internet().emailAddress()}
-- launchApp:
- clearState: true
-- assertVisible: Terms & Privacy
-- tapOn:
- point: 8%,86%
-- tapOn: Accept
-- assertVisible: Every language. Every culture.
-- tapOn:
- id: onboarding-close-button
+- runFlow:
+ when:
+ platform: iOS
+ file: _ios-toggle-autofill-passwords.yaml
+- runFlow: _launch-and-onboard.yaml
- tapOn: Create Account
- tapOn: Username
- inputText: ${output.username}-testing
@@ -23,13 +18,13 @@ jsEngine: graaljs
- inputText: password
- tapOn: Confirm Password
- inputText: password
-- hideKeyboard
+- pressKey: Enter
- tapOn:
- point: 10%,69%
+ id: checkbox # checkboxes are hard to find by maestro in LangQuest, using id
- tapOn: Register
- assertVisible: Projects
- tapOn:
- point: 89%,5%
+ id: app-drawer-menu-button
- tapOn: Profile
- assertVisible: Profile
- evalScript: ${output.api.deleteUser(output.email)}
diff --git a/maestro/reset-password.yaml b/.maestro/flows/reset-password.yaml
similarity index 90%
rename from maestro/reset-password.yaml
rename to .maestro/flows/reset-password.yaml
index 2eabb7cc9..9598dfcad 100644
--- a/maestro/reset-password.yaml
+++ b/.maestro/flows/reset-password.yaml
@@ -1,7 +1,6 @@
appId: ${MAESTRO_APP_ID}
onFlowStart:
- - runScript: ./api.js
-jsEngine: graaljs
+ - runScript: ../api.js
---
# Reset Password Test Flow
# This test covers the complete password reset flow:
@@ -22,15 +21,8 @@ jsEngine: graaljs
- evalScript: ${output.resetLink = output.api.generatePasswordResetLink(MAESTRO_TEST_EMAIL)}
# Step 2: Launch app and open the reset link
-- launchApp:
- clearState: true
-- assertVisible: Terms & Privacy
-- tapOn:
- point: 8%,86%
-- tapOn: Accept
-- assertVisible: Every language. Every culture.
-- tapOn:
- id: onboarding-close-button
+- runFlow:
+ file: _launch-and-onboard.yaml
- openLink:
link: ${output.resetLink}
autoVerify: true
diff --git a/.maestro/flows/sign-in.yaml b/.maestro/flows/sign-in.yaml
new file mode 100644
index 000000000..f456e4808
--- /dev/null
+++ b/.maestro/flows/sign-in.yaml
@@ -0,0 +1,26 @@
+appId: ${MAESTRO_APP_ID}
+---
+- runFlow:
+ when:
+ platform: iOS
+ file: _ios-toggle-autofill-passwords.yaml
+- runFlow: _launch-and-onboard.yaml
+- tapOn:
+ text: Sign In
+ index: -1
+- tapOn: Enter your email
+- inputText: ${MAESTRO_TEST_EMAIL}
+- tapOn: Enter your password
+- inputText: ${MAESTRO_TEST_PASSWORD}
+- tapOn:
+ point: 95%,30%
+- tapOn:
+ text: Sign In
+ below: I forgot my password
+- tapOn:
+ id: app-drawer-menu-button
+- tapOn: Profile
+- assertVisible:
+ text: Profile
+ above:
+ text: ${MAESTRO_TEST_EMAIL}
diff --git a/.vscode/settings.json b/.vscode/settings.json
index be018b0d9..0c3f9f28e 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -1,5 +1,6 @@
{
"editor.defaultFormatter": "esbenp.prettier-vscode",
+ "files.eol": "\n",
"tailwindCSS.classAttributes": [
"class",
"className",
diff --git a/app.config.ts b/app.config.ts
index 55860183e..fe1100ff0 100644
--- a/app.config.ts
+++ b/app.config.ts
@@ -53,7 +53,7 @@ export default ({ config }: ConfigContext): ExpoConfig =>
owner: 'eten-genesis',
name: getAppName(appVariant),
slug: 'langquest',
- version: '2.0.14',
+ version: '2.1.0',
orientation: 'portrait',
icon: iconLight,
scheme: getScheme(appVariant),
@@ -76,7 +76,6 @@ export default ({ config }: ConfigContext): ExpoConfig =>
}
},
android: {
- edgeToEdgeEnabled: true,
adaptiveIcon: {
foregroundImage: './assets/icons/adaptive-icon.png',
monochromeImage: './assets/icons/adaptive-icon-mono.png',
@@ -111,6 +110,8 @@ export default ({ config }: ConfigContext): ExpoConfig =>
'expo-router',
// TODO: migrate existing localization to expo-localization
'expo-localization',
+ 'expo-asset',
+ 'expo-audio',
[
'expo-splash-screen',
{
diff --git a/app/_layout.tsx b/app/_layout.tsx
index 33672aab4..dcf6cde28 100644
--- a/app/_layout.tsx
+++ b/app/_layout.tsx
@@ -143,12 +143,12 @@ export default function RootLayout() {
return (
-
-
-
-
-
-
+
+
+
+
+
+
@@ -164,12 +164,12 @@ export default function RootLayout() {
-
-
-
-
-
-
+
+
+
+
+
+
);
}
diff --git a/app/terms.tsx b/app/terms.tsx
index d5d8681ff..52770b3cc 100644
--- a/app/terms.tsx
+++ b/app/terms.tsx
@@ -1,21 +1,14 @@
import { LanguageSelect } from '@/components/language-select';
-import {
- Button,
- ButtonPressableOpacity,
- buttonTextVariants
-} from '@/components/ui/button';
-import { Checkbox } from '@/components/ui/checkbox';
+import { Button, OpacityPressable } from '@/components/ui/button';
import { Icon } from '@/components/ui/icon';
-import { Label } from '@/components/ui/label';
import { Text } from '@/components/ui/text';
import { useLocalization } from '@/hooks/useLocalization';
import { useLocalStore } from '@/store/localStore';
-import { cn } from '@/utils/styleUtils';
import { useRouter } from 'expo-router';
import * as SplashScreen from 'expo-splash-screen';
import { ArrowLeftIcon, XIcon } from 'lucide-react-native';
import React, { useCallback, useState } from 'react';
-import { Linking, Pressable, View } from 'react-native';
+import { Linking, View } from 'react-native';
import { ScrollView } from 'react-native-gesture-handler';
function Terms() {
@@ -23,7 +16,6 @@ function Terms() {
const dateTermsAccepted = useLocalStore((state) => state.dateTermsAccepted);
const acceptTerms = useLocalStore((state) => state.acceptTerms);
const { t } = useLocalization();
- const [termsAccepted, setTermsAccepted] = useState(false);
const handleAcceptTerms = useCallback(() => {
console.log('Accepting terms...');
@@ -33,10 +25,6 @@ function Terms() {
router.replace('/');
}, [acceptTerms, router]);
- const handleToggleTerms = useCallback(() => {
- setTermsAccepted(!termsAccepted);
- }, [termsAccepted]);
-
const handleClosePress = useCallback(() => {
if (router.canGoBack()) {
router.back();
@@ -45,14 +33,6 @@ function Terms() {
}
}, [router]);
- const handleViewTerms = useCallback(() => {
- void Linking.openURL(`${process.env.EXPO_PUBLIC_SITE_URL}/terms`);
- }, []);
-
- const handleViewPrivacy = useCallback(() => {
- void Linking.openURL(`${process.env.EXPO_PUBLIC_SITE_URL}/privacy`);
- }, []);
-
const canAcceptTerms = !dateTermsAccepted;
const [languagesLoaded, setLanguagesLoaded] = useState(false);
@@ -65,11 +45,11 @@ function Terms() {
return (
- {router.canGoBack() && (
+ {router.canGoBack() && dateTermsAccepted && (
@@ -89,56 +69,54 @@ function Terms() {
-
- {t('termsContributionInfo')}
+
+
+ {(() => {
+ // Get raw translation without replacing placeholders
+ const rawText = t('termsContributionInfo');
+ // Split on {iAgree} placeholder (with optional spaces)
+ const placeholderRegex = /\{ *iAgree *\}/;
+ const match = rawText.match(placeholderRegex);
+ if (match) {
+ const parts = rawText.split(placeholderRegex);
+ const iAgreeText = t('iAgree');
+ return (
+ <>
+ {parts[0]}
+ {iAgreeText}
+ {parts[1]}
+ >
+ );
+ }
+ // Fallback if placeholder not found
+ return rawText;
+ })()}
+
{t('termsDataInfo')}
{t('analyticsInfo')}
-
-
-
- {t('viewFullTerms')}
-
-
-
-
- {t('viewFullPrivacy')}
-
-
-
+
+ Linking.openURL(`${process.env.EXPO_PUBLIC_SITE_URL}/terms`)
+ }
+ className="w-full justify-start"
+ >
+ {t('viewFullTerms')}
+
+
+ Linking.openURL(`${process.env.EXPO_PUBLIC_SITE_URL}/privacy`)
+ }
+ className="w-full justify-start"
+ >
+ {t('viewFullPrivacy')}
+
+
{canAcceptTerms && (
-
-
-
-
-
-
-
-
{onTranscribe && (
- = ({
color={colors.background}
/>
) : (
-
)}
-
+
)}
{!mini && {item.title}}
diff --git a/components/AudioRecorder.tsx b/components/AudioRecorder.tsx
index dc3ec089b..8f55bd01c 100644
--- a/components/AudioRecorder.tsx
+++ b/components/AudioRecorder.tsx
@@ -5,12 +5,20 @@ import { Text } from '@/components/ui/text';
import { useAuth } from '@/contexts/AuthContext';
import { useLocalization } from '@/hooks/useLocalization';
import { deleteIfExists } from '@/utils/fileUtils';
-import type { AVPlaybackStatus } from 'expo-av';
-import { Audio } from 'expo-av';
-import type { RecordingOptions } from 'expo-av/build/Audio';
+import {
+ createAudioPlayer,
+ getRecordingPermissionsAsync,
+ RecordingPresets,
+ requestRecordingPermissionsAsync,
+ setAudioModeAsync,
+ useAudioRecorder,
+ useAudioRecorderState,
+ type AudioPlayer,
+ type RecordingOptions
+} from 'expo-audio';
import type { LucideIcon } from 'lucide-react-native';
import { Check, Mic, Pause, Play } from 'lucide-react-native';
-import React, { useCallback, useEffect, useState } from 'react';
+import React, { useCallback, useEffect, useRef, useState } from 'react';
import { Platform, Pressable, View } from 'react-native';
// Maximum file size in bytes (50MB)
@@ -29,10 +37,8 @@ interface AudioRecorderProps {
}
function calculateMaxDuration(options: RecordingOptions) {
- const platform = Platform.OS === 'ios' ? 'ios' : 'android';
- const platformSpecificOptions = options[platform];
- // Using the exact bit rates from RecordingOptionsPresets
- const bitRate = platformSpecificOptions.bitRate ?? 128000; // bits per second
+ // expo-audio has bitRate at the top level
+ const bitRate = options.bitRate ?? 128000; // bits per second
// Convert bit rate to bytes per second
const bytesPerSecond = bitRate / 8;
@@ -49,124 +55,156 @@ const AudioRecorder: React.FC = ({
}) => {
const { currentUser } = useAuth();
const { t } = useLocalization();
- const [recording, setRecording] = useState(null);
- const [sound, setSound] = useState(null);
const [recordingUri, setRecordingUri] = useState(null);
const [recordingDuration, setRecordingDuration] = useState(0);
const [playbackPosition, setPlaybackPosition] = useState(0);
- const [isRecording, setIsRecording] = useState(false);
+ const [isRecordingActive, setIsRecordingActive] = useState(false);
const [isRecordingPaused, setIsRecordingPaused] = useState(false);
const [isPlaying, setIsPlaying] = useState(false);
const [showWarning, setShowWarning] = useState(false);
- const [permissionResponse, requestPermission] = Audio.usePermissions();
+ const [permissionGranted, setPermissionGranted] = useState(
+ null
+ );
const [quality, setQuality] = useState('HIGH_QUALITY');
- // Calculate max duration and warning threshold based on quality
- const maxDuration = calculateMaxDuration(
- Audio.RecordingOptionsPresets[quality]!
+ // Refs for playback
+ const playerRef = useRef(null);
+ const playerListenerRef = useRef<{ remove: () => void } | null>(null);
+ const positionIntervalRef = useRef | null>(
+ null
);
+
+ // Ref for stopRecording to avoid stale closure in status listener
+ const stopRecordingRef = useRef<(() => Promise) | undefined>(undefined);
+
+ // Calculate max duration and warning threshold based on quality
+ const maxDuration = calculateMaxDuration(RecordingPresets[quality]!);
const warningThreshold = maxDuration * 0.85; // Warning at 85% of max duration
+ // Check permissions on mount
+ useEffect(() => {
+ void getRecordingPermissionsAsync().then(({ granted }) =>
+ setPermissionGranted(granted)
+ );
+ }, []);
+
+ // Create recorder (no status listener -- use useAudioRecorderState for duration/metering)
+ const recorder = useAudioRecorder(RecordingPresets.HIGH_QUALITY);
+
+ // Poll recording state for duration tracking
+ const recorderState = useAudioRecorderState(recorder, 100);
+
+ // React to recorder state changes for duration tracking and auto-stop
+ useEffect(() => {
+ if (recorderState.isRecording) {
+ const duration = recorderState.durationMillis || 0;
+ setRecordingDuration(duration);
+
+ // Check if we're approaching the limit
+ if (duration >= warningThreshold && !showWarning) {
+ setShowWarning(true);
+ }
+
+ // Stop recording if we've reached the maximum duration
+ if (duration >= maxDuration) {
+ void stopRecordingRef.current?.();
+ }
+ }
+ }, [recorderState, warningThreshold, showWarning, maxDuration]);
+
+ const cleanupPlayer = useCallback(() => {
+ if (positionIntervalRef.current) {
+ clearInterval(positionIntervalRef.current);
+ positionIntervalRef.current = null;
+ }
+ if (playerListenerRef.current) {
+ playerListenerRef.current.remove();
+ playerListenerRef.current = null;
+ }
+ if (playerRef.current) {
+ playerRef.current.pause();
+ playerRef.current.release();
+ playerRef.current = null;
+ }
+ }, []);
+
const stopRecording = useCallback(async () => {
- if (!recording) return;
+ if (!recorder.isRecording) return;
try {
- await recording.stopAndUnloadAsync();
- await Audio.setAudioModeAsync({
- allowsRecordingIOS: false
+ await recorder.stop();
+ await setAudioModeAsync({
+ allowsRecording: false
});
- const uri = recording.getURI();
+ const uri = recorder.uri;
if (recordingUri) {
await deleteIfExists(recordingUri);
console.log('Deleted previous recording attempt', recordingUri);
}
console.log('Recording stopped and stored at', uri);
setRecordingUri(uri ?? null);
- setRecording(null);
- setIsRecording(false);
+ setIsRecordingActive(false);
setIsRecordingPaused(false);
if (uri) onRecordingComplete(uri);
} catch (error) {
console.error('Failed to stop recording:', error);
}
- }, [recording, recordingUri, onRecordingComplete]);
+ }, [recorder, recordingUri, onRecordingComplete]);
+ // Keep ref up to date for status listener
+ stopRecordingRef.current = stopRecording;
+
+ // Cleanup on unmount
useEffect(() => {
return () => {
- const cleanup = async () => {
- if (sound?._loaded) {
- await sound.stopAsync();
- await sound.unloadAsync();
- setSound(null);
- setIsPlaying(false);
- }
- if (!recording?._isDoneRecording) await stopRecording();
- };
- void cleanup();
+ cleanupPlayer();
+ // Recorder cleanup is handled by useAudioRecorder hook
};
- }, [recording, sound, stopRecording]);
+ }, [cleanupPlayer]);
const startRecording = async () => {
try {
if (!currentUser) return;
- if (permissionResponse?.status !== Audio.PermissionStatus.GRANTED) {
+ if (!permissionGranted) {
console.log('Requesting permission..');
- await requestPermission();
+ const { granted } = await requestRecordingPermissionsAsync();
+ setPermissionGranted(granted);
+ if (!granted) return;
}
resetRecording?.();
- await Audio.setAudioModeAsync({
- allowsRecordingIOS: true,
- playsInSilentModeIOS: true
+ await setAudioModeAsync({
+ allowsRecording: true,
+ playsInSilentMode: true
});
- // resume recording if it paused
- if (recording) {
- await recording.startAsync();
- setIsRecording(true);
+ // Resume recording if it was paused
+ if (isRecordingPaused) {
+ recorder.record();
+ setIsRecordingActive(true);
setIsRecordingPaused(false);
return;
}
console.log('recording');
- const activeRecording = (
- await Audio.Recording.createAsync(
- Audio.RecordingOptionsPresets[quality]
- )
- ).recording;
+ // Prepare and start a new recording with the selected quality
+ await recorder.prepareToRecordAsync(RecordingPresets[quality]);
+ recorder.record();
- setRecording(activeRecording);
- setIsRecording(true);
+ setIsRecordingActive(true);
setIsRecordingPaused(false);
setShowWarning(false);
- // Start monitoring recording status
- activeRecording.setOnRecordingStatusUpdate((status) => {
- if (status.isRecording) {
- const duration = status.durationMillis || 0;
- setRecordingDuration(duration);
-
- // Check if we're approaching the limit
- if (duration >= warningThreshold && !showWarning) {
- setShowWarning(true);
- }
-
- // Stop recording if we've reached the maximum duration
- if (duration >= maxDuration) {
- void stopRecording();
- }
- }
- });
} catch (error) {
console.error('Failed to start recording:', error);
}
};
const pauseRecording = async () => {
- if (!recording) return;
- await recording.pauseAsync();
- setIsRecording(false);
+ if (!recorder.isRecording) return;
+ recorder.pause();
+ setIsRecordingActive(false);
setIsRecordingPaused(true);
};
@@ -174,18 +212,41 @@ const AudioRecorder: React.FC = ({
if (!recordingUri) return;
try {
- if (sound) {
- // If sound exists, just replay it from the beginning
- if (playbackPosition === 0) await sound.setPositionAsync(0);
- await sound.playAsync();
+ if (playerRef.current) {
+ // If player exists, just replay it
+ if (playbackPosition === 0) playerRef.current.seekTo(0);
+ playerRef.current.play();
} else {
- // Only create a new sound if one doesn't exist yet
- const { sound: newSound } = await Audio.Sound.createAsync(
- { uri: recordingUri },
- { shouldPlay: true },
- onPlaybackStatusUpdate
+ // Create a new player
+ await setAudioModeAsync({
+ allowsRecording: false,
+ playsInSilentMode: true
+ });
+
+ const player = createAudioPlayer(recordingUri);
+ playerRef.current = player;
+ player.play();
+
+ // Listen for playback end
+ playerListenerRef.current = player.addListener(
+ 'playbackStatusUpdate',
+ (status) => {
+ if (!status.didJustFinish) return;
+ setIsPlaying(false);
+ setPlaybackPosition(0);
+ if (positionIntervalRef.current) {
+ clearInterval(positionIntervalRef.current);
+ positionIntervalRef.current = null;
+ }
+ }
);
- setSound(newSound);
+
+ // Track position
+ positionIntervalRef.current = setInterval(() => {
+ if (playerRef.current?.isLoaded) {
+ setPlaybackPosition(playerRef.current.currentTime * 1000);
+ }
+ }, 100);
}
setIsPlaying(true);
@@ -195,26 +256,16 @@ const AudioRecorder: React.FC = ({
};
const pausePlayback = async () => {
- if (!sound) return;
+ if (!playerRef.current) return;
try {
- await sound.pauseAsync();
+ playerRef.current.pause();
setIsPlaying(false);
} catch (error) {
console.error('Failed to pause playback:', error);
}
};
- const onPlaybackStatusUpdate = (status: AVPlaybackStatus) => {
- if (status.isLoaded) {
- setPlaybackPosition(status.positionMillis);
- if (status.didJustFinish) {
- setIsPlaying(false);
- setPlaybackPosition(0);
- }
- }
- };
-
const formatTime = (milliseconds: number): string => {
const totalSeconds = Math.floor(milliseconds / 1000);
const hours = Math.floor(totalSeconds / 3600);
@@ -237,7 +288,7 @@ const AudioRecorder: React.FC = ({
};
const getButtonConfig = (): [ButtonConfig, ButtonConfig] => {
- if (isRecording || isRecordingPaused) {
+ if (isRecordingActive || isRecordingPaused) {
return [
{
icon: isRecordingPaused ? Mic : Pause,
@@ -271,7 +322,7 @@ const AudioRecorder: React.FC = ({
{
icon: Check,
onPress: undefined,
- disabled: !recording
+ disabled: !isRecordingActive
}
];
};
diff --git a/components/AudioSegmentItem.tsx b/components/AudioSegmentItem.tsx
index 17c2df809..ee0f79013 100644
--- a/components/AudioSegmentItem.tsx
+++ b/components/AudioSegmentItem.tsx
@@ -1,5 +1,12 @@
import { colors, fontSizes, spacing } from '@/styles/theme';
-import { Ionicons } from '@expo/vector-icons';
+import { Icon } from '@/components/ui/icon';
+import {
+ ChevronUp,
+ ChevronDown,
+ Play,
+ Pause,
+ Trash2
+} from 'lucide-react-native';
import React from 'react';
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import WaveformVisualizer from './WaveformVisualizer';
@@ -48,10 +55,10 @@ const AudioSegmentItem: React.FC = ({
onPress={() => canMoveUp && onMoveUp(segment.id)}
disabled={!canMoveUp}
>
-
@@ -60,10 +67,12 @@ const AudioSegmentItem: React.FC = ({
onPress={() => canMoveDown && onMoveDown(segment.id)}
disabled={!canMoveDown}
>
-
@@ -98,10 +107,10 @@ const AudioSegmentItem: React.FC = ({
style={styles.playButton}
onPress={() => onPlay(segment.uri)}
>
-
)}
@@ -110,7 +119,7 @@ const AudioSegmentItem: React.FC = ({
style={styles.deleteButton}
onPress={() => onDelete(segment.id)}
>
-
+
diff --git a/components/Carousel.native.tsx b/components/Carousel.native.tsx
index a0e00f821..682061255 100644
--- a/components/Carousel.native.tsx
+++ b/components/Carousel.native.tsx
@@ -1,5 +1,6 @@
import { borderRadius, colors, spacing } from '@/styles/theme';
-import { Ionicons } from '@expo/vector-icons';
+import { Icon } from '@/components/ui/icon';
+import { ChevronLeft, ChevronRight } from 'lucide-react-native';
import type { ReactNode } from 'react';
import React, { useRef, useState } from 'react';
import { StyleSheet, TouchableOpacity, View } from 'react-native';
@@ -49,7 +50,7 @@ function Carousel({ items, renderItem, onPageChange }: CarouselProps) {
onPress={() => pagerRef.current?.setPage(currentPage - 1)}
disabled={currentPage === 0}
>
-
+
{items.map((_, index) => (
@@ -71,7 +72,7 @@ function Carousel({ items, renderItem, onPageChange }: CarouselProps) {
onPress={() => pagerRef.current?.setPage(currentPage + 1)}
disabled={currentPage === items.length - 1}
>
-
+
diff --git a/components/Carousel.tsx b/components/Carousel.tsx
index b18d8503c..89f0815ad 100644
--- a/components/Carousel.tsx
+++ b/components/Carousel.tsx
@@ -1,5 +1,6 @@
import { borderRadius, colors, spacing } from '@/styles/theme';
-import { Ionicons } from '@expo/vector-icons';
+import { Icon } from '@/components/ui/icon';
+import { ChevronLeft, ChevronRight } from 'lucide-react-native';
import type { ReactNode } from 'react';
import React, { useRef, useState } from 'react';
import type { NativeScrollEvent, NativeSyntheticEvent } from 'react-native';
@@ -68,7 +69,7 @@ function Carousel({ items, renderItem, onPageChange }: CarouselProps) {
onPress={() => scrollToPage(currentPage - 1)}
disabled={currentPage === 0}
>
-
+
{items.map((_, index) => (
@@ -90,7 +91,7 @@ function Carousel({ items, renderItem, onPageChange }: CarouselProps) {
onPress={() => scrollToPage(currentPage + 1)}
disabled={currentPage === items.length - 1}
>
-
+
diff --git a/components/Carousel.web.tsx b/components/Carousel.web.tsx
index b18d8503c..89f0815ad 100644
--- a/components/Carousel.web.tsx
+++ b/components/Carousel.web.tsx
@@ -1,5 +1,6 @@
import { borderRadius, colors, spacing } from '@/styles/theme';
-import { Ionicons } from '@expo/vector-icons';
+import { Icon } from '@/components/ui/icon';
+import { ChevronLeft, ChevronRight } from 'lucide-react-native';
import type { ReactNode } from 'react';
import React, { useRef, useState } from 'react';
import type { NativeScrollEvent, NativeSyntheticEvent } from 'react-native';
@@ -68,7 +69,7 @@ function Carousel({ items, renderItem, onPageChange }: CarouselProps) {
onPress={() => scrollToPage(currentPage - 1)}
disabled={currentPage === 0}
>
-
+
{items.map((_, index) => (
@@ -90,7 +91,7 @@ function Carousel({ items, renderItem, onPageChange }: CarouselProps) {
onPress={() => scrollToPage(currentPage + 1)}
disabled={currentPage === items.length - 1}
>
-
+
diff --git a/components/CustomDropdown.tsx b/components/CustomDropdown.tsx
index d5fa91c6a..a1c6a3c96 100644
--- a/components/CustomDropdown.tsx
+++ b/components/CustomDropdown.tsx
@@ -20,7 +20,7 @@ interface CustomDropdownProps {
search?: boolean;
searchPlaceholder?: string;
containerStyle?: object;
- renderLeftIcon?: (visible?: boolean) => React.ReactElement | null | undefined;
+ renderLeftIcon?: (visible?: boolean) => React.ReactElement | null;
}
export const CustomDropdown: React.FC = ({
diff --git a/components/DownloadIndicator.tsx b/components/DownloadIndicator.tsx
index 427720efd..4d9da9dbd 100644
--- a/components/DownloadIndicator.tsx
+++ b/components/DownloadIndicator.tsx
@@ -1,10 +1,11 @@
+import { Button } from '@/components/ui/button';
import { useAuth } from '@/contexts/AuthContext';
import { useNetworkStatus } from '@/hooks/useNetworkStatus';
import { storage } from '@/utils/storage';
import { cn, useThemeColor } from '@/utils/styleUtils';
import { CircleArrowDownIcon, CircleCheckIcon } from 'lucide-react-native';
import React, { useState } from 'react';
-import { ActivityIndicator, TouchableOpacity } from 'react-native';
+import { ActivityIndicator } from 'react-native';
import { DownloadConfirmationModal } from './DownloadConfirmationModal';
import { OfflineUndownloadWarning } from './OfflineUndownloadWarning';
import { Icon } from './ui/icon';
@@ -116,7 +117,9 @@ export const DownloadIndicator: React.FC = ({
return (
<>
- = ({
) : (
)}
-
+
{/* Download confirmation modal */}
{downloadType && stats && (
diff --git a/components/EnergyVADRecorder.tsx b/components/EnergyVADRecorder.tsx
index f7165df9c..0b7f56c86 100644
--- a/components/EnergyVADRecorder.tsx
+++ b/components/EnergyVADRecorder.tsx
@@ -1,15 +1,16 @@
-import { Ionicons } from '@expo/vector-icons';
-import { Audio } from 'expo-av';
+import { Icon } from '@/components/ui/icon';
+import { StopCircle, PlayCircle } from 'lucide-react-native';
+import {
+ RecordingPresets,
+ setAudioModeAsync,
+ useAudioRecorder
+} from 'expo-audio';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import RNAlert from '@blazejkustra/react-native-alert';
import { useMicrophoneEnergy } from '../hooks/useMicrophoneEnergy';
import { colors, fontSizes, spacing } from '../styles/theme';
-// Global recording state to prevent multiple concurrent recordings
-let globalRecordingInstance: Audio.Recording | null = null;
-let globalRecordingInProgress = false;
-
interface EnergyVADRecorderProps {
onRecordingComplete: (uri: string, segmentIndex: number) => void;
energyThreshold?: number;
@@ -25,7 +26,10 @@ const EnergyVADRecorder: React.FC = ({
const [currentEnergy, setCurrentEnergy] = useState(0);
const [threshold, setThreshold] = useState(energyThreshold);
const [segmentCount, setSegmentCount] = useState(0);
- const recordingRef = useRef(null);
+ // Guard against concurrent recording attempts
+ const isRecordingInProgressRef = useRef(false);
+
+ const recorder = useAudioRecorder(RecordingPresets.HIGH_QUALITY);
const {
isActive,
@@ -36,95 +40,53 @@ const EnergyVADRecorder: React.FC = ({
error
} = useMicrophoneEnergy();
- // Initialize: Clean up any orphaned recording state on mount
- useEffect(() => {
- if (globalRecordingInstance && !isRecording) {
- console.log('Cleaning up orphaned recording on mount');
- globalRecordingInstance.stopAndUnloadAsync().catch((_error) => {
- console.log('Orphaned recording already cleaned up');
- });
- globalRecordingInstance = null;
- globalRecordingInProgress = false;
- }
- }, [isRecording]); // Run only on mount
-
const startRecording = useCallback(async () => {
try {
- // Prevent starting if already recording globally or locally
- if (
- globalRecordingInProgress ||
- isRecording ||
- recordingRef.current ||
- globalRecordingInstance
- ) {
- console.log('Recording already in progress (global or local)');
+ // Prevent starting if already recording
+ if (isRecordingInProgressRef.current || isRecording) {
+ console.log('Recording already in progress');
return;
}
- console.log('Starting expo-av recording (energy-triggered)...');
- globalRecordingInProgress = true;
+ console.log('Starting recording (energy-triggered)...');
+ isRecordingInProgressRef.current = true;
// Small delay to ensure any previous recording is fully cleaned up
await new Promise((resolve) => setTimeout(resolve, 50));
- await Audio.setAudioModeAsync({
- allowsRecordingIOS: true,
- playsInSilentModeIOS: true
+ await setAudioModeAsync({
+ allowsRecording: true,
+ playsInSilentMode: true
});
- const { recording } = await Audio.Recording.createAsync(
- Audio.RecordingOptionsPresets.HIGH_QUALITY
- );
+ await recorder.prepareToRecordAsync(RecordingPresets.HIGH_QUALITY);
+ recorder.record();
- globalRecordingInstance = recording;
- recordingRef.current = recording;
setIsRecording(true);
- globalRecordingInProgress = false;
+ isRecordingInProgressRef.current = false;
} catch (error) {
console.error('Failed to start recording:', error);
- // Clean up all state on error
- globalRecordingInstance = null;
- recordingRef.current = null;
setIsRecording(false);
- globalRecordingInProgress = false;
+ isRecordingInProgressRef.current = false;
}
- }, [isRecording]);
+ }, [isRecording, recorder]);
const stopRecording = useCallback(async () => {
try {
- console.log('Stopping expo-av recording...');
-
- const recordingToStop = recordingRef.current || globalRecordingInstance;
+ console.log('Stopping recording...');
- if (!recordingToStop || !isRecording) {
+ if (!isRecording) {
console.log('No active recording to stop');
- // Clean up all state
- globalRecordingInstance = null;
- recordingRef.current = null;
setIsRecording(false);
- globalRecordingInProgress = false;
+ isRecordingInProgressRef.current = false;
return;
}
- // Check if recording is actually in progress
- const status = await recordingToStop.getStatusAsync();
- if (!status.isRecording) {
- console.log('Recording is not active, cleaning up reference');
- globalRecordingInstance = null;
- recordingRef.current = null;
- setIsRecording(false);
- globalRecordingInProgress = false;
- return;
- }
-
- await recordingToStop.stopAndUnloadAsync();
- const uri = recordingToStop.getURI();
+ await recorder.stop();
+ const uri = recorder.uri;
- // Clean up all references
- globalRecordingInstance = null;
- recordingRef.current = null;
setIsRecording(false);
- globalRecordingInProgress = false;
+ isRecordingInProgressRef.current = false;
if (uri) {
const currentSegment = segmentCount;
@@ -137,13 +99,10 @@ const EnergyVADRecorder: React.FC = ({
}
} catch (error) {
console.error('Failed to stop recording:', error);
- // Clean up all state even if stopping failed
- globalRecordingInstance = null;
- recordingRef.current = null;
setIsRecording(false);
- globalRecordingInProgress = false;
+ isRecordingInProgressRef.current = false;
}
- }, [onRecordingComplete, isRecording, segmentCount]);
+ }, [onRecordingComplete, isRecording, segmentCount, recorder]);
// Handle energy levels and control recording
useEffect(() => {
@@ -178,9 +137,7 @@ const EnergyVADRecorder: React.FC = ({
}
await stopEnergyDetection();
- // Clean up global state when stopping energy detection
- globalRecordingInstance = null;
- globalRecordingInProgress = false;
+ isRecordingInProgressRef.current = false;
setSegmentCount(0); // Reset segment count when stopping detection
} else {
await startEnergyDetection();
@@ -188,11 +145,8 @@ const EnergyVADRecorder: React.FC = ({
} catch (error) {
RNAlert.alert('Error', 'Failed to toggle energy detection');
console.error('Energy detection toggle error:', error);
- // Clean up state on error
- globalRecordingInstance = null;
- recordingRef.current = null;
setIsRecording(false);
- globalRecordingInProgress = false;
+ isRecordingInProgressRef.current = false;
}
}, [
isActive,
@@ -202,23 +156,10 @@ const EnergyVADRecorder: React.FC = ({
startEnergyDetection
]);
- // Cleanup on unmount
+ // Cleanup on unmount (recorder lifecycle handled by useAudioRecorder hook)
useEffect(() => {
return () => {
- // Clean up recording if it exists (check both local and global)
- const recordingToCleanup =
- recordingRef.current || globalRecordingInstance;
- if (recordingToCleanup) {
- recordingToCleanup.stopAndUnloadAsync().catch((_error) => {
- console.log('Cleanup: Recording already stopped or released');
- });
- }
-
- // Reset all recording state
- globalRecordingInstance = null;
- recordingRef.current = null;
- setIsRecording(false);
- globalRecordingInProgress = false;
+ isRecordingInProgressRef.current = false;
};
}, []);
@@ -231,10 +172,10 @@ const EnergyVADRecorder: React.FC = ({
style={[styles.vadButton, isActive && styles.vadButtonActive]}
onPress={handleToggleEnergyDetection}
>
-
{isActive ? 'Stop Energy Detection' : 'Start Energy Detection'}
diff --git a/components/FloatingMenu.tsx b/components/FloatingMenu.tsx
index 53a161057..151311618 100644
--- a/components/FloatingMenu.tsx
+++ b/components/FloatingMenu.tsx
@@ -1,10 +1,12 @@
import { colors } from '@/styles/theme';
-import { Ionicons } from '@expo/vector-icons';
+import { Icon } from '@/components/ui/icon';
+import type { LucideIcon } from 'lucide-react-native';
+import { Menu, X } from 'lucide-react-native';
import { useEffect, useRef, useState } from 'react';
import { Animated, StyleSheet, TouchableOpacity, View } from 'react-native';
export interface FloatingMenuItem {
- icon: keyof typeof Ionicons.glyphMap;
+ icon: LucideIcon;
label: string;
action: () => void;
}
@@ -164,11 +166,7 @@ export const FloatingMenu = ({
activeOpacity={0.8}
>
-
+
@@ -176,6 +174,7 @@ export const FloatingMenu = ({
};
const ItemButton = ({ icon, action }: FloatingMenuItem) => {
+ const IconComponent = icon;
return (
{
activeOpacity={0.7}
>
-
+
);
};
export function createMenuItem(
- iconString: string,
+ icon: LucideIcon,
label: string,
onClick: () => void
) {
return {
- icon: iconString as keyof typeof Ionicons.glyphMap,
+ icon,
label,
action: () => {
onClick();
diff --git a/components/LanguageSelect.tsx b/components/LanguageSelect.tsx
index 2c1de0a37..799548f82 100644
--- a/components/LanguageSelect.tsx
+++ b/components/LanguageSelect.tsx
@@ -6,7 +6,8 @@ import {
import { useLocalization } from '@/hooks/useLocalization';
import { useLocalStore } from '@/store/localStore';
import { colors, spacing } from '@/styles/theme';
-import { Ionicons } from '@expo/vector-icons';
+import { Icon } from '@/components/ui/icon';
+import { Languages } from 'lucide-react-native';
import {
memo,
default as React,
@@ -88,10 +89,10 @@ const LanguageSelect: React.FC = memo(
const renderLeftIcon = useCallback(
() => (
-
),
diff --git a/components/MiniAudioPlayer.tsx b/components/MiniAudioPlayer.tsx
index 59f18beab..1cf98af53 100644
--- a/components/MiniAudioPlayer.tsx
+++ b/components/MiniAudioPlayer.tsx
@@ -1,17 +1,12 @@
+import { Button } from '@/components/ui/button';
import { Icon } from '@/components/ui/icon';
import { Text } from '@/components/ui/text';
import { useAudio } from '@/contexts/AudioContext';
-import { colors, spacing } from '@/styles/theme';
-import { getThemeColor } from '@/utils/styleUtils';
-import { Ionicons } from '@expo/vector-icons';
-import { SparklesIcon } from 'lucide-react-native';
+import { spacing } from '@/styles/theme';
+import { getThemeColor, useThemeColor } from '@/utils/styleUtils';
+import { Play, Pause, SparklesIcon } from 'lucide-react-native';
import React from 'react';
-import {
- ActivityIndicator,
- StyleSheet,
- TouchableOpacity,
- View
-} from 'react-native';
+import { ActivityIndicator, StyleSheet, View } from 'react-native';
interface MiniAudioPlayerProps {
id: string;
@@ -35,6 +30,7 @@ export default function MiniAudioPlayer({
isPlaying,
currentAudioId
} = useAudio();
+ const primaryColor = useThemeColor('primary');
const handlePlayPause = async () => {
if (isPlaying && currentAudioId === id) {
@@ -60,17 +56,22 @@ export default function MiniAudioPlayer({
return (
-
-
+
-
+
{onTranscribe && (
-
{isTranscribing ? (
@@ -80,11 +81,15 @@ export default function MiniAudioPlayer({
/>
) : (
-
- Aa
+
+ Aa
)}
-
+
)}
);
@@ -102,14 +107,12 @@ const styles = StyleSheet.create({
width: 44,
height: 44,
borderRadius: 22,
- backgroundColor: colors.primary,
alignItems: 'center',
justifyContent: 'center'
},
transcribePill: {
height: 32,
borderRadius: 16,
- backgroundColor: colors.primary,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 12
diff --git a/components/NewReportModal.tsx b/components/NewReportModal.tsx
index 4c2e04647..455156213 100644
--- a/components/NewReportModal.tsx
+++ b/components/NewReportModal.tsx
@@ -92,6 +92,7 @@ export const ReportModal: React.FC = ({
{ text: t('cancel'), style: 'cancel' },
{
text: t('signIn') || 'Sign In',
+ isPreferred: true,
onPress: () => {
onClose();
setAuthView('sign-in');
@@ -278,9 +279,7 @@ export const ReportModal: React.FC = ({
-
-
-
+
);
};
diff --git a/components/PasswordInput.tsx b/components/PasswordInput.tsx
index 3ed456566..d9d34f8a9 100644
--- a/components/PasswordInput.tsx
+++ b/components/PasswordInput.tsx
@@ -1,7 +1,9 @@
-import { Ionicons } from '@expo/vector-icons';
+import { Button } from '@/components/ui/button';
+import { Icon } from '@/components/ui/icon';
+import { Eye, EyeOff } from 'lucide-react-native';
import React, { useState } from 'react';
import type { StyleProp, ViewStyle } from 'react-native';
-import { StyleSheet, TextInput, TouchableOpacity, View } from 'react-native';
+import { StyleSheet, TextInput, View } from 'react-native';
interface PasswordInputProps {
value: string;
@@ -30,20 +32,20 @@ export const PasswordInput = ({
style={[styles.input, { color: style?.color }]}
placeholderTextColor={placeholderTextColor}
/>
- {
setShowPassword(!showPassword);
}}
- activeOpacity={1}
style={styles.icon}
>
-
-
+
);
};
diff --git a/components/PrivateAccessGate.tsx b/components/PrivateAccessGate.tsx
index e87f6ca7d..4e54cd83e 100644
--- a/components/PrivateAccessGate.tsx
+++ b/components/PrivateAccessGate.tsx
@@ -325,6 +325,7 @@ export const PrivateAccessGate: React.FC = ({
{
text: t('confirm'),
style: 'destructive',
+ isPreferred: true,
onPress: () => {
void (async () => {
setIsSubmitting(true);
diff --git a/components/ProjectDetails.tsx b/components/ProjectDetails.tsx
index 60a4a1731..e1b842881 100644
--- a/components/ProjectDetails.tsx
+++ b/components/ProjectDetails.tsx
@@ -3,7 +3,8 @@ import { languoid, project_language_link } from '@/db/drizzleSchema';
import { system } from '@/db/powersync/system';
import { borderRadius, colors, fontSizes, spacing } from '@/styles/theme';
import { useHybridData } from '@/views/new/useHybridData';
-import { Ionicons } from '@expo/vector-icons';
+import { Icon } from '@/components/ui/icon';
+import { Languages, Info } from 'lucide-react-native';
import { toCompilableQuery } from '@powersync/drizzle-driver';
import { and, eq } from 'drizzle-orm';
import { default as React } from 'react';
@@ -110,7 +111,7 @@ export const ProjectDetails: React.FC = ({
{project.name}
-
+
{sourceLanguoids.length
? sourceLanguoids
@@ -124,11 +125,7 @@ export const ProjectDetails: React.FC = ({
{project.description && (
-
+
{project.description}
)}
diff --git a/components/ProjectMembershipModal.tsx b/components/ProjectMembershipModal.tsx
index 6544699b2..7b8c08cb1 100644
--- a/components/ProjectMembershipModal.tsx
+++ b/components/ProjectMembershipModal.tsx
@@ -356,6 +356,7 @@ export const ProjectMembershipModal: React.FC = ({
{
text: t('remove'),
style: 'destructive',
+ isPreferred: true,
onPress: () => {
void (async () => {
try {
@@ -392,6 +393,7 @@ export const ProjectMembershipModal: React.FC = ({
{ text: t('cancel'), style: 'cancel' },
{
text: t('confirm'),
+ isPreferred: true,
onPress: () => {
void (async () => {
try {
@@ -434,6 +436,7 @@ export const ProjectMembershipModal: React.FC = ({
{
text: t('confirm'),
style: 'destructive',
+ isPreferred: true,
onPress: () => {
void (async () => {
try {
@@ -545,6 +548,7 @@ export const ProjectMembershipModal: React.FC = ({
{ text: t('cancel'), style: 'cancel' },
{
text: t('confirm'),
+ isPreferred: true,
onPress: () => {
void (async () => {
setIsSubmitting(true);
@@ -617,6 +621,7 @@ export const ProjectMembershipModal: React.FC = ({
{
text: t('confirm'),
style: 'destructive',
+ isPreferred: true,
onPress: () => {
void (async () => {
setIsSubmitting(true);
diff --git a/components/QuestFilterModal.tsx b/components/QuestFilterModal.tsx
index 1569c3b6d..4e4927ae3 100644
--- a/components/QuestFilterModal.tsx
+++ b/components/QuestFilterModal.tsx
@@ -11,7 +11,17 @@ import {
sharedStyles,
spacing
} from '@/styles/theme';
-import { Ionicons } from '@expo/vector-icons';
+import { Icon } from '@/components/ui/icon';
+import {
+ ChevronUp,
+ ChevronDown,
+ CheckCircle2,
+ Filter,
+ ArrowUpDown,
+ ArrowUp,
+ ArrowDown,
+ Trash2
+} from 'lucide-react-native';
import React, { useEffect, useMemo, useState } from 'react';
import {
ScrollView,
@@ -88,10 +98,10 @@ const CategorySection: React.FC<{
{category}
-
@@ -106,11 +116,7 @@ const CategorySection: React.FC<{
{option.label}
{selectedOptions.includes(option.id) ? (
-
+
) : (
)}
@@ -242,11 +248,13 @@ export const QuestFilterModal: React.FC = ({
onPress={() => setActiveTab('filter')}
>
-
{getActiveFiltersCount() > 0 && (
@@ -263,10 +271,12 @@ export const QuestFilterModal: React.FC = ({
onPress={() => setActiveTab('sort')}
>
-
{getActiveSortingCount() > 0 && (
@@ -333,14 +343,14 @@ export const QuestFilterModal: React.FC = ({
)
}
>
-
{sortingOptions[index]?.field && (
@@ -348,10 +358,10 @@ export const QuestFilterModal: React.FC = ({
style={styles.removeButton}
onPress={() => handleSortingChange(index, null)}
>
-
)}
diff --git a/components/RadioSelect.tsx b/components/RadioSelect.tsx
index 1c406b0f8..7066db4ac 100644
--- a/components/RadioSelect.tsx
+++ b/components/RadioSelect.tsx
@@ -1,6 +1,7 @@
+import { Button } from '@/components/ui/button';
import { colors } from '@/styles/theme';
import React from 'react';
-import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
+import { StyleSheet, Text, View } from 'react-native';
interface Option {
label: string;
@@ -29,7 +30,8 @@ export default function RadioSelect({
{options.map((option) => {
const selected = value === option.value;
return (
- onChange(option.value)}
@@ -44,7 +46,7 @@ export default function RadioSelect({
{option.label}
-
+
);
})}
diff --git a/components/TranscriptionEditModal.tsx b/components/TranscriptionEditModal.tsx
index 19648ba28..d444262e6 100644
--- a/components/TranscriptionEditModal.tsx
+++ b/components/TranscriptionEditModal.tsx
@@ -13,11 +13,11 @@ import {
KeyboardAvoidingView,
Modal,
Platform,
- SafeAreaView,
TextInput,
TouchableOpacity,
View
} from 'react-native';
+import { SafeAreaView } from 'react-native-safe-area-context';
interface TranscriptionEditModalProps {
visible: boolean;
@@ -132,7 +132,12 @@ export default function TranscriptionEditModal({
'You have unsaved changes. Are you sure you want to close?',
[
{ text: t('cancel') || 'Cancel', style: 'cancel' },
- { text: 'Discard', style: 'destructive', onPress: onClose }
+ {
+ text: 'Discard',
+ style: 'destructive',
+ isPreferred: true,
+ onPress: onClose
+ }
]
);
} else {
diff --git a/components/TranslationCard.tsx b/components/TranslationCard.tsx
index 913732631..238722452 100644
--- a/components/TranslationCard.tsx
+++ b/components/TranslationCard.tsx
@@ -9,7 +9,8 @@ import type { WithSource } from '@/utils/dbUtils';
import { SHOW_DEV_ELEMENTS } from '@/utils/featureFlags';
import { cn } from '@/utils/styleUtils';
import { ThumbsDownIcon, ThumbsUpIcon } from 'lucide-react-native';
-import { TouchableOpacity, View } from 'react-native';
+import { View } from 'react-native';
+import { Button } from './ui/button';
import AudioPlayer from './AudioPlayer';
interface TranslationCardProps {
@@ -46,7 +47,7 @@ export const TranslationCard = ({
};
return (
-
+
-
+
);
};
diff --git a/components/TranslationSettingsModal.tsx b/components/TranslationSettingsModal.tsx
index 6fc4a9180..dd7709ae8 100644
--- a/components/TranslationSettingsModal.tsx
+++ b/components/TranslationSettingsModal.tsx
@@ -12,7 +12,8 @@ import {
sharedStyles,
spacing
} from '@/styles/theme';
-import { Ionicons } from '@expo/vector-icons';
+import { Icon } from '@/components/ui/icon';
+import { X } from 'lucide-react-native';
import React, { useState } from 'react';
import {
Modal,
@@ -122,7 +123,7 @@ export const TranslationSettingsModal: React.FC<
{'Translation Settings'}
-
+
diff --git a/components/UpdateBanner.tsx b/components/UpdateBanner.tsx
index 41ff044eb..eed35a616 100644
--- a/components/UpdateBanner.tsx
+++ b/components/UpdateBanner.tsx
@@ -5,7 +5,7 @@ import { useExpoUpdates } from '@/hooks/useExpoUpdates';
import { useLocalization } from '@/hooks/useLocalization';
import { CloudDownload, XIcon } from 'lucide-react-native';
import React from 'react';
-import { ActivityIndicator, TouchableOpacity, View } from 'react-native';
+import { ActivityIndicator, View } from 'react-native';
// DEV ONLY: Import mock for testing
// To test OTA updates in development, uncomment the next 2 lines:
@@ -71,13 +71,14 @@ export function UpdateBanner() {
)}
-
-
+
);
diff --git a/components/VoteCommentModal.tsx b/components/VoteCommentModal.tsx
index 841d52c70..940a55f6e 100644
--- a/components/VoteCommentModal.tsx
+++ b/components/VoteCommentModal.tsx
@@ -1,7 +1,8 @@
import { useAuth } from '@/contexts/AuthContext';
import { useLocalization } from '@/hooks/useLocalization';
import { borderRadius, colors, fontSizes, spacing } from '@/styles/theme';
-import { Ionicons } from '@expo/vector-icons';
+import { Icon } from '@/components/ui/icon';
+import { ThumbsUp, ThumbsDown } from 'lucide-react-native';
import React, { useState } from 'react';
import {
Modal,
@@ -68,10 +69,10 @@ export const VoteCommentModal: React.FC = ({
e.stopPropagation()}>
-
= ({
const haptic = useHaptic('medium');
const { currentUser: _currentUser } = useAuth();
const { t } = useLocalization();
- const [recording, setRecording] = useState(null);
+ const [hasActiveRecording, setHasActiveRecording] = useState(false);
const [recordingDuration, setRecordingDuration] = useState(0);
// Permission check removed - handled by parent RecordingControls via canRecord prop
const isActivatingRef = useRef(false);
@@ -89,6 +95,58 @@ const WalkieTalkieRecorder: React.FC = ({
// Cancellation flag - set when user releases during async setup
const shouldCancelRecordingRef = useRef(false);
+ // Energy range tracking for logging
+ const energyRangeRef = useRef({ min: Infinity, max: -Infinity });
+
+ // Audio recorder from expo-audio (manages lifecycle automatically)
+ const recorder = useAudioRecorder(RecordingPresets.HIGH_QUALITY);
+
+ // Poll recording state for duration and metering data
+ const recorderState = useAudioRecorderState(recorder, 50);
+
+ // Previous duration ref to avoid duplicate processing
+ const lastProcessedDurationRef = useRef(0);
+
+ // React to recorder state changes for duration tracking and metering
+ useEffect(() => {
+ if (!recorderState.isRecording) return;
+
+ const duration = recorderState.durationMillis || 0;
+
+ // Skip if we already processed this duration (avoid duplicate work)
+ if (duration === lastProcessedDurationRef.current) return;
+ lastProcessedDurationRef.current = duration;
+
+ setRecordingDuration(duration);
+ // Notify parent of duration updates for progress bar
+ onRecordingDurationUpdate?.(duration);
+
+ let amplitude: number;
+ if (typeof recorderState.metering === 'number') {
+ const db = recorderState.metering;
+ const normalizedDb = Math.max(-60, Math.min(0, db));
+ amplitude = Math.pow(10, normalizedDb / 20);
+ appendLiveSample(amplitude);
+ } else {
+ const t = duration / 1000;
+ const base = 0.3 + Math.sin(t * 24) * 0.15;
+ const noise = (Math.random() - 0.5) * 0.1;
+ amplitude = Math.max(0.02, Math.min(0.8, base + noise));
+ appendLiveSample(amplitude);
+ }
+
+ // Track energy range
+ energyRangeRef.current.min = Math.min(
+ energyRangeRef.current.min,
+ amplitude
+ );
+ energyRangeRef.current.max = Math.max(
+ energyRangeRef.current.max,
+ amplitude
+ );
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [recorderState]);
+
// ============================================================================
// BUSY LOCK - Prevents race conditions during state transitions
// ============================================================================
@@ -185,23 +243,18 @@ const WalkieTalkieRecorder: React.FC = ({
}
};
- // Cleanup recording and timers on unmount
+ // Cleanup timers on unmount (recorder cleanup handled by useAudioRecorder hook)
useEffect(() => {
return () => {
- const cleanup = async () => {
- if (activationTimer.current) {
- clearTimeout(activationTimer.current);
- }
- if (releaseDelayTimer.current) {
- clearTimeout(releaseDelayTimer.current);
- }
- if (recording && !recording._isDoneRecording) {
- await recording.stopAndUnloadAsync();
- }
- };
- void cleanup();
+ if (activationTimer.current) {
+ clearTimeout(activationTimer.current);
+ }
+ if (releaseDelayTimer.current) {
+ clearTimeout(releaseDelayTimer.current);
+ }
+ // Recorder cleanup is handled automatically by useAudioRecorder hook
};
- }, [recording]);
+ }, []);
// Pulse animation when recording
useEffect(() => {
@@ -249,18 +302,6 @@ const WalkieTalkieRecorder: React.FC = ({
requestAnimationFrame(() => resolve(undefined))
);
- const startTime = performance.now();
-
- // Clean up any existing recording first
- if (recording) {
- try {
- await recording.stopAndUnloadAsync();
- } catch {
- // Ignore cleanup errors
- }
- setRecording(null);
- }
-
setRecordedSamples([]);
// Permission check removed - parent RecordingControls ensures canRecord=true
@@ -269,18 +310,19 @@ const WalkieTalkieRecorder: React.FC = ({
// Reset cancellation flag at start
shouldCancelRecordingRef.current = false;
- // ✅ Notify parent IMMEDIATELY - we're in "recording mode" now
+ // Notify parent IMMEDIATELY - we're in "recording mode" now
// This ensures isRecording is true synchronously, preventing race conditions
// where user releases before async setup completes
onRecordingStart();
// Heavy operations - but user already sees feedback
- await Audio.setAudioModeAsync({
- allowsRecordingIOS: true,
- playsInSilentModeIOS: true
+ await setAudioModeAsync({
+ allowsRecording: true,
+ playsInSilentMode: true
});
- const highQuality = Audio.RecordingOptionsPresets.HIGH_QUALITY;
+ // Prepare and start recording with metering-enabled options
+ const highQuality = RecordingPresets.HIGH_QUALITY;
const options = {
...highQuality,
ios: {
@@ -291,59 +333,24 @@ const WalkieTalkieRecorder: React.FC = ({
...(highQuality?.android ?? {}),
isMeteringEnabled: true
}
- } as typeof highQuality;
+ };
- const result = await Audio.Recording.createAsync(options);
- const activeRecording = result.recording;
- activeRecording.setProgressUpdateInterval(9);
+ await recorder.prepareToRecordAsync(options);
// Check if we were cancelled during async setup (user released early)
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (shouldCancelRecordingRef.current) {
- await activeRecording.stopAndUnloadAsync();
shouldCancelRecordingRef.current = false;
return;
}
- const _duration = performance.now() - startTime;
+ recorder.record();
- setRecording(activeRecording);
+ setHasActiveRecording(true);
setRecordingDuration(0);
- // Track energy range for logging
- const energyRange = { min: Infinity, max: -Infinity };
-
- // Set up status monitoring
- activeRecording.setOnRecordingStatusUpdate((status) => {
- if (status.isRecording) {
- const duration = status.durationMillis || 0;
- setRecordingDuration(duration);
- // Notify parent of duration updates for progress bar
- onRecordingDurationUpdate?.(duration);
-
- const anyStatus = status as unknown as { metering?: number };
- let amplitude: number;
- if (typeof anyStatus.metering === 'number') {
- const db = anyStatus.metering;
- const normalizedDb = Math.max(-60, Math.min(0, db));
- amplitude = Math.pow(10, normalizedDb / 20);
- appendLiveSample(amplitude);
- } else {
- const t = duration / 1000;
- const base = 0.3 + Math.sin(t * 24) * 0.15;
- const noise = (Math.random() - 0.5) * 0.1;
- amplitude = Math.max(0.02, Math.min(0.8, base + noise));
- appendLiveSample(amplitude);
- }
-
- // Track energy range
- energyRange.min = Math.min(energyRange.min, amplitude);
- energyRange.max = Math.max(energyRange.max, amplitude);
- }
- });
-
- // Store energy range ref for logging on stop
- (activeRecording as RecordingWithEnergyRange)._energyRange = energyRange;
+ // Reset energy range for this recording
+ energyRangeRef.current = { min: Infinity, max: -Infinity };
} catch (error) {
console.error('❌ Failed to start recording:', error);
onRecordingStop(); // Clean up
@@ -351,8 +358,8 @@ const WalkieTalkieRecorder: React.FC = ({
};
const stopRecording = async () => {
- if (!recording) {
- // Recording object not created yet (user released during async setup)
+ if (!hasActiveRecording) {
+ // Recording not started yet (user released during async setup)
// Set cancellation flag so startRecording() knows to abort
shouldCancelRecordingRef.current = true;
onRecordingStop();
@@ -360,16 +367,8 @@ const WalkieTalkieRecorder: React.FC = ({
}
try {
- const status = await recording.getStatusAsync().catch(() => null);
- if (!status) {
- console.warn('⚠️ Recording no longer exists, skipping stop');
- setRecording(null);
- onRecordingStop();
- return;
- }
-
- await recording.stopAndUnloadAsync();
- const uri = recording.getURI();
+ await recorder.stop();
+ const uri = recorder.uri;
if (uri) {
if (recordingDuration >= MIN_RECORDING_DURATION) {
@@ -380,14 +379,14 @@ const WalkieTalkieRecorder: React.FC = ({
}
}
- setRecording(null);
+ setHasActiveRecording(false);
setRecordingDuration(0);
setRecordedSamples([]);
onRecordingStop();
} catch (error) {
console.error('Failed to stop recording:', error);
- setRecording(null);
+ setHasActiveRecording(false);
setRecordingDuration(0);
setRecordedSamples([]);
onRecordingStop();
diff --git a/components/navigation/TabBarIcon.tsx b/components/navigation/TabBarIcon.tsx
index a4892dacd..c14ecbe02 100644
--- a/components/navigation/TabBarIcon.tsx
+++ b/components/navigation/TabBarIcon.tsx
@@ -1,12 +1,21 @@
-// You can explore the built-in icon families and icons on the web at https://icons.expo.fyi/
+// You can explore Lucide icons at https://lucide.dev/
-import Ionicons from '@expo/vector-icons/Ionicons';
-import type { IconProps } from '@expo/vector-icons/build/createIconSet';
-import type { ComponentProps } from 'react';
+import { Icon } from '@/components/ui/icon';
+import type { LucideIcon, LucideProps } from 'lucide-react-native';
+import type { StyleProp, ViewStyle } from 'react-native';
export function TabBarIcon({
+ as,
style,
...rest
-}: IconProps['name']>) {
- return ;
+}: { as: LucideIcon; style?: StyleProp } & LucideProps) {
+ return (
+
+ );
}
diff --git a/components/questsComponents/QuestItem.tsx b/components/questsComponents/QuestItem.tsx
index 3a9919536..25f29ed14 100644
--- a/components/questsComponents/QuestItem.tsx
+++ b/components/questsComponents/QuestItem.tsx
@@ -1,9 +1,9 @@
import { QuestCard } from '@/components/QuestCard';
+import { Button } from '@/components/ui/button';
import type { Project } from '@/database_services/projectService';
import type { Quest } from '@/database_services/questService';
import type { Tag } from '@/database_services/tagService';
import React, { useCallback } from 'react';
-import { TouchableOpacity } from 'react-native';
// Memoized quest item component for better performance
@@ -24,9 +24,9 @@ export const QuestItem = React.memo(
if (!project) return null;
return (
-
+
-
+
);
}
);
diff --git a/components/ui/button.tsx b/components/ui/button.tsx
index 6a298e70b..9e5b2fcc8 100644
--- a/components/ui/button.tsx
+++ b/components/ui/button.tsx
@@ -1,25 +1,35 @@
import { TextClassContext } from '@/components/ui/text';
+import { easeButton, easeOut } from '@/constants/animations';
import { cn, useThemeColor } from '@/utils/styleUtils';
import type { VariantProps } from 'class-variance-authority';
import { cva } from 'class-variance-authority';
-import type { BaseSyntheticEvent } from 'react';
import * as React from 'react';
-import { ActivityIndicator, Pressable, TouchableOpacity } from 'react-native';
+import type { GestureResponderEvent, Pressable } from 'react-native';
+import { ActivityIndicator, Pressable as RNPressable } from 'react-native';
+import Animated, {
+ useAnimatedReaction,
+ useAnimatedStyle,
+ useSharedValue,
+ withTiming
+} from 'react-native-reanimated';
import * as Slot from './slot';
+const AnimatedPressable = Animated.createAnimatedComponent(RNPressable);
+
const buttonVariants = cva(
- 'group flex items-center justify-center rounded-md web:ring-offset-background web:transition-[transform,color] web:focus-visible:outline-none web:focus-visible:ring-2 web:focus-visible:ring-ring web:focus-visible:ring-offset-2',
+ 'group flex items-center justify-center rounded-md web:ring-offset-background web:transition-[color] web:focus-visible:outline-none web:focus-visible:ring-2 web:focus-visible:ring-ring web:focus-visible:ring-offset-2',
{
variants: {
variant: {
- default: 'bg-primary active:opacity-90 web:hover:opacity-90',
- destructive: 'bg-destructive active:opacity-90 web:hover:opacity-90',
+ default: 'bg-primary web:hover:opacity-90',
+ destructive: 'bg-destructive web:hover:opacity-90',
outline:
'border border-input bg-background active:bg-accent web:hover:bg-accent web:hover:text-accent-foreground',
- secondary: 'bg-secondary active:opacity-80 web:hover:opacity-80',
+ secondary: 'bg-secondary web:hover:opacity-80',
ghost:
'active:bg-accent web:hover:bg-accent web:hover:text-accent-foreground',
- link: 'active:scale-100 web:underline-offset-4 web:hover:underline web:focus:underline'
+ link: 'web:underline-offset-4 web:hover:underline web:focus:underline',
+ plain: ''
},
size: {
sm: 'h-10 rounded-md px-3',
@@ -29,7 +39,8 @@ const buttonVariants = cva(
icon: 'size-10',
'icon-lg': 'size-12',
'icon-xl': 'size-14',
- 'icon-2xl': 'size-16'
+ 'icon-2xl': 'size-16',
+ auto: ''
}
},
defaultVariants: {
@@ -50,7 +61,8 @@ const buttonTextVariants = cva(
secondary:
'text-secondary-foreground group-active:text-secondary-foreground',
ghost: 'group-active:text-accent-foreground',
- link: 'text-primary group-active:underline'
+ link: 'text-primary group-active:underline',
+ plain: 'text-primary'
},
size: {
default: '',
@@ -60,7 +72,8 @@ const buttonTextVariants = cva(
icon: '',
'icon-lg': '',
'icon-xl': '',
- 'icon-2xl': ''
+ 'icon-2xl': '',
+ auto: ''
}
},
defaultVariants: {
@@ -70,18 +83,174 @@ const buttonTextVariants = cva(
}
);
-const ButtonPressable = Pressable;
+type ButtonPressableComponentProps = React.ComponentPropsWithoutRef<
+ typeof RNPressable
+> & {
+ ref?: React.ComponentRef;
+ disabled?: boolean;
+ className?: string;
+};
+
+/**
+ * ButtonPressable - A Pressable component with built-in scale animation on press
+ * Handles scale animation on press (0.97 scale) with smooth transitions
+ * Uses react-native-reanimated on all platforms (native and web)
+ */
+const ButtonPressable = React.forwardRef<
+ React.ComponentRef,
+ ButtonPressableComponentProps
+>(({ disabled, onPressIn, onPressOut, style, className, ...props }, ref) => {
+ const scale = useSharedValue(1);
+ const opacity = useSharedValue(disabled ? 0.5 : 1);
+
+ useAnimatedReaction(
+ () => disabled,
+ (isDisabled) => {
+ opacity.set(
+ withTiming(isDisabled ? 0.5 : 1, {
+ duration: 160,
+ easing: easeButton
+ })
+ );
+ }
+ );
+
+ const animatedStyle = useAnimatedStyle(() => ({
+ transform: [{ scale: scale.get() }],
+ opacity: opacity.get()
+ }));
+
+ return (
+ {
+ if (!disabled) {
+ scale.set(
+ withTiming(0.97, {
+ duration: 160,
+ easing: easeButton
+ })
+ );
+ opacity.set(
+ withTiming(0.9, {
+ duration: 160,
+ easing: easeButton
+ })
+ );
+ }
+ onPressIn?.(e);
+ }}
+ onPressOut={(e: GestureResponderEvent) => {
+ if (!disabled) {
+ scale.set(
+ withTiming(1, {
+ duration: 160,
+ easing: easeButton
+ })
+ );
+ opacity.set(
+ withTiming(1, {
+ duration: 160,
+ easing: easeButton
+ })
+ );
+ }
+ onPressOut?.(e);
+ }}
+ style={[animatedStyle, style]}
+ className={className}
+ {...props}
+ />
+ );
+});
-const ButtonPressableOpacity = TouchableOpacity;
+ButtonPressable.displayName = 'ButtonPressable';
-type ButtonPressableProps = Omit<
- React.ComponentPropsWithoutRef,
- 'onPress'
+type PressableOpacityProps = React.ComponentPropsWithoutRef<
+ typeof RNPressable
> & {
+ ref?: React.ComponentRef;
+ disabled?: boolean;
+ activeOpacity?: number;
+ className?: string;
+};
+
+/**
+ * PressableOpacity - A Pressable component with opacity animation on press
+ * Similar to TouchableOpacity but uses react-native-reanimated for better performance
+ * Uses opacity animation (default 0.7) on press with smooth transitions
+ */
+const OpacityPressable = React.forwardRef<
+ React.ComponentRef,
+ PressableOpacityProps
+>(
+ (
+ {
+ disabled,
+ activeOpacity = 0.4, // official TouchableOpacity default is 0.2
+ onPressIn,
+ onPressOut,
+ style,
+ className,
+ ...props
+ },
+ ref
+ ) => {
+ const opacity = useSharedValue(disabled ? 0.5 : 1);
+
+ useAnimatedReaction(
+ () => disabled,
+ (isDisabled) => {
+ opacity.set(
+ withTiming(isDisabled ? 0.5 : 1, {
+ duration: 160,
+ easing: easeOut
+ })
+ );
+ }
+ );
+
+ const animatedStyle = useAnimatedStyle(() => ({
+ opacity: opacity.get()
+ }));
+
+ return (
+ {
+ opacity.set(
+ withTiming(activeOpacity, {
+ duration: 160,
+ easing: easeOut
+ })
+ );
+ onPressIn?.(e);
+ }}
+ onPressOut={(e: GestureResponderEvent) => {
+ opacity.set(
+ withTiming(1, {
+ duration: 160,
+ easing: easeOut
+ })
+ );
+ onPressOut?.(e);
+ }}
+ style={[animatedStyle, style]}
+ className={className}
+ {...props}
+ />
+ );
+ }
+);
+
+OpacityPressable.displayName = 'OpacityPressable';
+
+type ButtonPressableProps = React.ComponentPropsWithoutRef & {
ref?: React.ComponentRef;
role?: string;
disabled?: boolean;
- onPress?: (e?: BaseSyntheticEvent) => void | Promise;
};
type ButtonProps = ButtonPressableProps &
@@ -104,6 +273,8 @@ const Button = React.forwardRef<
loading,
disabled,
asChild,
+ onPressIn: _onPressIn,
+ onPressOut: _onPressOut,
...props
}: ButtonProps,
ref
@@ -119,7 +290,8 @@ const Button = React.forwardRef<
secondary: secondaryForeground,
outline: accentForeground,
ghost: accentForeground,
- link: primaryForeground
+ link: primaryForeground,
+ plain: primaryForeground
} as const;
const isDisabled = disabled || loading;
@@ -140,34 +312,35 @@ const Button = React.forwardRef<
>
);
- const commonProps = {
- className: cn(
- 'flex flex-row items-center gap-2',
- isDisabled && 'opacity-50 web:pointer-events-none web:cursor-default',
- buttonVariants({ variant, size, className })
- ),
- role: 'button' as const,
- disabled: isDisabled,
- ...props
- };
+ // Use PressableOpacity for link and plain variants (subtle opacity animation)
+ // Use ButtonPressable for other variants (scale animation)
+ const Component = asChild
+ ? Slot.Pressable
+ : variant === 'link' || variant === 'plain'
+ ? OpacityPressable
+ : ButtonPressable;
return (
- {asChild ? (
-
- {content}
-
- ) : (
-
- {content}
-
- )}
+
+ {content}
+
);
}
@@ -178,8 +351,8 @@ Button.displayName = 'Button';
export {
Button,
ButtonPressable,
- ButtonPressableOpacity,
buttonTextVariants,
- buttonVariants
+ buttonVariants,
+ OpacityPressable
};
export type { ButtonProps };
diff --git a/components/ui/checkbox.tsx b/components/ui/checkbox.tsx
index 59264b75f..cb041c413 100644
--- a/components/ui/checkbox.tsx
+++ b/components/ui/checkbox.tsx
@@ -11,12 +11,14 @@ function Checkbox({
checkedClassName,
indicatorClassName,
iconClassName,
+ testID = 'checkbox',
...props
}: CheckboxPrimitive.RootProps &
React.RefAttributes & {
checkedClassName?: string;
indicatorClassName?: string;
iconClassName?: string;
+ testID?: string;
}) {
return (
| null;
@@ -234,6 +233,10 @@ const DrawerContent = React.forwardRef<
return (
(
(
- KeyboardAwareScrollView
- );
+const AnimatedScrollView = Animated.createAnimatedComponent(
+ KeyboardAwareScrollView
+);
const BottomSheetScrollViewComponent = createBottomSheetScrollableComponent<
BottomSheetScrollViewMethods,
BottomSheetScrollViewProps
diff --git a/components/ui/text.tsx b/components/ui/text.tsx
index c2411d9a1..b6208cb36 100644
--- a/components/ui/text.tsx
+++ b/components/ui/text.tsx
@@ -45,7 +45,7 @@ const textVariants = cva(
}
},
defaultVariants: {
- variant: 'default'
+ variant: 'default' // this is the default variant
}
}
);
@@ -74,8 +74,8 @@ const TextClassContext = React.createContext(undefined);
function Text({
className,
- asChild = false,
- variant = 'default',
+ asChild,
+ variant,
...props
}: React.ComponentProps &
TextVariantProps &
@@ -87,22 +87,12 @@ function Text({
const { style, ...restProps } = props;
const notoSansStyle = useNotoSans(mergedClassName, style);
+ const Component = asChild ? Slot.Text : RNText;
// Render directly as RNText to avoid Slot navigation context issues during transitions
// Only use Slot.Text when explicitly using asChild pattern
- if (!asChild) {
- return (
-
- );
- }
return (
- Promise;
- playSoundSequence: (uris: string[], audioId?: string) => Promise;
+ playSound: (input: PlayInput, audioId?: string) => Promise;
+ /** @deprecated Use playSound — it now accepts all input types */
+ playSoundSequence: (
+ segments: (string | AudioSegment)[],
+ audioId?: string
+ ) => Promise;
stopCurrentSound: () => Promise;
+ waitForPlaybackEnd: (audioId: string) => Promise;
isPlaying: boolean;
currentAudioId: string | null;
position: number; // Keep for backward compatibility
duration: number; // Keep for backward compatibility
setPosition: (position: number) => Promise;
- // NEW: SharedValues for high-performance UI updates
+ // SharedValues for high-performance UI updates (60fps via Reanimated)
positionShared: SharedValue;
durationShared: SharedValue;
}
@@ -20,286 +58,626 @@ interface AudioContextType {
const AudioContext = createContext(undefined);
export function AudioProvider({ children }: { children: React.ReactNode }) {
+ // Hybrid progress model:
+ // - Get duration for a segment or segment sequence
+ // and use that to set the duration for audio progress bar animation
+ // - sparse native status callbacks for truth/corrections
+ // - continuous UI-thread timing for smooth movement
+ const DRIFT_CORRECTION_THRESHOLD_MS = 80;
+ // Initial merged-playback timing estimate: add this much wall-clock time
+ // per segment before we do the one-time last-segment slope correction.
+ const MERGED_SEGMENT_PADDING_MS = 250;
+ const FINAL_SEGMENT_END_FUDGE_MS = 40;
+ // How often to push React state (triggers re-renders). Keep low to reduce
+ // JS-thread pressure — progress bars use SharedValues, not React state.
+ const REACT_STATE_THROTTLE_MS = 100;
+
const [isPlaying, setIsPlaying] = useState(false);
const [currentAudioId, setCurrentAudioId] = useState(null);
const [position, setPositionState] = useState(0);
const [duration, setDuration] = useState(0);
- // NEW: Reanimated SharedValues for high-performance position tracking
+ // Reanimated SharedValues for high-performance position tracking
const positionShared = useSharedValue(0);
const durationShared = useSharedValue(0);
- const cumulativePositionShared = useSharedValue(0); // SharedValue for worklet access
-
- const soundRef = useRef(null);
- const positionUpdateInterval = useRef | null>(
- null
+ const cumulativePositionShared = useSharedValue(0);
+
+ const soundRef = useRef(null);
+ const soundListenerRef = useRef<{ remove: () => void } | null>(null);
+ const lastReactStateUpdateMs = useRef(0);
+ const currentAudioIdRef = useRef(null);
+ const isPlayingRef = useRef(false);
+ const isAdvancingSegmentRef = useRef(false);
+ const useSequenceLevelProgressRef = useRef(false);
+ const hasRetimedLastSegmentRef = useRef(false);
+ const playbackSessionRef = useRef(0);
+ const preloadedSegmentRef = useRef<{
+ index: number;
+ sound: AudioPlayer;
+ } | null>(null);
+ const animationStartWallClockMsRef = useRef(null);
+ const animationStartPositionMsRef = useRef(0);
+ const animationTargetPositionMsRef = useRef(0);
+ const animationDurationMsRef = useRef(0);
+ // Wall-clock guard: ignore all status-callback position updates until this
+ // timestamp. expo-av fires an unpredictable number of eager callbacks when
+ // a sound loads/plays/seeks — not on the interval timer. Those early
+ // callbacks carry real positions that, if applied, would hard-set
+ // positionShared and interrupt the deterministic withTiming animation,
+ // causing visible jumps at the very start of playback.
+ const statusGuardUntilMsRef = useRef(0);
+ const playbackCompletionWaitersRef = useRef
{!shouldDownload && (
-
+
{t('projectNotAvailableOfflineWarning')}
@@ -966,7 +960,7 @@ export default function NotificationsView() {
handleAccept(
item.id,
@@ -975,25 +969,16 @@ export default function NotificationsView() {
item.as_owner
)
}
- disabled={isProcessing}
+ loading={isProcessing}
>
- {isProcessing ? (
-
- ) : (
- <>
-
- {t('accept')}
- >
- )}
+
+ {t('accept')}
handleDecline(item.id, item.type)}
loading={isProcessing}
>
diff --git a/views/ProfileView.tsx b/views/ProfileView.tsx
index da0947619..a3f69ba1b 100644
--- a/views/ProfileView.tsx
+++ b/views/ProfileView.tsx
@@ -260,6 +260,7 @@ export default function ProfileView() {
{
text: t('confirm'),
style: 'destructive',
+ isPreferred: true,
onPress: () => {
void seedDatabase();
}
@@ -283,6 +284,7 @@ export default function ProfileView() {
{
text: t('confirm'),
style: 'destructive',
+ isPreferred: true,
onPress: () => {
void deleteDatabase();
}
@@ -307,6 +309,7 @@ export default function ProfileView() {
{
text: t('confirm'),
style: 'destructive',
+ isPreferred: true,
onPress: () => {
void deleteAttachments();
}
@@ -330,6 +333,7 @@ export default function ProfileView() {
{ text: t('cancel'), style: 'cancel' },
{
text: t('confirm'),
+ isPreferred: true,
onPress: () => {
void clearDegradedModeState();
}
diff --git a/views/RegisterView.tsx b/views/RegisterView.tsx
index cb65b98ab..c638bcba1 100644
--- a/views/RegisterView.tsx
+++ b/views/RegisterView.tsx
@@ -1,6 +1,6 @@
import { LanguageCombobox } from '@/components/language-combobox';
import { OfflineAlert } from '@/components/offline-alert';
-import { Button } from '@/components/ui/button';
+import { Button, buttonTextVariants } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import {
Form,
@@ -26,14 +26,39 @@ import RNAlert from '@blazejkustra/react-native-alert';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation } from '@tanstack/react-query';
import { LockIcon, MailIcon, UserIcon } from 'lucide-react-native';
-import React, { useEffect } from 'react';
+import React, { useCallback, useEffect } from 'react';
import { useForm } from 'react-hook-form';
-import { Pressable, View } from 'react-native';
+import { Linking, Pressable, View } from 'react-native';
import { KeyboardAwareScrollView } from 'react-native-keyboard-controller';
import { z } from 'zod';
const { supabaseConnector } = system;
+function AgreeToTermsText({ className }: { className?: string }) {
+ const { t } = useLocalization();
+ const handleLinkPress = useCallback(() => {
+ void Linking.openURL(`${process.env.EXPO_PUBLIC_SITE_URL}/terms`);
+ }, []);
+
+ const baseText = t('agreeToTerms');
+ const linkText = t('termsAndPrivacyLink');
+ const textParts = baseText.split('{link}');
+
+ return (
+
+ {textParts[0]}
+
+ {linkText}
+
+ {textParts[1]}
+
+ );
+}
+
export default function RegisterView({
onNavigate,
sharedAuthInfo
@@ -270,12 +295,9 @@ export default function RegisterView({
checked={field.value}
onCheckedChange={field.onChange}
/>
-
- {t('agreeToTerms') ||
- 'I accept the terms and conditions'}
-
+ />
diff --git a/views/ResetPasswordView.tsx b/views/ResetPasswordView.tsx
index 0be7a98be..dc95ea2c8 100644
--- a/views/ResetPasswordView.tsx
+++ b/views/ResetPasswordView.tsx
@@ -62,6 +62,7 @@ export default function ResetPasswordView() {
RNAlert.alert(t('success'), t('passwordResetSuccess'), [
{
text: t('ok'),
+ isPreferred: true,
// Sign out and let auth context handle navigation to sign in
// ** It is needed to wait the keyboard be hidden, otherwise can cause some components to be flickering
// at next page.
diff --git a/views/SettingsView.tsx b/views/SettingsView.tsx
index 4b3db159b..45fe50257 100644
--- a/views/SettingsView.tsx
+++ b/views/SettingsView.tsx
@@ -155,6 +155,7 @@ export default function SettingsView() {
{
text: t('clear'),
style: 'destructive',
+ isPreferred: true,
onPress: () => {
// TODO: Implement cache clearing logic
RNAlert.alert(t('success'), t('cacheClearedSuccess'));
diff --git a/views/SignInView.tsx b/views/SignInView.tsx
index c6dc711b5..f32231624 100644
--- a/views/SignInView.tsx
+++ b/views/SignInView.tsx
@@ -1,10 +1,6 @@
import { LanguageCombobox } from '@/components/language-combobox';
import { OfflineAlert } from '@/components/offline-alert';
-import {
- Button,
- ButtonPressableOpacity,
- buttonTextVariants
-} from '@/components/ui/button';
+import { Button } from '@/components/ui/button';
import {
Form,
FormControl,
@@ -21,7 +17,6 @@ import { useLocalization } from '@/hooks/useLocalization';
import { useNetworkStatus } from '@/hooks/useNetworkStatus';
import type { SharedAuthInfo } from '@/navigators/AuthNavigator';
import { safeNavigate } from '@/utils/sharedUtils';
-import { cn } from '@/utils/styleUtils';
import RNAlert from '@blazejkustra/react-native-alert';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation } from '@tanstack/react-query';
@@ -76,7 +71,7 @@ export default function SignInView({
? error.message
: t('signInError') || 'Sign in failed',
[
- { text: t('ok') || 'OK' },
+ { text: t('ok') || 'OK', isPreferred: true },
{
text: t('newUser'),
onPress: () =>
@@ -159,22 +154,19 @@ export default function SignInView({
)}
/>
-
safeNavigate(() =>
onNavigate('forgot-password', { email: form.watch('email') })
)
}
+ size="auto"
+ className="self-start"
+ // className="native:px-0 native:py-0 h-auto self-start px-0 py-0"
>
-
- {t('forgotPassword')}
-
-
+ {t('forgotPassword')}
+
diff --git a/views/new/AssetCardItem.tsx b/views/new/AssetCardItem.tsx
index aaca53c67..a87634d8b 100644
--- a/views/new/AssetCardItem.tsx
+++ b/views/new/AssetCardItem.tsx
@@ -16,6 +16,7 @@ import { useAppNavigation } from '@/hooks/useAppNavigation';
import { useLocalization } from '@/hooks/useLocalization';
// import { useTagStore } from '@/hooks/useTagStore';
import { SHOW_DEV_ELEMENTS } from '@/utils/featureFlags';
+import { cn } from '@/utils/styleUtils';
import type { AttachmentRecord } from '@powersync/attachments';
import {
CheckSquareIcon,
@@ -32,6 +33,7 @@ import {
import React from 'react';
import { Pressable, View } from 'react-native';
// import { TagModal } from '../../components/TagModal';
+import { Button } from '@/components/ui/button';
import { Text } from '@/components/ui/text';
import { useItemDownload, useItemDownloadStatus } from './useHybridData';
@@ -286,13 +288,14 @@ const AssetCardItemComponent: React.FC = ({
return (
{/* Highlight indicator triangle */}
{/* { isHighlighted && } */}
@@ -398,7 +401,7 @@ const AssetCardItemComponent: React.FC = ({
*/}
{/* Actions: Edit name + Open details (hidden in selection mode) */}
-
+
{/* Highlight indicator badge */}
{isHighlighted && (
@@ -417,8 +420,8 @@ const AssetCardItemComponent: React.FC = ({
e.stopPropagation();
onRename(asset.id, asset.name);
}}
- className="flex h-7 w-7 items-center justify-center rounded-full bg-primary/20 active:bg-primary/40"
- hitSlop={8}
+ className="flex size-7 items-center justify-center rounded-full bg-primary/20 active:bg-primary/40"
+ hitSlop={6}
>
= ({
isFlaggedForDownload={isDownloaded}
isLoading={isDownloading}
onPress={handleDownloadToggle}
- size={16}
+ size={20}
iconColor="text-primary/50"
/>
)}
{!isSelectionMode && (
- {
e.stopPropagation();
handleOpenAsset();
}}
- className="mr-2"
- hitSlop={8}
+ hitSlop={2}
>
-
+
)}
diff --git a/views/new/AssetListItem.tsx b/views/new/AssetListItem.tsx
index 79a06c4f2..836559b8a 100644
--- a/views/new/AssetListItem.tsx
+++ b/views/new/AssetListItem.tsx
@@ -28,6 +28,7 @@ import {
import React from 'react';
import { Pressable, View } from 'react-native';
// import { TagModal } from '../../components/TagModal';
+import { Button } from '@/components/ui/button';
import { useItemDownload, useItemDownloadStatus } from './useHybridData';
// Define props locally to avoid require cycle
@@ -240,16 +241,24 @@ export const AssetListItem: React.FC = ({
isFlaggedForDownload={isDownloaded}
isLoading={isDownloading}
onPress={handleDownloadToggle}
- size={16}
+ size={20}
iconColor="text-primary/50"
/>
-
+ {
+ e.stopPropagation();
+ handleOpenAsset();
+ }}
+ hitSlop={2}
+ >
-
+
{SHOW_DEV_ELEMENTS && (
diff --git a/views/new/BibleAssetListItem.tsx b/views/new/BibleAssetListItem.tsx
index 728a5b472..2c0e96731 100644
--- a/views/new/BibleAssetListItem.tsx
+++ b/views/new/BibleAssetListItem.tsx
@@ -19,6 +19,7 @@ import { useAppNavigation } from '@/hooks/useAppNavigation';
import { useLocalization } from '@/hooks/useLocalization';
// import { useTagStore } from '@/hooks/useTagStore';
import { SHOW_DEV_ELEMENTS } from '@/utils/featureFlags';
+import { cn } from '@/utils/styleUtils';
import type { AttachmentRecord } from '@powersync/attachments';
import {
CheckSquareIcon,
@@ -35,6 +36,7 @@ import {
import React from 'react';
import { Pressable, View } from 'react-native';
// import { TagModal } from '../../components/TagModal';
+import { Button } from '@/components/ui/button';
import { Text } from '@/components/ui/text';
import { useItemDownload, useItemDownloadStatus } from './useHybridData';
@@ -289,13 +291,14 @@ const BibleAssetListItemComponent: React.FC = ({
return (
{/* Highlight indicator triangle */}
{/* { isHighlighted && } */}
@@ -434,26 +437,27 @@ const BibleAssetListItemComponent: React.FC = ({
isFlaggedForDownload={isDownloaded}
isLoading={isDownloading}
onPress={handleDownloadToggle}
- size={16}
+ size={20}
iconColor="text-primary/50"
/>
)}
{!isSelectionMode && (
- {
e.stopPropagation();
handleOpenAsset();
}}
- className="mr-2"
- hitSlop={8}
+ hitSlop={2}
>
-
+
)}
diff --git a/views/new/BibleAssetsView.tsx b/views/new/BibleAssetsView.tsx
index 7fa274d62..3cfee4f93 100644
--- a/views/new/BibleAssetsView.tsx
+++ b/views/new/BibleAssetsView.tsx
@@ -11,7 +11,7 @@ import {
SpeedDialTrigger
} from '@/components/ui/speed-dial';
import { Text } from '@/components/ui/text';
-import { useAudio } from '@/contexts/AudioContext';
+import { useAssetAudio } from '@/services/assetAudio';
import { useAuth } from '@/contexts/AuthContext';
import { LayerType, useStatusContext } from '@/contexts/StatusContext';
import type { asset } from '@/db/drizzleSchema';
@@ -34,7 +34,6 @@ import { useLocalStore } from '@/store/localStore';
import { SHOW_DEV_ELEMENTS } from '@/utils/featureFlags';
import RNAlert from '@blazejkustra/react-native-alert';
import AsyncStorage from '@react-native-async-storage/async-storage';
-import { Audio } from 'expo-av';
import {
BookmarkPlusIcon,
BrushCleaning,
@@ -53,7 +52,13 @@ import {
UserPlusIcon
} from 'lucide-react-native';
import React from 'react';
-import { ActivityIndicator, Pressable, View } from 'react-native';
+import {
+ ActivityIndicator,
+ InteractionManager,
+ Pressable,
+ View
+} from 'react-native';
+import type { FlatList } from 'react-native';
import Animated, {
cancelAnimation,
Easing,
@@ -94,13 +99,11 @@ import {
} from '@/database_services/assetService';
import { audioSegmentService } from '@/database_services/audioSegmentService';
import { createQuestRecordingSession } from '@/database_services/questService';
-import { AppConfig } from '@/db/supabase/AppConfig';
import { useAssetsByQuest, useLocalAssetsByQuest } from '@/hooks/db/useAssets';
import { useBlockedAssetsCount } from '@/hooks/useBlockedCount';
import { useQuestOffloadVerification } from '@/hooks/useQuestOffloadVerification';
import { useHasUserReported } from '@/hooks/useReports';
import { resolveTable } from '@/utils/dbUtils';
-import { fileExists, getLocalAttachmentUriWithOPFS } from '@/utils/fileUtils';
import { publishQuest as publishQuestUtils } from '@/utils/publishUtils';
import { offloadQuest } from '@/utils/questOffloadUtils';
import { getThemeColor } from '@/utils/styleUtils';
@@ -457,7 +460,7 @@ export default function BibleAssetsView() {
} = useCurrentNavigation();
const { goBack, navigate } = useAppNavigation();
const { currentUser } = useAuth();
- const audioContext = useAudio();
+ const assetAudio = useAssetAudio();
const queryClient = useQueryClient();
const insets = useSafeAreaInsets();
@@ -564,17 +567,13 @@ export default function BibleAssetsView() {
},
[]
);
- // Track which asset is currently playing during play-all
- const [currentlyPlayingAssetId, setCurrentlyPlayingAssetId] = React.useState<
- string | null
- >(null);
// Track if PlayAll is running (for button icon state)
const [isPlayAllRunning, setIsPlayAllRunning] = React.useState(false);
- // OLD handlePlayAllAssets refs - commented out
- // const assetUriMapRef = React.useRef