REF: bump react native
This commit is contained in:
parent
1d208142e8
commit
9dc35cecb9
@ -33,7 +33,7 @@
|
||||
"simulator": {
|
||||
"type": "ios.simulator",
|
||||
"device": {
|
||||
"type": "iPhone 16"
|
||||
"type": "iPhone 17"
|
||||
}
|
||||
},
|
||||
"emulator": {
|
||||
@ -44,6 +44,10 @@
|
||||
}
|
||||
},
|
||||
"configurations": {
|
||||
"ios.debug": {
|
||||
"device": "simulator",
|
||||
"app": "ios.debug"
|
||||
},
|
||||
"ios.release": {
|
||||
"device": "simulator",
|
||||
"app": "ios.release"
|
||||
|
||||
@ -21,6 +21,10 @@
|
||||
"react-native/no-unused-styles": "error",
|
||||
"react/no-is-mounted": "off",
|
||||
"react-native/no-single-element-style-arrays": "error",
|
||||
"react-hooks/refs": "off",
|
||||
"react-hooks/immutability": "off",
|
||||
"react-hooks/purity": "off",
|
||||
"react-hooks/set-state-in-effect": "off",
|
||||
"prettier/prettier": [
|
||||
"warn",
|
||||
{
|
||||
|
||||
@ -12,7 +12,7 @@ on:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: macos-15
|
||||
runs-on: macos-26
|
||||
timeout-minutes: 180
|
||||
outputs:
|
||||
new_build_number: ${{ steps.generate_build_number.outputs.build_number }}
|
||||
@ -463,7 +463,7 @@ jobs:
|
||||
|
||||
testflight-upload:
|
||||
needs: build
|
||||
runs-on: macos-15
|
||||
runs-on: macos-26
|
||||
if: github.event_name == 'push' || contains(github.event.pull_request.labels.*.name, 'testflight')
|
||||
env:
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
|
||||
67
.github/workflows/build-release-apk.yml
vendored
67
.github/workflows/build-release-apk.yml
vendored
@ -11,7 +11,7 @@ on:
|
||||
|
||||
jobs:
|
||||
buildReleaseApk:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-22.04
|
||||
|
||||
steps:
|
||||
- name: Checkout project
|
||||
@ -19,6 +19,23 @@ jobs:
|
||||
with:
|
||||
fetch-depth: "0"
|
||||
|
||||
- name: Free disk space (Android build)
|
||||
shell: bash
|
||||
run: |
|
||||
df -h
|
||||
sudo rm -rf /usr/share/dotnet || true
|
||||
sudo rm -rf /opt/ghc || true
|
||||
sudo rm -rf /usr/local/share/boost || true
|
||||
sudo rm -rf /usr/local/lib/android/sdk/ndk || true
|
||||
docker system prune -af || true
|
||||
sudo rm -rf /usr/local/lib/android/sdk/system-images || true
|
||||
sudo rm -rf /usr/local/lib/android/sdk/emulator || true
|
||||
rm -rf ~/.gradle/caches/modules-2/files-2.1 || true
|
||||
rm -rf ~/.gradle/caches/build-cache || true
|
||||
rm -rf ~/.npm/_cacache ~/.cache || true
|
||||
sudo rm -rf /home/runner/work/_temp || true
|
||||
df -h
|
||||
|
||||
- name: Specify node version
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6
|
||||
with:
|
||||
@ -33,8 +50,16 @@ jobs:
|
||||
java-version: '17'
|
||||
cache: 'gradle'
|
||||
|
||||
- name: Install node_modules
|
||||
run: npm ci --omit=dev --yes
|
||||
- name: Set up Android SDK
|
||||
uses: android-actions/setup-android@v3
|
||||
|
||||
- name: Install Android SDK components
|
||||
run: |
|
||||
yes | sdkmanager --licenses
|
||||
sdkmanager "platforms;android-36" "platform-tools" "build-tools;36.0.0" "ndk;27.1.12297006"
|
||||
|
||||
- name: Install node_modules (include dev deps for patch-package)
|
||||
run: npm ci --yes
|
||||
|
||||
- name: Set up Ruby
|
||||
uses: ruby/setup-ruby@v1
|
||||
@ -55,31 +80,41 @@ jobs:
|
||||
run: |
|
||||
NEW_BUILD_NUMBER="$(date +%s)"
|
||||
echo "NEW_BUILD_NUMBER=$NEW_BUILD_NUMBER" >> $GITHUB_ENV
|
||||
echo "build_number=$NEW_BUILD_NUMBER" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Prepare Keystore
|
||||
run: bundle exec fastlane android prepare_keystore
|
||||
env:
|
||||
KEYSTORE_FILE_HEX: ${{ secrets.KEYSTORE_FILE_HEX }}
|
||||
|
||||
- name: Update Version Code, Build, and Sign APK
|
||||
- name: Build and sign APK
|
||||
id: build_and_sign_apk
|
||||
run: |
|
||||
bundle exec fastlane android update_version_build_and_sign_apk
|
||||
run: bundle exec fastlane android build_release_apk
|
||||
env:
|
||||
BUILD_NUMBER: ${{ env.NEW_BUILD_NUMBER }}
|
||||
BUILD_NUMBER: ${{ steps.build_number.outputs.build_number }}
|
||||
KEYSTORE_FILE_HEX: ${{ secrets.KEYSTORE_FILE_HEX }}
|
||||
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
|
||||
|
||||
- name: Upload build logs on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: android-build-logs
|
||||
path: |
|
||||
fastlane/logs/**/*.log
|
||||
android/**/*.log
|
||||
android/**/build/**/*.log
|
||||
android/**/outputs/logs/**/*.log
|
||||
android/**/reports/**/*.log
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Determine APK Filename and Path
|
||||
id: determine_apk_path
|
||||
run: |
|
||||
BUILD_NUMBER=${{ steps.build_number.outputs.build_number }}
|
||||
VERSION_NAME=$(grep versionName android/app/build.gradle | awk '{print $2}' | tr -d '"')
|
||||
BRANCH_NAME=${GITHUB_HEAD_REF:-${GITHUB_REF_NAME}}
|
||||
BRANCH_NAME=$(echo "$BRANCH_NAME" | sed 's/[^a-zA-Z0-9_-]/_/g')
|
||||
|
||||
if [ -n "$BRANCH_NAME" ] && [ "$BRANCH_NAME" != "master" ]; then
|
||||
EXPECTED_FILENAME="BlueWallet-${VERSION_NAME}-${NEW_BUILD_NUMBER}-${BRANCH_NAME}.apk"
|
||||
EXPECTED_FILENAME="BlueWallet-${VERSION_NAME}-${BUILD_NUMBER}-${BRANCH_NAME}.apk"
|
||||
else
|
||||
EXPECTED_FILENAME="BlueWallet-${VERSION_NAME}-${NEW_BUILD_NUMBER}.apk"
|
||||
EXPECTED_FILENAME="BlueWallet-${VERSION_NAME}-${BUILD_NUMBER}.apk"
|
||||
fi
|
||||
|
||||
APK_PATH="android/app/build/outputs/apk/release/${EXPECTED_FILENAME}"
|
||||
@ -94,7 +129,7 @@ jobs:
|
||||
if-no-files-found: error
|
||||
|
||||
browserstack:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: macos-latest
|
||||
needs: buildReleaseApk
|
||||
if: ${{ github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'browserstack') }}
|
||||
|
||||
@ -127,4 +162,4 @@ jobs:
|
||||
BROWSERSTACK_ACCESS_KEY: ${{ secrets.BROWSERSTACK_ACCESS_KEY }}
|
||||
GITHUB_PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: bundle exec fastlane upload_to_browserstack_and_comment
|
||||
run: bundle exec fastlane upload_to_browserstack_and_comment
|
||||
|
||||
54
.github/workflows/e2e.yml
vendored
54
.github/workflows/e2e.yml
vendored
@ -7,7 +7,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
ios:
|
||||
runs-on: macos-15
|
||||
runs-on: macos-26
|
||||
env:
|
||||
BUILD_CONFIGURATION: Release
|
||||
HD_MNEMONIC: ${{ secrets.HD_MNEMONIC }}
|
||||
@ -139,6 +139,7 @@ jobs:
|
||||
brew install applesimutils
|
||||
|
||||
- name: Run detox tests
|
||||
timeout-minutes: 360
|
||||
run: |
|
||||
npm run e2e:test:ios-release -- \
|
||||
--record-videos failing \
|
||||
@ -157,7 +158,7 @@ jobs:
|
||||
|
||||
|
||||
android:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-22.04
|
||||
env:
|
||||
HD_MNEMONIC: ${{ secrets.HD_MNEMONIC }}
|
||||
HD_MNEMONIC_BIP84: ${{ secrets.HD_MNEMONIC_BIP84 }}
|
||||
@ -168,32 +169,26 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Free disk space
|
||||
uses: jlumbroso/free-disk-space@main
|
||||
with:
|
||||
tool-cache: true
|
||||
android: false
|
||||
dotnet: true
|
||||
haskell: true
|
||||
large-packages: true
|
||||
docker-images: true
|
||||
swap-storage: true
|
||||
|
||||
- name: npm and gradle caches in /mnt
|
||||
- name: Free disk space (Ubuntu)
|
||||
run: |
|
||||
rm -rf ~/.npm
|
||||
rm -rf ~/.gradle
|
||||
sudo mkdir -p /mnt/.npm
|
||||
sudo mkdir -p /mnt/.gradle
|
||||
sudo chown -R runner /mnt/.npm
|
||||
sudo chown -R runner /mnt/.gradle
|
||||
ln -s /mnt/.npm /home/runner/
|
||||
ln -s /mnt/.gradle /home/runner/
|
||||
echo "Disk before cleanup:" && df -h
|
||||
sudo rm -rf /usr/share/dotnet /opt/ghc
|
||||
sudo apt-get clean
|
||||
sudo rm -rf /opt/ghc || true
|
||||
sudo rm -rf /usr/local/share/boost || true
|
||||
sudo rm -rf /usr/local/lib/android/sdk/ndk || true
|
||||
sudo docker system prune -af || true
|
||||
sudo rm -rf /usr/local/lib/android/sdk/system-images || true
|
||||
sudo rm -rf /usr/local/lib/android/sdk/emulator || true
|
||||
rm -rf ~/.gradle/caches/modules-2/files-2.1 || true
|
||||
rm -rf ~/.gradle/caches/build-cache || true
|
||||
rm -rf ~/.npm/_cacache ~/.cache || true
|
||||
sudo rm -rf /home/runner/work/_temp || true
|
||||
echo "Disk after cleanup:" && df -h
|
||||
|
||||
- name: Create artifacts directory on /mnt
|
||||
- name: Ensure artifacts directory
|
||||
run: |
|
||||
sudo mkdir -p /mnt/artifacts
|
||||
sudo chown -R runner /mnt/artifacts
|
||||
mkdir -p ${{ github.workspace }}/artifacts
|
||||
|
||||
- name: Specify node version
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6
|
||||
@ -236,16 +231,17 @@ jobs:
|
||||
- name: Run tests
|
||||
uses: reactivecircus/android-emulator-runner@b530d96654c385303d652368551fb075bc2f0b6b # v2
|
||||
with:
|
||||
api-level: 31
|
||||
api-level: 36
|
||||
profile: pixel
|
||||
avd-name: Pixel_API_29_AOSP
|
||||
force-avd-creation: false
|
||||
force-avd-creation: true
|
||||
enable-hw-keyboard: true
|
||||
emulator-options: -no-window -gpu swiftshader_indirect -no-snapshot -noaudio -no-boot-anim -camera-back none -camera-front none -partition-size 2047
|
||||
arch: x86_64
|
||||
script: npm run e2e:release-test -- --record-videos failing --record-logs failing --take-screenshots failing --headless --retries 3 --reuse --artifacts-location /mnt/artifacts
|
||||
script: npm run e2e:release-test -- --record-videos failing --record-logs failing --take-screenshots failing --headless --retries 4 --reuse --artifacts-location ${{ github.workspace }}/artifacts
|
||||
|
||||
- uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
if: failure()
|
||||
with:
|
||||
name: e2e-android-videos
|
||||
path: /mnt/artifacts/
|
||||
path: ${{ github.workspace }}/artifacts
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@ -88,9 +88,11 @@ artifacts/
|
||||
*.realm
|
||||
*.realm.lock
|
||||
android/app/.project
|
||||
android/.settings/org.eclipse.buildship.core.prefs
|
||||
android/app/.classpath
|
||||
android/.settings/org.eclipse.buildship.core.prefs
|
||||
android/.project
|
||||
android/app/.settings/org.eclipse.jdt.core.prefs
|
||||
android/.settings/org.eclipse.buildship.core.prefs
|
||||
android/app/.classpath
|
||||
android/app/.project
|
||||
|
||||
4
App.tsx
4
App.tsx
@ -1,4 +1,4 @@
|
||||
import { NavigationContainer } from '@react-navigation/native';
|
||||
import { NavigationContainer, NavigationContainerRef, ParamListBase } from '@react-navigation/native';
|
||||
import React from 'react';
|
||||
import { useColorScheme } from 'react-native';
|
||||
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
||||
@ -13,7 +13,7 @@ import { StorageProvider } from './components/Context/StorageProvider';
|
||||
const App = () => {
|
||||
const colorScheme = useColorScheme();
|
||||
|
||||
useLogger(navigationRef);
|
||||
useLogger(navigationRef as unknown as React.RefObject<NavigationContainerRef<ParamListBase>>);
|
||||
|
||||
return (
|
||||
<SizeClassProvider>
|
||||
|
||||
@ -90,13 +90,14 @@ export const BlueFormLabel: React.FC<TextProps> = props => {
|
||||
|
||||
export const BlueFormMultiInput: React.FC<TextInputProps> = props => {
|
||||
const { colors } = useTheme();
|
||||
const { style, editable, ...restProps } = props;
|
||||
|
||||
return (
|
||||
<TextInput
|
||||
multiline
|
||||
underlineColorAndroid="transparent"
|
||||
numberOfLines={4}
|
||||
editable={!props.editable}
|
||||
editable={editable}
|
||||
style={[
|
||||
styles.blueFormMultiInput,
|
||||
{
|
||||
@ -105,11 +106,12 @@ export const BlueFormMultiInput: React.FC<TextInputProps> = props => {
|
||||
backgroundColor: colors.inputBackgroundColor,
|
||||
color: colors.foregroundColor,
|
||||
},
|
||||
style,
|
||||
]}
|
||||
autoCorrect={false}
|
||||
autoCapitalize="none"
|
||||
spellCheck={false}
|
||||
{...props}
|
||||
{...restProps}
|
||||
selectTextOnFocus={false}
|
||||
keyboardType={Platform.OS === 'android' ? 'visible-password' : 'default'}
|
||||
/>
|
||||
|
||||
@ -25,3 +25,9 @@ Do *not* add new dependencies. Bonus points if you manage to actually remove a d
|
||||
All new files must be in typescript. Bonus points if you convert some of the existing files to typescript.
|
||||
|
||||
New components must go in `components/`. Bonus points if you refactor some of old components in `BlueComponents.js` to separate files.
|
||||
|
||||
Don't forget to add tests. Bonus points for e2e tests.
|
||||
|
||||
# PRs
|
||||
|
||||
When submitting PR, it must include screenshot (from the emulator or the device) how the proposed change looks, even better - a video; and a short description of why (it was implemented) and how (it works under the hood).
|
||||
178
Gemfile.lock
178
Gemfile.lock
@ -23,8 +23,8 @@ GEM
|
||||
artifactory (3.0.17)
|
||||
atomos (0.1.3)
|
||||
aws-eventstream (1.4.0)
|
||||
aws-partitions (1.1220.0)
|
||||
aws-sdk-core (3.242.0)
|
||||
aws-partitions (1.1222.0)
|
||||
aws-sdk-core (3.243.0)
|
||||
aws-eventstream (~> 1, >= 1.3.0)
|
||||
aws-partitions (~> 1, >= 1.992.0)
|
||||
aws-sigv4 (~> 1.9)
|
||||
@ -35,8 +35,8 @@ GEM
|
||||
aws-sdk-kms (1.122.0)
|
||||
aws-sdk-core (~> 3, >= 3.241.4)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sdk-s3 (1.213.0)
|
||||
aws-sdk-core (~> 3, >= 3.241.4)
|
||||
aws-sdk-s3 (1.215.0)
|
||||
aws-sdk-core (~> 3, >= 3.243.0)
|
||||
aws-sdk-kms (~> 1)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sigv4 (1.12.1)
|
||||
@ -88,7 +88,7 @@ GEM
|
||||
commander (4.6.0)
|
||||
highline (~> 2.0.0)
|
||||
concurrent-ruby (1.3.3)
|
||||
connection_pool (2.5.5)
|
||||
connection_pool (3.0.2)
|
||||
csv (3.3.5)
|
||||
declarative (0.0.20)
|
||||
digest-crc (0.7.0)
|
||||
@ -98,9 +98,8 @@ GEM
|
||||
drb (2.2.3)
|
||||
emoji_regex (3.2.3)
|
||||
escape (0.0.4)
|
||||
ethon (0.18.0)
|
||||
ethon (0.15.0)
|
||||
ffi (>= 1.15.0)
|
||||
logger
|
||||
excon (0.112.0)
|
||||
faraday (1.10.5)
|
||||
faraday-em_http (~> 1.0)
|
||||
@ -194,10 +193,10 @@ GEM
|
||||
fourflusher (2.3.1)
|
||||
fuzzy_match (2.0.4)
|
||||
gh_inspector (1.1.3)
|
||||
git (3.1.1)
|
||||
git (4.3.1)
|
||||
activesupport (>= 5.0)
|
||||
addressable (~> 2.8)
|
||||
process_executer (~> 1.3)
|
||||
process_executer (~> 4.0)
|
||||
rchardet (~> 1.9)
|
||||
google-apis-androidpublisher_v3 (0.96.0)
|
||||
google-apis-core (>= 0.15.0, < 2.a)
|
||||
@ -246,17 +245,19 @@ GEM
|
||||
i18n (1.14.8)
|
||||
concurrent-ruby (~> 1.0)
|
||||
jmespath (1.6.2)
|
||||
json (2.18.1)
|
||||
json (2.19.0)
|
||||
jwt (2.10.2)
|
||||
base64
|
||||
logger (1.7.0)
|
||||
mime-types (3.7.0)
|
||||
logger
|
||||
mime-types-data (~> 3.2025, >= 3.2025.0507)
|
||||
mime-types-data (3.2026.0203)
|
||||
mime-types-data (3.2026.0303)
|
||||
mini_magick (4.13.2)
|
||||
mini_mime (1.1.5)
|
||||
minitest (5.27.0)
|
||||
minitest (6.0.2)
|
||||
drb (~> 2.0)
|
||||
prism (~> 1.5)
|
||||
molinillo (0.8.0)
|
||||
multi_json (1.19.1)
|
||||
multipart-post (2.4.1)
|
||||
@ -270,7 +271,9 @@ GEM
|
||||
os (1.1.4)
|
||||
ostruct (0.6.3)
|
||||
plist (3.7.2)
|
||||
process_executer (1.3.0)
|
||||
prism (1.9.0)
|
||||
process_executer (4.0.2)
|
||||
track_open_instances (~> 0.1)
|
||||
public_suffix (4.0.7)
|
||||
rake (13.3.1)
|
||||
rchardet (1.10.0)
|
||||
@ -283,7 +286,7 @@ GEM
|
||||
http-cookie (>= 1.0.2, < 2.0)
|
||||
mime-types (>= 1.16, < 4.0)
|
||||
netrc (~> 0.8)
|
||||
retriable (3.2.1)
|
||||
retriable (3.3.0)
|
||||
rexml (3.4.4)
|
||||
rouge (3.28.0)
|
||||
ruby-macho (2.5.1)
|
||||
@ -303,13 +306,14 @@ GEM
|
||||
terminal-notifier (2.0.0)
|
||||
terminal-table (3.0.2)
|
||||
unicode-display_width (>= 1.1.1, < 3)
|
||||
track_open_instances (0.1.15)
|
||||
trailblazer-option (0.1.2)
|
||||
tty-cursor (0.7.1)
|
||||
tty-screen (0.8.2)
|
||||
tty-spinner (0.9.3)
|
||||
tty-cursor (~> 0.7)
|
||||
typhoeus (1.4.1)
|
||||
ethon (>= 0.9.0)
|
||||
typhoeus (1.5.0)
|
||||
ethon (>= 0.9.0, < 0.16.0)
|
||||
tzinfo (2.0.6)
|
||||
concurrent-ruby (~> 1.0)
|
||||
uber (0.1.0)
|
||||
@ -343,8 +347,146 @@ DEPENDENCIES
|
||||
jwt
|
||||
xcodeproj (< 1.26.0)
|
||||
|
||||
CHECKSUMS
|
||||
CFPropertyList (3.0.8) sha256=2c99d0d980536d3d7ab252f7bd59ac8be50fbdd1ff487c98c949bb66bb114261
|
||||
abbrev (0.1.2) sha256=ad1b4eaaaed4cb722d5684d63949e4bde1d34f2a95e20db93aecfe7cbac74242
|
||||
activesupport (7.2.3) sha256=5675c9770dac93e371412684249f9dc3c8cec104efd0624362a520ae685c7b10
|
||||
addressable (2.8.9) sha256=cc154fcbe689711808a43601dee7b980238ce54368d23e127421753e46895485
|
||||
algoliasearch (1.27.5) sha256=26c1cddf3c2ec4bd60c148389e42702c98fdac862881dc6b07a4c0b89ffec853
|
||||
artifactory (3.0.17) sha256=3023d5c964c31674090d655a516f38ca75665c15084140c08b7f2841131af263
|
||||
atomos (0.1.3) sha256=7d43b22f2454a36bace5532d30785b06de3711399cb1c6bf932573eda536789f
|
||||
aws-eventstream (1.4.0) sha256=116bf85c436200d1060811e6f5d2d40c88f65448f2125bc77ffce5121e6e183b
|
||||
aws-partitions (1.1222.0) sha256=e86b1c65f5cedff52586f9f2b288448d5a069376cbfe1a8abdc29f5e06411bd5
|
||||
aws-sdk-core (3.243.0) sha256=a014eef785124b71d28325783fa422a1512f8421ec9b6e3931c8b0ca3fbb0f1c
|
||||
aws-sdk-kms (1.122.0) sha256=47ce3f51b26bd7d76f1270cfdfca17b40073ecd3219c8c9400788712abfb4eb8
|
||||
aws-sdk-s3 (1.215.0) sha256=15e90b20d985e343cd2cd315c030226b4f8826dd2a031c237cfcd8e0893b01ce
|
||||
aws-sigv4 (1.12.1) sha256=6973ff95cb0fd0dc58ba26e90e9510a2219525d07620c8babeb70ef831826c00
|
||||
babosa (1.0.4) sha256=18dea450f595462ed7cb80595abd76b2e535db8c91b350f6c4b3d73986c5bc99
|
||||
base64 (0.2.0) sha256=0f25e9b21a02a0cc0cea8ef92b2041035d39350946e8789c562b2d1a3da01507
|
||||
benchmark (0.5.0) sha256=465df122341aedcb81a2a24b4d3bd19b6c67c1530713fd533f3ff034e419236c
|
||||
bigdecimal (4.0.1) sha256=8b07d3d065a9f921c80ceaea7c9d4ae596697295b584c296fe599dd0ad01c4a7
|
||||
claide (1.1.0) sha256=6d3c5c089dde904d96aa30e73306d0d4bd444b1accb9b3125ce14a3c0183f82e
|
||||
cocoapods (1.15.2) sha256=f0f5153de8d028d133b96f423e04f37fb97a1da0d11dda581a9f46c0cba4090a
|
||||
cocoapods-core (1.15.2) sha256=322650d97fe1ad4c0831a09669764b888bd91c6d79d0f6bb07281a17667a2136
|
||||
cocoapods-deintegrate (1.0.5) sha256=517c2a448ef563afe99b6e7668704c27f5de9e02715a88ee9de6974dc1b3f6a2
|
||||
cocoapods-downloader (2.1) sha256=bb6ebe1b3966dc4055de54f7a28b773485ac724fdf575d9bee2212d235e7b6d1
|
||||
cocoapods-plugins (1.0.0) sha256=725d17ce90b52f862e73476623fd91441b4430b742d8a071000831efb440ca9a
|
||||
cocoapods-search (1.0.1) sha256=1b133b0e6719ed439bd840e84a1828cca46425ab73a11eff5e096c3b2df05589
|
||||
cocoapods-trunk (1.6.0) sha256=5f5bda8c172afead48fa2d43a718cf534b1313c367ba1194cebdeb9bfee9ed31
|
||||
cocoapods-try (1.2.0) sha256=145b946c6e7747ed0301d975165157951153d27469e6b2763c83e25c84b9defe
|
||||
colored (1.2) sha256=9d82b47ac589ce7f6cab64b1f194a2009e9fd00c326a5357321f44afab2c1d2c
|
||||
colored2 (3.1.2) sha256=b13c2bd7eeae2cf7356a62501d398e72fde78780bd26aec6a979578293c28b4a
|
||||
commander (4.6.0) sha256=7d1ddc3fccae60cc906b4131b916107e2ef0108858f485fdda30610c0f2913d9
|
||||
concurrent-ruby (1.3.3) sha256=4f9cd28965c4dcf83ffd3ea7304f9323277be8525819cb18a3b61edcb56a7c6a
|
||||
connection_pool (3.0.2) sha256=33fff5ba71a12d2aa26cb72b1db8bba2a1a01823559fb01d29eb74c286e62e0a
|
||||
csv (3.3.5) sha256=6e5134ac3383ef728b7f02725d9872934f523cb40b961479f69cf3afa6c8e73f
|
||||
declarative (0.0.20) sha256=8021dd6cb17ab2b61233c56903d3f5a259c5cf43c80ff332d447d395b17d9ff9
|
||||
digest-crc (0.7.0) sha256=64adc23a26a241044cbe6732477ca1b3c281d79e2240bcff275a37a5a0d78c07
|
||||
domain_name (0.6.20240107) sha256=5f693b2215708476517479bf2b3802e49068ad82167bcd2286f899536a17d933
|
||||
dotenv (2.8.1) sha256=c5944793349ae03c432e1780a2ca929d60b88c7d14d52d630db0508c3a8a17d8
|
||||
drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373
|
||||
emoji_regex (3.2.3) sha256=ecd8be856b7691406c6bf3bb3a5e55d6ed683ffab98b4aa531bb90e1ddcc564b
|
||||
escape (0.0.4) sha256=e49f44ae2b4f47c6a3abd544ae77fe4157802794e32f19b8e773cbc4dcec4169
|
||||
ethon (0.15.0) sha256=0809805a035bc10f54162ca99f15ded49e428e0488bcfe1c08c821e18261a74d
|
||||
excon (0.112.0) sha256=daf9ac3a4c2fc9aa48383a33da77ecb44fa395111e973084d5c52f6f214ae0f0
|
||||
faraday (1.10.5) sha256=b144f1d2b045652fa820b5f532723e1643cc28b93dae911d784e5c5f88e8f6ed
|
||||
faraday-cookie_jar (0.0.8) sha256=0140605823f8cc63c7028fccee486aaed8e54835c360cffc1f7c8c07c4299dbb
|
||||
faraday-em_http (1.0.0) sha256=7a3d4c7079789121054f57e08cd4ef7e40ad1549b63101f38c7093a9d6c59689
|
||||
faraday-em_synchrony (1.0.1) sha256=bf3ce45dcf543088d319ab051f80985ea6d294930635b7a0b966563179f81750
|
||||
faraday-excon (1.1.0) sha256=b055c842376734d7f74350fe8611542ae2000c5387348d9ba9708109d6e40940
|
||||
faraday-httpclient (1.0.1) sha256=4c8ff1f0973ff835be8d043ef16aaf54f47f25b7578f6d916deee8399a04d33b
|
||||
faraday-multipart (1.2.0) sha256=7d89a949693714176f612323ca13746a2ded204031a6ba528adee788694ef757
|
||||
faraday-net_http (1.0.2) sha256=63992efea42c925a20818cf3c0830947948541fdcf345842755510d266e4c682
|
||||
faraday-net_http_persistent (1.2.0) sha256=0b0cbc8f03dab943c3e1cc58d8b7beb142d9df068b39c718cd83e39260348335
|
||||
faraday-patron (1.0.0) sha256=dc2cd7b340bb3cc8e36bcb9e6e7eff43d134b6d526d5f3429c7a7680ddd38fa7
|
||||
faraday-rack (1.0.0) sha256=ef60ec969a2bb95b8dbf24400155aee64a00fc8ba6c6a4d3968562bcc92328c0
|
||||
faraday-retry (1.0.3) sha256=add154f4f399243cbe070806ed41b96906942e7f5259bb1fe6daf2ec8f497194
|
||||
faraday_middleware (1.2.1) sha256=d45b78c8ee864c4783fbc276f845243d4a7918a67301c052647bacabec0529e9
|
||||
fastimage (2.4.0) sha256=5fce375e27d3bdbb46c18dbca6ba9af29d3304801ae1eb995771c4796c5ac7e8
|
||||
fastlane (2.232.2) sha256=978689f60f0fc3d54699de86ef12be4eda9f5b52217c1798965257c390d2b112
|
||||
fastlane-plugin-browserstack (0.3.4) sha256=a4f3e4a552e2390a4733570857512571535912100ffada177d5374413f2c1333
|
||||
fastlane-plugin-bugsnag (3.0.0) sha256=8ddac4b79cb4b5d00432cccd5789a9e1a1119c29f7773a27d01b1d8a2363915d
|
||||
fastlane-plugin-bugsnag_sourcemaps_upload (0.2.0) sha256=a05afaefa81a7bf56c36386dddeb0931db31ead6886e3eae24f9683bda1a064d
|
||||
fastlane-sirp (1.0.0) sha256=66478f25bcd039ec02ccf65625373fca29646fa73d655eb533c915f106c5e641
|
||||
ffi (1.17.3) sha256=0e9f39f7bb3934f77ad6feab49662be77e87eedcdeb2a3f5c0234c2938563d4c
|
||||
fourflusher (2.3.1) sha256=1b3de61c7c791b6a4e64f31e3719eb25203d151746bb519a0292bff1065ccaa9
|
||||
fuzzy_match (2.0.4) sha256=b5de4f95816589c5b5c3ad13770c0af539b75131c158135b3f3bbba75d0cfca5
|
||||
gh_inspector (1.1.3) sha256=04cca7171b87164e053aa43147971d3b7f500fcb58177698886b48a9fc4a1939
|
||||
git (4.3.1) sha256=91ca566c39766a033e61a148c8f470908bd4786b818f8f3ff566d3a9a0200c50
|
||||
google-apis-androidpublisher_v3 (0.96.0) sha256=9e27b03295fdd2c4a67b5e4d11f891492c89f73beff4a3f9323419165a56d01c
|
||||
google-apis-core (0.18.0) sha256=96b057816feeeab448139ed5b5c78eab7fc2a9d8958f0fbc8217dedffad054ee
|
||||
google-apis-iamcredentials_v1 (0.26.0) sha256=3ff70a10a1d6cddf2554e95b7c5df2c26afdeaeb64100048a355194da19e48a3
|
||||
google-apis-playcustomapp_v1 (0.17.0) sha256=d5bc90b705f3f862bab4998086449b0abe704ee1685a84821daa90ca7fa95a78
|
||||
google-apis-storage_v1 (0.61.0) sha256=b330e599b58e6a01533c189525398d6dbdbaf101ffb0c60145940b57e1c982e8
|
||||
google-cloud-core (1.8.0) sha256=e572edcbf189cfcab16590628a516cec3f4f63454b730e59f0b36575120281cf
|
||||
google-cloud-env (2.1.1) sha256=cf4bb8c7d517ee1ea692baedf06e0b56ce68007549d8d5a66481aa9f97f46999
|
||||
google-cloud-errors (1.5.0) sha256=b56be28b8c10628125214dde571b925cfcebdbc58619e598250c37a2114f7b4b
|
||||
google-cloud-storage (1.58.0) sha256=1bedc07a9c75af169e1ede1dd306b9f941f9ffa9e7095d0364c0803c468fdffd
|
||||
googleauth (1.11.2) sha256=7e6bacaeed7aea3dd66dcea985266839816af6633e9f5983c3c2e0e40a44731e
|
||||
highline (2.0.3) sha256=2ddd5c127d4692721486f91737307236fe005352d12a4202e26c48614f719479
|
||||
http-accept (1.7.0) sha256=c626860682bfbb3b46462f8c39cd470fd7b0584f61b3cc9df5b2e9eb9972a126
|
||||
http-cookie (1.0.8) sha256=b14fe0445cf24bf9ae098633e9b8d42e4c07c3c1f700672b09fbfe32ffd41aa6
|
||||
httpclient (2.9.0) sha256=4b645958e494b2f86c2f8a2f304c959baa273a310e77a2931ddb986d83e498c8
|
||||
i18n (1.14.8) sha256=285778639134865c5e0f6269e0b818256017e8cde89993fdfcbfb64d088824a5
|
||||
jmespath (1.6.2) sha256=238d774a58723d6c090494c8879b5e9918c19485f7e840f2c1c7532cf84ebcb1
|
||||
json (2.19.0) sha256=bc5202f083618b3af7aba3184146ec9d820f8f6de261838b577173475e499d9a
|
||||
jwt (2.10.2) sha256=31e1ee46f7359883d5e622446969fe9c118c3da87a0b1dca765ce269c3a0c4f4
|
||||
logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203
|
||||
mime-types (3.7.0) sha256=dcebf61c246f08e15a4de34e386ebe8233791e868564a470c3fe77c00eed5e56
|
||||
mime-types-data (3.2026.0303) sha256=164af1de5824c5195d4b503b0a62062383b65c08671c792412450cd22d3bc224
|
||||
mini_magick (4.13.2) sha256=71d6258e0e8a3d04a9a0a09784d5d857b403a198a51dd4f882510435eb95ddd9
|
||||
mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef
|
||||
minitest (6.0.2) sha256=db6e57956f6ecc6134683b4c87467d6dd792323c7f0eea7b93f66bd284adbc3d
|
||||
molinillo (0.8.0) sha256=efbff2716324e2a30bccd3eba1ff3a735f4d5d53ffddbc6a2f32c0ca9433045d
|
||||
multi_json (1.19.1) sha256=7aefeff8f2c854bf739931a238e4aea64592845e0c0395c8a7d2eea7fdd631b7
|
||||
multipart-post (2.4.1) sha256=9872d03a8e552020ca096adadbf5e3cb1cd1cdd6acd3c161136b8a5737cdb4a8
|
||||
mutex_m (0.3.0) sha256=cfcb04ac16b69c4813777022fdceda24e9f798e48092a2b817eb4c0a782b0751
|
||||
nanaimo (0.3.0) sha256=aaaedc60497070b864a7e220f7c4b4cad3a0daddda2c30055ba8dae306342376
|
||||
nap (1.1.0) sha256=949691660f9d041d75be611bb2a8d2fd559c467537deac241f4097d9b5eea576
|
||||
naturally (2.3.0) sha256=459923cf76c2e6613048301742363200c3c7e4904c324097d54a67401e179e01
|
||||
netrc (0.11.0) sha256=de1ce33da8c99ab1d97871726cba75151113f117146becbe45aa85cb3dabee3f
|
||||
nkf (0.2.0) sha256=fbc151bda025451f627fafdfcb3f4f13d0b22ae11f58c6d3a2939c76c5f5f126
|
||||
optparse (0.8.1) sha256=42bea10d53907ccff4f080a69991441d611fbf8733b60ed1ce9ee365ce03bd1a
|
||||
os (1.1.4) sha256=57816d6a334e7bd6aed048f4b0308226c5fb027433b67d90a9ab435f35108d3f
|
||||
ostruct (0.6.3) sha256=95a2ed4a4bd1d190784e666b47b2d3f078e4a9efda2fccf18f84ddc6538ed912
|
||||
plist (3.7.2) sha256=d37a4527cc1116064393df4b40e1dbbc94c65fa9ca2eec52edf9a13616718a42
|
||||
prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85
|
||||
process_executer (4.0.2) sha256=c73eb646d450044241c973a8360f6326e33ec5ad933f7acf503f6f3579873a71
|
||||
public_suffix (4.0.7) sha256=8be161e2421f8d45b0098c042c06486789731ea93dc3a896d30554ee38b573b8
|
||||
rake (13.3.1) sha256=8c9e89d09f66a26a01264e7e3480ec0607f0c497a861ef16063604b1b08eb19c
|
||||
rchardet (1.10.0) sha256=d5ea2ed61a720a220f1914778208e718a0c7ed2a484b6d357ba695aa7001390f
|
||||
representable (3.2.0) sha256=cc29bf7eebc31653586849371a43ffe36c60b54b0a6365b5f7d95ec34d1ebace
|
||||
rest-client (2.1.0) sha256=35a6400bdb14fae28596618e312776c158f7ebbb0ccad752ff4fa142bf2747e3
|
||||
retriable (3.3.0) sha256=b6a5a4000a0dc04fcbea0976d5af3bf62c142e8d2d5c85191369ed9ff8bdfe11
|
||||
rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142
|
||||
rouge (3.28.0) sha256=0d6de482c7624000d92697772ab14e48dca35629f8ddf3f4b21c99183fd70e20
|
||||
ruby-macho (2.5.1) sha256=9075e52e0f9270b552a90b24fcc6219ad149b0d15eae1bc364ecd0ac8984f5c9
|
||||
ruby2_keywords (0.0.5) sha256=ffd13740c573b7301cf7a2e61fc857b2a8e3d3aff32545d6f8300d8bae10e3ef
|
||||
rubyzip (2.4.1) sha256=8577c88edc1fde8935eb91064c5cb1aef9ad5494b940cf19c775ee833e075615
|
||||
securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1
|
||||
security (0.1.5) sha256=3a977a0eca7706e804c96db0dd9619e0a94969fe3aac9680fcfc2bf9b8a833b7
|
||||
signet (0.21.0) sha256=d617e9fbf24928280d39dcfefba9a0372d1c38187ffffd0a9283957a10a8cd5b
|
||||
simctl (1.6.10) sha256=b99077f4d13ad81eace9f86bf5ba4df1b0b893a4d1b368bd3ed59b5b27f9236b
|
||||
sysrandom (1.0.5) sha256=5ac1ac3c2ec64ef76ac91018059f541b7e8f437fbda1ccddb4f2c56a9ccf1e75
|
||||
terminal-notifier (2.0.0) sha256=7a0d2b2212ab9835c07f4b2e22a94cff64149dba1eed203c04835f7991078cea
|
||||
terminal-table (3.0.2) sha256=f951b6af5f3e00203fb290a669e0a85c5dd5b051b3b023392ccfd67ba5abae91
|
||||
track_open_instances (0.1.15) sha256=7f0e48821e6b4c881daaa40fb1583e308937c22a9c84883c150b399c3b5c3029
|
||||
trailblazer-option (0.1.2) sha256=20e4f12ea4e1f718c8007e7944ca21a329eee4eed9e0fa5dde6e8ad8ac4344a3
|
||||
tty-cursor (0.7.1) sha256=79534185e6a777888d88628b14b6a1fdf5154a603f285f80b1753e1908e0bf48
|
||||
tty-screen (0.8.2) sha256=c090652115beae764336c28802d633f204fb84da93c6a968aa5d8e319e819b50
|
||||
tty-spinner (0.9.3) sha256=0e036f047b4ffb61f2aa45f5a770ec00b4d04130531558a94bfc5b192b570542
|
||||
typhoeus (1.5.0) sha256=120b67ed1ef515e6c0e938176db880f15b0916f038e78ce2a66290f3f1de3e3b
|
||||
tzinfo (2.0.6) sha256=8daf828cc77bcf7d63b0e3bdb6caa47e2272dcfaf4fbfe46f8c3a9df087a829b
|
||||
uber (0.1.0) sha256=5beeb407ff807b5db994f82fa9ee07cfceaa561dad8af20be880bc67eba935dc
|
||||
unicode-display_width (2.6.0) sha256=12279874bba6d5e4d2728cef814b19197dbb10d7a7837a869bab65da943b7f5a
|
||||
word_wrap (1.0.0) sha256=f556d4224c812e371000f12a6ee8102e0daa724a314c3f246afaad76d82accc7
|
||||
xcodeproj (1.25.1) sha256=9a2310dccf6d717076e86f602b17c640046b6f1dfe64480044596f6f2f13dc84
|
||||
xcpretty (0.4.1) sha256=b14c50e721f6589ee3d6f5353e2c2cfcd8541fa1ea16d6c602807dd7327f3892
|
||||
xcpretty-travis-formatter (1.0.1) sha256=aacc332f17cb7b2cba222994e2adc74223db88724fe76341483ad3098e232f93
|
||||
xml-simple (1.1.9) sha256=d21131e519c86f1a5bc2b6d2d57d46e6998e47f18ed249b25cad86433dbd695d
|
||||
|
||||
RUBY VERSION
|
||||
ruby 3.4.8p72
|
||||
ruby 3.4.8
|
||||
|
||||
BUNDLED WITH
|
||||
2.3.27
|
||||
4.0.7
|
||||
|
||||
@ -1,2 +0,0 @@
|
||||
connection.project.dir=
|
||||
eclipse.preferences.version=1
|
||||
@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<classpath>
|
||||
<classpathentry kind="con" path="org.eclipse.jdt.launching.JRE_CONTAINER/org.eclipse.jdt.internal.debug.ui.launcher.StandardVMType/JavaSE-1.8/"/>
|
||||
<classpathentry kind="con" path="org.eclipse.jdt.launching.JRE_CONTAINER/org.eclipse.jdt.internal.debug.ui.launcher.StandardVMType/JavaSE-17/"/>
|
||||
<classpathentry kind="con" path="org.eclipse.buildship.core.gradleclasspathcontainer"/>
|
||||
<classpathentry kind="output" path="bin/default"/>
|
||||
</classpath>
|
||||
|
||||
@ -1,2 +1,2 @@
|
||||
connection.project.dir=..
|
||||
eclipse.preferences.version=1
|
||||
eclipse.preferences.version=1
|
||||
|
||||
4
android/app/.settings/org.eclipse.jdt.core.prefs
Normal file
4
android/app/.settings/org.eclipse.jdt.core.prefs
Normal file
@ -0,0 +1,4 @@
|
||||
eclipse.preferences.version=1
|
||||
org.eclipse.jdt.core.compiler.codegen.targetPlatform=17
|
||||
org.eclipse.jdt.core.compiler.compliance=17
|
||||
org.eclipse.jdt.core.compiler.source=17
|
||||
@ -90,7 +90,8 @@ android {
|
||||
versionName "7.2.7"
|
||||
testBuildType System.getProperty('testBuildType', 'debug')
|
||||
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
|
||||
missingDimensionStrategy "react-native-capture-protection", "fullMediaCapture"
|
||||
// Keep compatibility across react-native-capture-protection flavor changes.
|
||||
missingDimensionStrategy "react-native-capture-protection", "callbackTiramisu", "base"
|
||||
}
|
||||
|
||||
lintOptions {
|
||||
@ -108,6 +109,7 @@ android {
|
||||
release {
|
||||
// Caution! In production, you need to generate your own keystore file.
|
||||
// see https://reactnative.dev/docs/signed-apk-android.
|
||||
signingConfig signingConfigs.debug
|
||||
minifyEnabled enableProguardInReleaseBuilds
|
||||
proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
|
||||
proguardFile "${rootProject.projectDir}/../node_modules/detox/android/detox/proguard-rules-app.pro"
|
||||
@ -123,6 +125,13 @@ task copyFiatUnits(type: Copy) {
|
||||
|
||||
preBuild.dependsOn(copyFiatUnits)
|
||||
|
||||
// Ensure fiat units are available before codegen scans JS sources
|
||||
tasks.configureEach { task ->
|
||||
if (task.name == 'generateCodegenSchemaFromJavaScript') {
|
||||
task.dependsOn(copyFiatUnits)
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
androidTestImplementation('com.wix:detox:+')
|
||||
// The version of react-native is set by the React Native Gradle Plugin
|
||||
|
||||
@ -34,27 +34,18 @@
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:configChanges="uiMode">
|
||||
|
||||
<meta-data
|
||||
android:name="com.dieam.reactnativepushnotification.notification_channel_name"
|
||||
android:value="BlueWallet notifications" />
|
||||
<meta-data
|
||||
android:name="com.dieam.reactnativepushnotification.notification_channel_description"
|
||||
android:value="Notifications about incoming payments" />
|
||||
<meta-data
|
||||
android:name="com.dieam.reactnativepushnotification.notification_foreground"
|
||||
android:value="true" />
|
||||
<meta-data
|
||||
android:name="com.dieam.reactnativepushnotification.channel_create_default"
|
||||
android:value="true" />
|
||||
<meta-data
|
||||
android:name="com.dieam.reactnativepushnotification.notification_color"
|
||||
android:resource="@color/white" />
|
||||
<meta-data
|
||||
android:name="firebase_messaging_auto_init_enabled"
|
||||
android:value="false" />
|
||||
<meta-data
|
||||
android:name="firebase_analytics_collection_enabled"
|
||||
android:value="false" />
|
||||
<meta-data
|
||||
android:name="com.google.firebase.messaging.default_notification_icon"
|
||||
android:resource="@drawable/notification_icon" />
|
||||
<meta-data
|
||||
android:name="com.google.firebase.messaging.default_notification_color"
|
||||
android:resource="@color/foreground_color" />
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.provider"
|
||||
@ -64,16 +55,6 @@
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
<receiver android:name="com.dieam.reactnativepushnotification.modules.RNPushNotificationActions" />
|
||||
<receiver android:name="com.dieam.reactnativepushnotification.modules.RNPushNotificationPublisher" />
|
||||
<receiver
|
||||
android:name="com.dieam.reactnativepushnotification.modules.RNPushNotificationBootEventReceiver"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<receiver android:name=".BitcoinPriceWidget" android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
@ -130,14 +111,6 @@
|
||||
</intent-filter>
|
||||
</activity-alias>
|
||||
|
||||
<service
|
||||
android:name="com.dieam.reactnativepushnotification.modules.RNPushNotificationListenerService"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="com.google.firebase.MESSAGING_EVENT" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:label="@string/app_name"
|
||||
|
||||
@ -39,31 +39,43 @@ class BitcoinPriceWidget : AppWidgetProvider() {
|
||||
// Try to load cached data first
|
||||
val sharedPref = context.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE)
|
||||
val cachedPrice = sharedPref.getString("previous_price", null)
|
||||
val preferredCurrency = sharedPref.getString("preferredCurrency", "USD")
|
||||
val preferredCurrencyLocale = sharedPref.getString("preferredCurrencyLocale", null)
|
||||
|
||||
if (cachedPrice != null) {
|
||||
// Show cached data immediately
|
||||
val preferredCurrency = sharedPref.getString("preferredCurrency", "USD")
|
||||
val preferredCurrencyLocale = sharedPref.getString("preferredCurrencyLocale", "en-US")
|
||||
|
||||
try {
|
||||
val localeParts = preferredCurrencyLocale?.split("-") ?: listOf("en", "US")
|
||||
val locale = if (localeParts.size == 2) {
|
||||
java.util.Locale(localeParts[0], localeParts[1])
|
||||
} else {
|
||||
java.util.Locale.getDefault()
|
||||
}
|
||||
val locale = preferredCurrencyLocale
|
||||
?.let { runCatching { java.util.Locale.forLanguageTag(it) }.getOrNull() }
|
||||
?.takeIf { it.language.isNotBlank() }
|
||||
?: java.util.Locale.getDefault()
|
||||
|
||||
val currencyFormat = java.text.NumberFormat.getCurrencyInstance(locale)
|
||||
val currency = java.util.Currency.getInstance(preferredCurrency ?: "USD")
|
||||
currencyFormat.currency = currency
|
||||
currencyFormat.maximumFractionDigits = 0
|
||||
|
||||
|
||||
val parsedCached = cachedPrice.toDoubleOrNull()?.toInt()
|
||||
|
||||
views.setViewVisibility(R.id.loading_indicator, View.GONE)
|
||||
views.setViewVisibility(R.id.price_value, View.VISIBLE)
|
||||
views.setViewVisibility(R.id.last_updated_label, View.VISIBLE)
|
||||
views.setViewVisibility(R.id.last_updated_time, View.VISIBLE)
|
||||
views.setTextViewText(R.id.price_value, currencyFormat.format(cachedPrice.toDouble().toInt()))
|
||||
views.setTextViewText(R.id.last_updated_time, java.text.SimpleDateFormat("hh:mm a", java.util.Locale.getDefault()).format(java.util.Date()))
|
||||
views.setViewVisibility(R.id.price_arrow_container, View.GONE)
|
||||
|
||||
if (parsedCached != null) {
|
||||
views.setViewVisibility(R.id.price_value, View.VISIBLE)
|
||||
views.setViewVisibility(R.id.last_updated_label, View.VISIBLE)
|
||||
views.setViewVisibility(R.id.last_updated_time, View.VISIBLE)
|
||||
views.setTextViewText(R.id.price_value, currencyFormat.format(parsedCached))
|
||||
views.setTextViewText(
|
||||
R.id.last_updated_time,
|
||||
java.text.SimpleDateFormat("hh:mm a", java.util.Locale.getDefault()).format(java.util.Date())
|
||||
)
|
||||
} else {
|
||||
// If parsing fails, show loading state
|
||||
views.setViewVisibility(R.id.price_value, View.GONE)
|
||||
views.setViewVisibility(R.id.last_updated_label, View.GONE)
|
||||
views.setViewVisibility(R.id.last_updated_time, View.GONE)
|
||||
views.setViewVisibility(R.id.loading_indicator, View.VISIBLE)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error displaying cached price", e)
|
||||
// Show loading state if cache display fails
|
||||
|
||||
@ -11,12 +11,9 @@ import com.facebook.react.PackageList
|
||||
import com.facebook.react.ReactApplication
|
||||
import com.facebook.react.ReactHost
|
||||
import com.facebook.react.ReactNativeHost
|
||||
import com.facebook.react.ReactPackage
|
||||
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.load
|
||||
import com.facebook.react.ReactNativeApplicationEntryPoint.loadReactNative
|
||||
import com.facebook.react.defaults.DefaultReactHost.getDefaultReactHost
|
||||
import com.facebook.react.defaults.DefaultReactNativeHost
|
||||
import com.facebook.react.soloader.OpenSourceMergedSoMapping
|
||||
import com.facebook.soloader.SoLoader
|
||||
import com.facebook.react.modules.i18nmanager.I18nUtil
|
||||
import io.bluewallet.bluewallet.components.segmentedcontrol.CustomSegmentedControlPackage
|
||||
|
||||
@ -66,9 +63,9 @@ class MainApplication : Application(), ReactApplication {
|
||||
}
|
||||
}
|
||||
|
||||
override val reactNativeHost: ReactNativeHost =
|
||||
override val reactNativeHost: ReactNativeHost by lazy {
|
||||
object : DefaultReactNativeHost(this) {
|
||||
override fun getPackages(): List<ReactPackage> =
|
||||
override fun getPackages() =
|
||||
PackageList(this).packages.apply {
|
||||
// Packages that cannot be autolinked yet can be added manually here, for example:
|
||||
// add(MyReactNativePackage())
|
||||
@ -76,16 +73,15 @@ class MainApplication : Application(), ReactApplication {
|
||||
add(SettingsPackage())
|
||||
}
|
||||
|
||||
override fun getJSMainModuleName(): String = "index"
|
||||
override fun getUseDeveloperSupport() = BuildConfig.DEBUG
|
||||
|
||||
override fun getUseDeveloperSupport(): Boolean = BuildConfig.DEBUG
|
||||
|
||||
override val isNewArchEnabled: Boolean = BuildConfig.IS_NEW_ARCHITECTURE_ENABLED
|
||||
override val isHermesEnabled: Boolean = BuildConfig.IS_HERMES_ENABLED
|
||||
override fun getJSMainModuleName() = "index"
|
||||
}
|
||||
}
|
||||
|
||||
override val reactHost: ReactHost
|
||||
get() = getDefaultReactHost(applicationContext, reactNativeHost)
|
||||
override val reactHost: ReactHost by lazy {
|
||||
getDefaultReactHost(applicationContext, reactNativeHost)
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
@ -101,11 +97,7 @@ class MainApplication : Application(), ReactApplication {
|
||||
|
||||
val sharedI18nUtilInstance = I18nUtil.getInstance()
|
||||
sharedI18nUtilInstance.allowRTL(applicationContext, true)
|
||||
SoLoader.init(this, OpenSourceMergedSoMapping)
|
||||
if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) {
|
||||
// If you opted-in for the New Architecture, we load the native entry point for this app.
|
||||
load()
|
||||
}
|
||||
loadReactNative(this)
|
||||
|
||||
initializeDeviceUID()
|
||||
initializeBugsnag()
|
||||
|
||||
@ -0,0 +1,210 @@
|
||||
package io.bluewallet.bluewallet
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.SharedPreferences
|
||||
import android.util.Log
|
||||
import com.bugsnag.android.Bugsnag
|
||||
import com.facebook.react.PackageList
|
||||
import com.facebook.react.ReactApplication
|
||||
import com.facebook.react.ReactHost
|
||||
import com.facebook.react.ReactNativeApplicationEntryPoint.loadReactNative
|
||||
import com.facebook.react.defaults.DefaultReactHost.getDefaultReactHost
|
||||
import com.facebook.react.modules.i18nmanager.I18nUtil
|
||||
import io.bluewallet.bluewallet.components.segmentedcontrol.CustomSegmentedControlPackage
|
||||
|
||||
class MainApplication : Application(), ReactApplication {
|
||||
|
||||
private lateinit var sharedPref: SharedPreferences
|
||||
private val themeChangeReceiver = ThemeChangeReceiver()
|
||||
private val preferenceChangeListener = SharedPreferences.OnSharedPreferenceChangeListener { prefs, key ->
|
||||
if (key == "preferredCurrency") {
|
||||
prefs.edit().remove("previous_price").apply()
|
||||
|
||||
// Update BitcoinPrice widgets
|
||||
WidgetUpdateWorker.scheduleWork(this)
|
||||
|
||||
// Immediately refresh Market widgets
|
||||
MarketWidget.refreshAllWidgetsImmediately(this)
|
||||
} else if (key == "force_dark_mode") {
|
||||
// Theme setting changed, update all widgets
|
||||
ThemeHelper.updateAllWidgets(this)
|
||||
} else if (key == "donottrack") {
|
||||
// Handle Do Not Track changes similar to iOS
|
||||
val isEnabled = prefs.getString("donottrack", "0") == "1"
|
||||
Log.d("MainApplication", "Do Not Track changed to: $isEnabled")
|
||||
|
||||
if (isEnabled) {
|
||||
// Set deviceUIDCopy to "Disabled"
|
||||
prefs.edit()
|
||||
.putString("deviceUIDCopy", "Disabled")
|
||||
.apply()
|
||||
Log.d("MainApplication", "Do Not Track enabled - set deviceUIDCopy to 'Disabled'")
|
||||
} else {
|
||||
// Re-initialize device UID
|
||||
initializeDeviceUID()
|
||||
}
|
||||
} else if (key == "deviceUID") {
|
||||
// When deviceUID changes, update deviceUIDCopy
|
||||
val isDoNotTrackEnabled = prefs.getString("donottrack", "0") == "1"
|
||||
if (!isDoNotTrackEnabled) {
|
||||
val deviceUID = prefs.getString("deviceUID", null)
|
||||
if (deviceUID != null) {
|
||||
prefs.edit()
|
||||
.putString("deviceUIDCopy", deviceUID)
|
||||
.apply()
|
||||
Log.d("MainApplication", "deviceUID changed, synced to deviceUIDCopy: $deviceUID")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override val reactHost: ReactHost by lazy {
|
||||
getDefaultReactHost(
|
||||
context = applicationContext,
|
||||
packageList = PackageList(this).packages.apply {
|
||||
// Packages that cannot be autolinked yet can be added manually here, for example:
|
||||
// add(MyReactNativePackage())
|
||||
add(CustomSegmentedControlPackage())
|
||||
add(SettingsPackage())
|
||||
},
|
||||
useDevSupport = BuildConfig.DEBUG,
|
||||
jsMainModulePath = "index",
|
||||
)
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
sharedPref = getSharedPreferences("group.io.bluewallet.bluewallet", Context.MODE_PRIVATE)
|
||||
|
||||
// Handle clearFilesOnLaunch before registering listeners
|
||||
clearFilesIfNeeded()
|
||||
|
||||
sharedPref.registerOnSharedPreferenceChangeListener(preferenceChangeListener)
|
||||
|
||||
// Register the theme change receiver
|
||||
registerReceiver(themeChangeReceiver, IntentFilter(Intent.ACTION_CONFIGURATION_CHANGED))
|
||||
|
||||
val sharedI18nUtilInstance = I18nUtil.getInstance()
|
||||
sharedI18nUtilInstance.allowRTL(applicationContext, true)
|
||||
loadReactNative(this)
|
||||
|
||||
initializeDeviceUID()
|
||||
initializeBugsnag()
|
||||
}
|
||||
|
||||
override fun onTerminate() {
|
||||
super.onTerminate()
|
||||
sharedPref.unregisterOnSharedPreferenceChangeListener(preferenceChangeListener)
|
||||
|
||||
// Unregister the theme change receiver
|
||||
try {
|
||||
unregisterReceiver(themeChangeReceiver)
|
||||
} catch (e: Exception) {
|
||||
Log.e("MainApplication", "Error unregistering theme receiver", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun initializeBugsnag() {
|
||||
val isDoNotTrackEnabled = sharedPref.getString("donottrack", "0")
|
||||
if (isDoNotTrackEnabled != "1") {
|
||||
Bugsnag.start(this)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize device UID similar to iOS implementation
|
||||
* Uses the same Android ID as react-native-device-info's getUniqueId()
|
||||
*/
|
||||
private fun initializeDeviceUID() {
|
||||
val isDoNotTrackEnabled = sharedPref.getString("donottrack", "0") == "1"
|
||||
|
||||
if (isDoNotTrackEnabled) {
|
||||
val currentCopy = sharedPref.getString("deviceUIDCopy", "")
|
||||
if (currentCopy != "Disabled") {
|
||||
sharedPref.edit()
|
||||
.putString("deviceUIDCopy", "Disabled")
|
||||
.apply()
|
||||
Log.d("MainApplication", "Do Not Track enabled - set deviceUIDCopy to 'Disabled'")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Get the Android ID (same as react-native-device-info's getUniqueId())
|
||||
val deviceUID = try {
|
||||
android.provider.Settings.Secure.getString(
|
||||
contentResolver,
|
||||
android.provider.Settings.Secure.ANDROID_ID
|
||||
) ?: "unknown"
|
||||
} catch (e: Exception) {
|
||||
Log.e("MainApplication", "Error getting Android ID", e)
|
||||
"unknown"
|
||||
}
|
||||
|
||||
// Store in deviceUID for consistency
|
||||
sharedPref.edit()
|
||||
.putString("deviceUID", deviceUID)
|
||||
.apply()
|
||||
|
||||
// Copy deviceUID to deviceUIDCopy (for Settings compatibility)
|
||||
val currentCopy = sharedPref.getString("deviceUIDCopy", "")
|
||||
if (deviceUID != currentCopy) {
|
||||
sharedPref.edit()
|
||||
.putString("deviceUIDCopy", deviceUID)
|
||||
.apply()
|
||||
Log.d("MainApplication", "Synced deviceUID to deviceUIDCopy: $deviceUID")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear files if clearFilesOnLaunch is enabled
|
||||
* Similar to iOS implementation
|
||||
*/
|
||||
private fun clearFilesIfNeeded() {
|
||||
val shouldClear = sharedPref.getBoolean("clearFilesOnLaunch", false)
|
||||
|
||||
if (shouldClear) {
|
||||
try {
|
||||
// Clear cache directory
|
||||
cacheDir?.let { clearDirectory(it) }
|
||||
|
||||
// Clear files directory
|
||||
filesDir?.let { clearDirectory(it) }
|
||||
|
||||
// Clear external cache directory
|
||||
externalCacheDir?.let { clearDirectory(it) }
|
||||
|
||||
// Reset the flag and set a flag to show alert
|
||||
sharedPref.edit()
|
||||
.putBoolean("clearFilesOnLaunch", false)
|
||||
.putBoolean("shouldShowCacheClearedAlert", true)
|
||||
.apply()
|
||||
|
||||
Log.d("MainApplication", "Cache and files cleared on launch")
|
||||
} catch (e: Exception) {
|
||||
Log.e("MainApplication", "Error clearing files", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively clear all files in a directory
|
||||
*/
|
||||
private fun clearDirectory(dir: java.io.File) {
|
||||
if (!dir.exists()) return
|
||||
|
||||
dir.listFiles()?.forEach { file ->
|
||||
if (file.isDirectory) {
|
||||
clearDirectory(file)
|
||||
}
|
||||
try {
|
||||
file.delete()
|
||||
Log.d("MainApplication", "Deleted: ${file.absolutePath}")
|
||||
} catch (e: Exception) {
|
||||
Log.e("MainApplication", "Error deleting file: ${file.absolutePath}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -4,12 +4,13 @@ import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.util.Log
|
||||
import com.facebook.react.bridge.ReactApplicationContext
|
||||
import com.facebook.react.bridge.ReactContextBaseJavaModule
|
||||
import com.facebook.react.bridge.ReactMethod
|
||||
import com.facebook.react.bridge.Promise
|
||||
import java.util.UUID
|
||||
import com.facebook.react.module.annotations.ReactModule
|
||||
import io.bluewallet.bluewallet.NativeSettingsModuleSpec
|
||||
|
||||
class SettingsModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) {
|
||||
@ReactModule(name = SettingsModule.NAME)
|
||||
class SettingsModule(reactContext: ReactApplicationContext) : NativeSettingsModuleSpec(reactContext) {
|
||||
|
||||
private val sharedPref: SharedPreferences = reactContext.getSharedPreferences(
|
||||
"group.io.bluewallet.bluewallet",
|
||||
@ -22,10 +23,7 @@ class SettingsModule(reactContext: ReactApplicationContext) : ReactContextBaseJa
|
||||
private const val DEVICE_UID_COPY_KEY = "deviceUIDCopy"
|
||||
private const val CLEAR_FILES_ON_LAUNCH_KEY = "clearFilesOnLaunch"
|
||||
private const val DO_NOT_TRACK_KEY = "donottrack"
|
||||
}
|
||||
|
||||
override fun getName(): String {
|
||||
return "SettingsModule"
|
||||
const val NAME = "SettingsModule"
|
||||
}
|
||||
|
||||
/**
|
||||
@ -33,7 +31,7 @@ class SettingsModule(reactContext: ReactApplicationContext) : ReactContextBaseJa
|
||||
* Uses the same Android ID as react-native-device-info's getUniqueId()
|
||||
*/
|
||||
@ReactMethod
|
||||
fun initializeDeviceUID(promise: Promise) {
|
||||
override fun initializeDeviceUID(promise: Promise) {
|
||||
try {
|
||||
val isDoNotTrackEnabled = sharedPref.getString(DO_NOT_TRACK_KEY, "0") == "1"
|
||||
|
||||
@ -86,7 +84,7 @@ class SettingsModule(reactContext: ReactApplicationContext) : ReactContextBaseJa
|
||||
* Get the device UID
|
||||
*/
|
||||
@ReactMethod
|
||||
fun getDeviceUID(promise: Promise) {
|
||||
override fun getDeviceUID(promise: Promise) {
|
||||
try {
|
||||
val isDoNotTrackEnabled = sharedPref.getString(DO_NOT_TRACK_KEY, "0") == "1"
|
||||
|
||||
@ -107,7 +105,7 @@ class SettingsModule(reactContext: ReactApplicationContext) : ReactContextBaseJa
|
||||
* Get the device UID copy (for Settings display)
|
||||
*/
|
||||
@ReactMethod
|
||||
fun getDeviceUIDCopy(promise: Promise) {
|
||||
override fun getDeviceUIDCopy(promise: Promise) {
|
||||
try {
|
||||
val deviceUIDCopy = sharedPref.getString(DEVICE_UID_COPY_KEY, "")
|
||||
promise.resolve(deviceUIDCopy)
|
||||
@ -121,7 +119,7 @@ class SettingsModule(reactContext: ReactApplicationContext) : ReactContextBaseJa
|
||||
* Set the clearFilesOnLaunch preference
|
||||
*/
|
||||
@ReactMethod
|
||||
fun setClearFilesOnLaunch(value: Boolean, promise: Promise) {
|
||||
override fun setClearFilesOnLaunch(value: Boolean, promise: Promise) {
|
||||
try {
|
||||
sharedPref.edit()
|
||||
.putBoolean(CLEAR_FILES_ON_LAUNCH_KEY, value)
|
||||
@ -138,7 +136,7 @@ class SettingsModule(reactContext: ReactApplicationContext) : ReactContextBaseJa
|
||||
* Get the clearFilesOnLaunch preference
|
||||
*/
|
||||
@ReactMethod
|
||||
fun getClearFilesOnLaunch(promise: Promise) {
|
||||
override fun getClearFilesOnLaunch(promise: Promise) {
|
||||
try {
|
||||
val value = sharedPref.getBoolean(CLEAR_FILES_ON_LAUNCH_KEY, false)
|
||||
promise.resolve(value)
|
||||
@ -152,7 +150,7 @@ class SettingsModule(reactContext: ReactApplicationContext) : ReactContextBaseJa
|
||||
* Set Do Not Track setting
|
||||
*/
|
||||
@ReactMethod
|
||||
fun setDoNotTrack(enabled: Boolean, promise: Promise) {
|
||||
override fun setDoNotTrack(enabled: Boolean, promise: Promise) {
|
||||
try {
|
||||
val value = if (enabled) "1" else "0"
|
||||
sharedPref.edit()
|
||||
@ -184,7 +182,7 @@ class SettingsModule(reactContext: ReactApplicationContext) : ReactContextBaseJa
|
||||
* Get Do Not Track setting
|
||||
*/
|
||||
@ReactMethod
|
||||
fun getDoNotTrack(promise: Promise) {
|
||||
override fun getDoNotTrack(promise: Promise) {
|
||||
try {
|
||||
val value = sharedPref.getString(DO_NOT_TRACK_KEY, "0")
|
||||
val enabled = value == "1"
|
||||
@ -199,7 +197,7 @@ class SettingsModule(reactContext: ReactApplicationContext) : ReactContextBaseJa
|
||||
* Open the settings activity from JavaScript
|
||||
*/
|
||||
@ReactMethod
|
||||
fun openSettings(promise: Promise) {
|
||||
override fun openSettings(promise: Promise) {
|
||||
try {
|
||||
val intent = android.content.Intent(reactApplicationContext, SettingsActivity::class.java)
|
||||
intent.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
|
||||
@ -1,16 +1,29 @@
|
||||
package io.bluewallet.bluewallet
|
||||
|
||||
import com.facebook.react.ReactPackage
|
||||
import com.facebook.react.TurboReactPackage
|
||||
import com.facebook.react.bridge.NativeModule
|
||||
import com.facebook.react.bridge.ReactApplicationContext
|
||||
import com.facebook.react.module.model.ReactModuleInfo
|
||||
import com.facebook.react.module.model.ReactModuleInfoProvider
|
||||
import com.facebook.react.uimanager.ViewManager
|
||||
|
||||
class SettingsPackage : ReactPackage {
|
||||
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
|
||||
return listOf(SettingsModule(reactContext))
|
||||
class SettingsPackage : TurboReactPackage() {
|
||||
override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? {
|
||||
return if (name == SettingsModule.NAME) SettingsModule(reactContext) else null
|
||||
}
|
||||
|
||||
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
|
||||
return emptyList()
|
||||
override fun getReactModuleInfoProvider(): ReactModuleInfoProvider = ReactModuleInfoProvider {
|
||||
val moduleInfo = ReactModuleInfo(
|
||||
SettingsModule.NAME,
|
||||
SettingsModule.NAME,
|
||||
false, // canOverrideExistingModule
|
||||
false, // needsEagerInit
|
||||
false, // hasConstants
|
||||
false, // isCxxModule
|
||||
true // isTurboModule
|
||||
)
|
||||
mapOf(SettingsModule.NAME to moduleInfo)
|
||||
}
|
||||
|
||||
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> = emptyList()
|
||||
}
|
||||
|
||||
@ -205,16 +205,23 @@ class WidgetUpdateWorker(context: Context, workerParams: WorkerParameters) : Cor
|
||||
preferredCurrency: String?,
|
||||
preferredCurrencyLocale: String?
|
||||
) {
|
||||
val parsedPrevious = previousPrice?.toDoubleOrNull()
|
||||
val currencyFormat = getCurrencyFormat(preferredCurrency, preferredCurrencyLocale)
|
||||
|
||||
views.apply {
|
||||
setViewVisibility(R.id.loading_indicator, View.GONE)
|
||||
setTextViewText(R.id.price_value, currencyFormat.format(previousPrice?.toDouble()?.toInt()))
|
||||
setTextViewText(R.id.last_updated_time, currentTime)
|
||||
setViewVisibility(R.id.price_value, View.VISIBLE)
|
||||
setViewVisibility(R.id.last_updated_label, View.VISIBLE)
|
||||
setViewVisibility(R.id.last_updated_time, View.VISIBLE)
|
||||
setViewVisibility(R.id.price_arrow_container, View.GONE)
|
||||
if (parsedPrevious != null) {
|
||||
setTextViewText(R.id.price_value, currencyFormat.format(parsedPrevious.toInt()))
|
||||
setViewVisibility(R.id.price_value, View.VISIBLE)
|
||||
setViewVisibility(R.id.last_updated_label, View.VISIBLE)
|
||||
setViewVisibility(R.id.last_updated_time, View.VISIBLE)
|
||||
} else {
|
||||
setViewVisibility(R.id.price_value, View.GONE)
|
||||
setViewVisibility(R.id.last_updated_label, View.GONE)
|
||||
setViewVisibility(R.id.last_updated_time, View.GONE)
|
||||
}
|
||||
setTextViewText(R.id.last_updated_time, currentTime)
|
||||
}
|
||||
}
|
||||
|
||||
@ -226,37 +233,45 @@ class WidgetUpdateWorker(context: Context, workerParams: WorkerParameters) : Cor
|
||||
preferredCurrency: String?,
|
||||
preferredCurrencyLocale: String?
|
||||
) {
|
||||
val currentPrice = fetchedPrice.toDouble().toInt()
|
||||
val currentPrice = fetchedPrice.toDoubleOrNull()?.toInt()
|
||||
val currencyFormat = getCurrencyFormat(preferredCurrency, preferredCurrencyLocale)
|
||||
|
||||
views.apply {
|
||||
setViewVisibility(R.id.loading_indicator, View.GONE)
|
||||
setTextViewText(R.id.price_value, currencyFormat.format(currentPrice))
|
||||
setTextViewText(R.id.last_updated_time, currentTime)
|
||||
setViewVisibility(R.id.price_value, View.VISIBLE)
|
||||
setViewVisibility(R.id.last_updated_label, View.VISIBLE)
|
||||
setViewVisibility(R.id.last_updated_time, View.VISIBLE)
|
||||
if (currentPrice != null) {
|
||||
setTextViewText(R.id.price_value, currencyFormat.format(currentPrice))
|
||||
setTextViewText(R.id.last_updated_time, currentTime)
|
||||
setViewVisibility(R.id.price_value, View.VISIBLE)
|
||||
setViewVisibility(R.id.last_updated_label, View.VISIBLE)
|
||||
setViewVisibility(R.id.last_updated_time, View.VISIBLE)
|
||||
|
||||
if (previousPrice != null) {
|
||||
setViewVisibility(R.id.price_arrow_container, View.VISIBLE)
|
||||
setTextViewText(R.id.previous_price, currencyFormat.format(previousPrice.toDouble().toInt()))
|
||||
setImageViewResource(
|
||||
R.id.price_arrow,
|
||||
if (currentPrice > previousPrice.toDouble().toInt()) android.R.drawable.arrow_up_float else android.R.drawable.arrow_down_float
|
||||
)
|
||||
val previousParsed = previousPrice?.toDoubleOrNull()?.toInt()
|
||||
if (previousParsed != null) {
|
||||
setViewVisibility(R.id.price_arrow_container, View.VISIBLE)
|
||||
setTextViewText(R.id.previous_price, currencyFormat.format(previousParsed))
|
||||
setImageViewResource(
|
||||
R.id.price_arrow,
|
||||
if (currentPrice > previousParsed) android.R.drawable.arrow_up_float else android.R.drawable.arrow_down_float
|
||||
)
|
||||
} else {
|
||||
setViewVisibility(R.id.price_arrow_container, View.GONE)
|
||||
}
|
||||
} else {
|
||||
// Fallback to loading state if parsing failed
|
||||
setViewVisibility(R.id.price_value, View.GONE)
|
||||
setViewVisibility(R.id.last_updated_label, View.GONE)
|
||||
setViewVisibility(R.id.last_updated_time, View.GONE)
|
||||
setViewVisibility(R.id.price_arrow_container, View.GONE)
|
||||
setViewVisibility(R.id.loading_indicator, View.VISIBLE)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getCurrencyFormat(currencyCode: String?, localeString: String?): NumberFormat {
|
||||
val localeParts = localeString?.split("-") ?: listOf("en", "US")
|
||||
val locale = if (localeParts.size == 2) {
|
||||
Locale(localeParts[0], localeParts[1])
|
||||
} else {
|
||||
Locale.getDefault()
|
||||
}
|
||||
val locale = localeString
|
||||
?.let { runCatching { Locale.forLanguageTag(it) }.getOrNull() }
|
||||
?.takeIf { it.language.isNotBlank() }
|
||||
?: Locale.getDefault()
|
||||
val currencyFormat = NumberFormat.getCurrencyInstance(locale)
|
||||
val currency = try {
|
||||
Currency.getInstance(currencyCode ?: "USD")
|
||||
|
||||
@ -23,7 +23,11 @@ class CustomSegmentedControl @JvmOverloads constructor(
|
||||
|
||||
private val toggleGroup: MaterialButtonToggleGroup
|
||||
private var currentSelectedIndex: Int = 0
|
||||
private var onChangeEvent: ((WritableMap) -> Unit)? = null
|
||||
private var backgroundColorProp: Int? = null
|
||||
private var tintColorProp: Int? = null
|
||||
private var textColorProp: Int? = null
|
||||
private var momentaryProp: Boolean = false
|
||||
private var isEnabledProp: Boolean = true
|
||||
|
||||
var values: Array<String> = emptyArray()
|
||||
set(value) {
|
||||
@ -55,6 +59,9 @@ class CustomSegmentedControl @JvmOverloads constructor(
|
||||
if (newIndex != -1 && newIndex != currentSelectedIndex) {
|
||||
currentSelectedIndex = newIndex
|
||||
emitChangeEvent(newIndex)
|
||||
if (momentaryProp) {
|
||||
toggleGroup.clearChecked()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -79,6 +86,7 @@ class CustomSegmentedControl @JvmOverloads constructor(
|
||||
isCheckable = true
|
||||
|
||||
strokeWidth = 2
|
||||
applyEnabledState()
|
||||
|
||||
val cornerRadius = resources.getDimensionPixelSize(
|
||||
com.google.android.material.R.dimen.mtrl_btn_corner_radius
|
||||
@ -111,10 +119,11 @@ class CustomSegmentedControl @JvmOverloads constructor(
|
||||
for (i in 0 until toggleGroup.childCount) {
|
||||
val button = toggleGroup.getChildAt(i) as? MaterialButton ?: continue
|
||||
|
||||
val selectedBgColor = ContextCompat.getColor(context, R.color.button_background_color)
|
||||
val unselectedBgColor = ContextCompat.getColor(context, R.color.button_disabled_background_color)
|
||||
val selectedTextColor = ContextCompat.getColor(context, R.color.button_text_color)
|
||||
val unselectedTextColor = ContextCompat.getColor(context, R.color.button_disabled_text_color)
|
||||
val selectedBgColor = tintColorProp ?: ContextCompat.getColor(context, R.color.button_background_color)
|
||||
val unselectedBgColor = backgroundColorProp ?: ContextCompat.getColor(context, R.color.button_disabled_background_color)
|
||||
val resolvedTextColor = textColorProp ?: ContextCompat.getColor(context, R.color.button_text_color)
|
||||
val selectedTextColor = resolvedTextColor
|
||||
val unselectedTextColor = textColorProp ?: ContextCompat.getColor(context, R.color.button_disabled_text_color)
|
||||
val borderColor = ContextCompat.getColor(context, R.color.form_border_color)
|
||||
val rippleColor = ContextCompat.getColor(context, R.color.ripple_color)
|
||||
val rippleColorSelected = ContextCompat.getColor(context, R.color.ripple_color_selected)
|
||||
@ -155,9 +164,36 @@ class CustomSegmentedControl @JvmOverloads constructor(
|
||||
button.setTextColor(textColorStateList)
|
||||
button.strokeColor = strokeColorStateList
|
||||
button.rippleColor = rippleColorStateList
|
||||
button.isEnabled = isEnabledProp
|
||||
}
|
||||
}
|
||||
|
||||
fun setBackgroundColorProp(color: String?) {
|
||||
backgroundColorProp = parseColor(color)
|
||||
updateButtonColors()
|
||||
}
|
||||
|
||||
fun setTintColorProp(color: String?) {
|
||||
tintColorProp = parseColor(color)
|
||||
updateButtonColors()
|
||||
}
|
||||
|
||||
fun setTextColorProp(color: String?) {
|
||||
textColorProp = parseColor(color)
|
||||
updateButtonColors()
|
||||
}
|
||||
|
||||
fun setMomentaryProp(momentary: Boolean) {
|
||||
momentaryProp = momentary
|
||||
toggleGroup.isSelectionRequired = !momentary
|
||||
}
|
||||
|
||||
fun setEnabledProp(enabled: Boolean) {
|
||||
isEnabledProp = enabled
|
||||
toggleGroup.isEnabled = enabled
|
||||
applyEnabledState()
|
||||
}
|
||||
|
||||
private fun updateSelectedSegment() {
|
||||
if (values.isNotEmpty() && currentSelectedIndex in 0 until values.size) {
|
||||
val buttonId = getButtonIdAtIndex(currentSelectedIndex)
|
||||
@ -196,18 +232,29 @@ class CustomSegmentedControl @JvmOverloads constructor(
|
||||
eventDispatcher?.dispatchEvent(ChangeEvent(surfaceId, id, event))
|
||||
}
|
||||
|
||||
private fun applyEnabledState() {
|
||||
for (i in 0 until toggleGroup.childCount) {
|
||||
val button = toggleGroup.getChildAt(i) as? MaterialButton ?: continue
|
||||
button.isEnabled = isEnabledProp
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseColor(color: String?): Int? {
|
||||
return try {
|
||||
color?.let { Color.parseColor(it) }
|
||||
} catch (_: IllegalArgumentException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private inner class ChangeEvent(
|
||||
surfaceId: Int,
|
||||
viewId: Int,
|
||||
private val eventData: WritableMap
|
||||
) : Event<ChangeEvent>(surfaceId, viewId) {
|
||||
|
||||
override fun getEventName(): String = "onChangeEvent"
|
||||
override fun getEventName(): String = "topChange"
|
||||
|
||||
override fun getEventData(): WritableMap = eventData
|
||||
}
|
||||
|
||||
fun setOnChangeEvent(callback: ((WritableMap) -> Unit)?) {
|
||||
onChangeEvent = callback
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,12 +5,17 @@ import com.facebook.react.common.MapBuilder
|
||||
import com.facebook.react.uimanager.SimpleViewManager
|
||||
import com.facebook.react.uimanager.ThemedReactContext
|
||||
import com.facebook.react.uimanager.annotations.ReactProp
|
||||
import com.facebook.react.module.annotations.ReactModule
|
||||
import com.facebook.react.viewmanagers.CustomSegmentedControlManagerInterface
|
||||
|
||||
class CustomSegmentedControlManager : SimpleViewManager<CustomSegmentedControl>() {
|
||||
@ReactModule(name = CustomSegmentedControlManager.REACT_CLASS)
|
||||
class CustomSegmentedControlManager : SimpleViewManager<CustomSegmentedControl>(),
|
||||
CustomSegmentedControlManagerInterface<CustomSegmentedControl> {
|
||||
|
||||
companion object {
|
||||
const val REACT_CLASS = "CustomSegmentedControl"
|
||||
private const val ON_CHANGE_EVENT = "onChangeEvent"
|
||||
private const val TOP_CHANGE = "topChange"
|
||||
private const val REGISTRATION_NAME = "onChange"
|
||||
}
|
||||
|
||||
override fun getName(): String = REACT_CLASS
|
||||
@ -20,7 +25,7 @@ class CustomSegmentedControlManager : SimpleViewManager<CustomSegmentedControl>(
|
||||
}
|
||||
|
||||
@ReactProp(name = "values")
|
||||
fun setValues(view: CustomSegmentedControl, values: ReadableArray?) {
|
||||
override fun setValues(view: CustomSegmentedControl, values: ReadableArray?) {
|
||||
val valuesArray = values?.let { array ->
|
||||
Array(array.size()) { index ->
|
||||
array.getString(index) ?: ""
|
||||
@ -31,13 +36,44 @@ class CustomSegmentedControlManager : SimpleViewManager<CustomSegmentedControl>(
|
||||
}
|
||||
|
||||
@ReactProp(name = "selectedIndex", defaultInt = 0)
|
||||
fun setSelectedIndex(view: CustomSegmentedControl, selectedIndex: Int) {
|
||||
override fun setSelectedIndex(view: CustomSegmentedControl, selectedIndex: Int) {
|
||||
view.selectedIndex = selectedIndex
|
||||
}
|
||||
|
||||
override fun getExportedCustomDirectEventTypeConstants(): Map<String, Any>? {
|
||||
@ReactProp(name = "backgroundColor")
|
||||
override fun setBackgroundColor(view: CustomSegmentedControl, value: String?) {
|
||||
view.setBackgroundColorProp(value)
|
||||
}
|
||||
|
||||
@ReactProp(name = "tintColor")
|
||||
override fun setTintColor(view: CustomSegmentedControl, value: String?) {
|
||||
view.setTintColorProp(value)
|
||||
}
|
||||
|
||||
@ReactProp(name = "textColor")
|
||||
override fun setTextColor(view: CustomSegmentedControl, value: String?) {
|
||||
view.setTextColorProp(value)
|
||||
}
|
||||
|
||||
@ReactProp(name = "momentary", defaultBoolean = false)
|
||||
override fun setMomentary(view: CustomSegmentedControl, value: Boolean) {
|
||||
view.setMomentaryProp(value)
|
||||
}
|
||||
|
||||
@ReactProp(name = "enabled", defaultBoolean = true)
|
||||
override fun setEnabled(view: CustomSegmentedControl, value: Boolean) {
|
||||
view.setEnabledProp(value)
|
||||
}
|
||||
|
||||
override fun getExportedCustomBubblingEventTypeConstants(): Map<String, Any>? {
|
||||
return MapBuilder.builder<String, Any>()
|
||||
.put(ON_CHANGE_EVENT, MapBuilder.of("registrationName", ON_CHANGE_EVENT))
|
||||
.put(
|
||||
TOP_CHANGE,
|
||||
MapBuilder.of(
|
||||
"phasedRegistrationNames",
|
||||
MapBuilder.of("bubbled", REGISTRATION_NAME, "captured", "${REGISTRATION_NAME}Capture")
|
||||
)
|
||||
)
|
||||
.build()
|
||||
}
|
||||
|
||||
|
||||
BIN
android/app/src/main/res/drawable/notification_icon.png
Normal file
BIN
android/app/src/main/res/drawable/notification_icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
@ -3,23 +3,22 @@
|
||||
buildscript {
|
||||
ext {
|
||||
minSdkVersion = 24
|
||||
buildToolsVersion = "35.0.0"
|
||||
compileSdkVersion = 35
|
||||
targetSdkVersion = 35
|
||||
buildToolsVersion = "36.0.0"
|
||||
compileSdkVersion = 36
|
||||
targetSdkVersion = 36
|
||||
googlePlayServicesVersion = "16.+"
|
||||
googlePlayServicesIidVersion = "16.0.1"
|
||||
firebaseVersion = "17.3.4"
|
||||
firebaseMessagingVersion = "21.1.0"
|
||||
firebaseVersion = "21.1.0"
|
||||
ndkVersion = "27.1.12297006"
|
||||
kotlin_version = '2.0.21'
|
||||
kotlinVersion = '2.0.21'
|
||||
kotlin_version = '2.1.20'
|
||||
kotlinVersion = '2.1.20'
|
||||
}
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
dependencies {
|
||||
classpath("com.android.tools.build:gradle")
|
||||
classpath("com.android.tools.build:gradle:8.7.2")
|
||||
classpath("com.facebook.react:react-native-gradle-plugin")
|
||||
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version")
|
||||
classpath 'com.google.gms:google-services:4.4.4' // Google Services plugin
|
||||
@ -27,6 +26,33 @@ buildscript {
|
||||
}
|
||||
}
|
||||
|
||||
// Gradle 9 removes jcenter(); add a shim that redirects any jcenter() call to mavenCentral()
|
||||
def addJcenterShim = { repoContainer, logger ->
|
||||
def mc = repoContainer.metaClass
|
||||
if (!mc.respondsTo(repoContainer, 'jcenter')) {
|
||||
mc.jcenter << { Closure config = null ->
|
||||
def repo = repoContainer.mavenCentral()
|
||||
if (config != null) {
|
||||
config.delegate = repo
|
||||
config.resolveStrategy = Closure.DELEGATE_FIRST
|
||||
config.call(repo)
|
||||
}
|
||||
logger.lifecycle("Redirected jcenter() to mavenCentral()")
|
||||
return repo
|
||||
}
|
||||
}
|
||||
if (!mc.respondsTo(repoContainer, 'methodMissing')) {
|
||||
mc.methodMissing = { String name, args ->
|
||||
if (name == 'jcenter') {
|
||||
def repo = repoContainer.mavenCentral()
|
||||
logger.lifecycle("Redirected jcenter() (methodMissing) to mavenCentral()")
|
||||
return repo
|
||||
}
|
||||
throw new MissingMethodException(name, repoContainer.class, args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
allprojects {
|
||||
repositories {
|
||||
maven {
|
||||
@ -54,12 +80,50 @@ allprojects {
|
||||
}
|
||||
}
|
||||
|
||||
subprojects {
|
||||
afterEvaluate {project ->
|
||||
if (project.hasProperty("android")) {
|
||||
// Apply jcenter shim very early for every project before its build.gradle is evaluated
|
||||
gradle.beforeProject { project ->
|
||||
addJcenterShim(project.repositories, project.logger)
|
||||
if (project.buildscript != null) {
|
||||
addJcenterShim(project.buildscript.repositories, project.logger)
|
||||
}
|
||||
}
|
||||
|
||||
// Apply to root as well
|
||||
addJcenterShim(repositories, logger)
|
||||
if (buildscript != null) {
|
||||
addJcenterShim(buildscript.repositories, logger)
|
||||
}
|
||||
|
||||
subprojects { project ->
|
||||
// Remove and block any jcenter() repositories at both project and buildscript levels
|
||||
def scrub = { repoContainer ->
|
||||
repoContainer.all { repo ->
|
||||
if (repo instanceof org.gradle.api.artifacts.repositories.MavenArtifactRepository &&
|
||||
repo.url?.toString()?.contains('jcenter')) {
|
||||
project.logger.lifecycle("Removing jcenter() from ${project.path}")
|
||||
repoContainer.remove(repo)
|
||||
}
|
||||
}
|
||||
repoContainer.whenObjectAdded { repo ->
|
||||
if (repo instanceof org.gradle.api.artifacts.repositories.MavenArtifactRepository &&
|
||||
repo.url?.toString()?.contains('jcenter')) {
|
||||
project.logger.lifecycle("Blocking jcenter() from ${project.path}")
|
||||
repoContainer.remove(repo)
|
||||
repoContainer.mavenCentral()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
scrub(project.repositories)
|
||||
if (project.buildscript != null) {
|
||||
scrub(project.buildscript.repositories)
|
||||
}
|
||||
|
||||
afterEvaluate {proj ->
|
||||
if (proj.hasProperty("android")) {
|
||||
android {
|
||||
buildToolsVersion "35.0.0"
|
||||
compileSdkVersion 35
|
||||
buildToolsVersion "36.0.0"
|
||||
compileSdkVersion 36
|
||||
defaultConfig {
|
||||
minSdkVersion 24
|
||||
}
|
||||
@ -68,12 +132,30 @@ subprojects {
|
||||
tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile) {
|
||||
// FIXME: next line should be removed when https://github.com/wix/Detox/issues/4678 is fixed
|
||||
kotlinOptions.freeCompilerArgs += ["-Xopt-in=kotlin.ExperimentalStdlibApi"]
|
||||
if (project.plugins.hasPlugin("com.android.application") || project.plugins.hasPlugin("com.android.library")) {
|
||||
if (proj.plugins.hasPlugin("com.android.application") || proj.plugins.hasPlugin("com.android.library")) {
|
||||
kotlinOptions.jvmTarget = android.compileOptions.sourceCompatibility
|
||||
} else {
|
||||
kotlinOptions.jvmTarget = sourceCompatibility
|
||||
}
|
||||
}
|
||||
|
||||
if (proj.name == "react-native-reanimated" && proj.hasProperty("android")) {
|
||||
// Wire Reanimated's generated codegen sources so WorkletsModule spec is visible under new architecture
|
||||
proj.android.sourceSets.main.java.srcDir("${proj.buildDir}/generated/source/codegen/java")
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
// Final guard: fail fast if any jcenter repository slips through
|
||||
gradle.projectsEvaluated {
|
||||
allprojects { proj ->
|
||||
def offenders = proj.repositories.findAll { repo ->
|
||||
repo instanceof org.gradle.api.artifacts.repositories.MavenArtifactRepository &&
|
||||
repo.url?.toString()?.contains('jcenter')
|
||||
}
|
||||
if (!offenders.isEmpty()) {
|
||||
throw new GradleException("jcenter() detected in ${proj.path}; remove or replace with mavenCentral()");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -32,8 +32,16 @@ reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64
|
||||
# your application. You should enable this flag either if you want
|
||||
# to write custom TurboModules/Fabric components OR use libraries that
|
||||
# are providing them.
|
||||
newArchEnabled=false
|
||||
newArchEnabled=true
|
||||
|
||||
# Use this property to enable or disable the Hermes JS engine.
|
||||
# If set to false, you will be using JSC instead.
|
||||
hermesEnabled=true
|
||||
hermesEnabled=true
|
||||
|
||||
# Use this property to enable edge-to-edge display support.
|
||||
# This allows your app to draw behind system bars for an immersive UI.
|
||||
# Note: Only works with ReactActivity and should not be used with custom Activity.
|
||||
edgeToEdgeEnabled=true
|
||||
|
||||
# Use legacy NDK symbol upload for Bugsnag (avoids v5.26.0 requirement)
|
||||
bugsnag.useLegacyNdkSymbolUpload=true
|
||||
BIN
android/gradle/wrapper/gradle-wrapper.jar
vendored
BIN
android/gradle/wrapper/gradle-wrapper.jar
vendored
Binary file not shown.
@ -1,6 +1,6 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-all.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.0.0-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
|
||||
@ -7,3 +7,29 @@ includeBuild('../node_modules/@react-native/gradle-plugin')
|
||||
include ':detox'
|
||||
project(':detox').projectDir = new File(rootProject.projectDir, '../node_modules/detox/android/detox')
|
||||
|
||||
// Ensure any jcenter() repos declared by subprojects are removed before their build.gradle is evaluated
|
||||
gradle.beforeProject { proj ->
|
||||
def stripJcenter = { repoContainer ->
|
||||
repoContainer.all { repo ->
|
||||
if (repo instanceof org.gradle.api.artifacts.repositories.MavenArtifactRepository &&
|
||||
repo.url?.toString()?.contains('jcenter')) {
|
||||
proj.logger.lifecycle("Settings.gradle: removing jcenter() from ${proj.path}")
|
||||
repoContainer.remove(repo)
|
||||
}
|
||||
}
|
||||
repoContainer.whenObjectAdded { repo ->
|
||||
if (repo instanceof org.gradle.api.artifacts.repositories.MavenArtifactRepository &&
|
||||
repo.url?.toString()?.contains('jcenter')) {
|
||||
proj.logger.lifecycle("Settings.gradle: blocking jcenter() from ${proj.path}")
|
||||
repoContainer.remove(repo)
|
||||
repoContainer.mavenCentral()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stripJcenter(proj.repositories)
|
||||
if (proj.buildscript != null) {
|
||||
stripJcenter(proj.buildscript.repositories)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
module.exports = {
|
||||
presets: ['module:@react-native/babel-preset'],
|
||||
plugins: ['react-native-reanimated/plugin'], // required by react-native-reanimated v2 https://docs.swmansion.com/react-native-reanimated/docs/installation/
|
||||
plugins: ['react-native-worklets/plugin'],
|
||||
};
|
||||
|
||||
1
blue_modules/NativeEventEmitter.ts
Normal file
1
blue_modules/NativeEventEmitter.ts
Normal file
@ -0,0 +1 @@
|
||||
export { default, type Spec } from '../codegen/NativeEventEmitter';
|
||||
1
blue_modules/NativeMenuElementsEmitter.ts
Normal file
1
blue_modules/NativeMenuElementsEmitter.ts
Normal file
@ -0,0 +1 @@
|
||||
export { default, type Spec } from '../codegen/NativeMenuElementsEmitter';
|
||||
1
blue_modules/NativeWidgetHelper.ts
Normal file
1
blue_modules/NativeWidgetHelper.ts
Normal file
@ -0,0 +1 @@
|
||||
export { default, type Spec } from '../codegen/NativeWidgetHelper';
|
||||
@ -1,4 +1,5 @@
|
||||
import { NativeModules, Platform } from 'react-native';
|
||||
import { Platform } from 'react-native';
|
||||
import NativeSettingsModule from '../codegen/NativeSettingsModule';
|
||||
|
||||
interface SettingsModuleInterface {
|
||||
/**
|
||||
@ -46,6 +47,7 @@ interface SettingsModuleInterface {
|
||||
}
|
||||
|
||||
// Only available on Android
|
||||
const SettingsModule: SettingsModuleInterface | null = Platform.OS === 'android' ? NativeModules.SettingsModule : null;
|
||||
const nativeModule = NativeSettingsModule ?? null;
|
||||
const SettingsModule: SettingsModuleInterface | null = Platform.OS === 'android' ? nativeModule : null;
|
||||
|
||||
export default SettingsModule;
|
||||
|
||||
@ -1,9 +1,13 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import PushNotificationIOS from '@react-native-community/push-notification-ios';
|
||||
import { AppState, AppStateStatus, Platform } from 'react-native';
|
||||
import { AppState, AppStateStatus, EmitterSubscription, Platform } from 'react-native';
|
||||
import { getApplicationName, getSystemName, getSystemVersion, getVersion, hasGmsSync, hasHmsSync } from 'react-native-device-info';
|
||||
import {
|
||||
Notification as RNNotification,
|
||||
NotificationBackgroundFetchResult,
|
||||
NotificationCompletion,
|
||||
Notifications,
|
||||
} from 'react-native-notifications';
|
||||
import { checkNotifications, requestNotifications, RESULTS } from 'react-native-permissions';
|
||||
import PushNotification, { ReceivedNotification } from 'react-native-push-notification';
|
||||
import loc from '../loc';
|
||||
import { groundControlUri } from './constants';
|
||||
import { fetch } from '../util/fetch';
|
||||
@ -11,37 +15,162 @@ import { fetch } from '../util/fetch';
|
||||
const PUSH_TOKEN = 'PUSH_TOKEN';
|
||||
const GROUNDCONTROL_BASE_URI = 'GROUNDCONTROL_BASE_URI';
|
||||
const NOTIFICATIONS_STORAGE = 'NOTIFICATIONS_STORAGE';
|
||||
const ANDROID_NOTIFICATION_CHANNEL_ID = 'channel_01';
|
||||
export const NOTIFICATIONS_NO_AND_DONT_ASK_FLAG = 'NOTIFICATIONS_NO_AND_DONT_ASK_FLAG';
|
||||
let alreadyConfigured = false;
|
||||
let baseURI = groundControlUri;
|
||||
let notificationSubscriptions: EmitterSubscription[] = [];
|
||||
let onProcessNotificationsHandler: undefined | (() => void | Promise<void>);
|
||||
const handledNotificationKeys = new Set<string>();
|
||||
let pendingRegistrationPromise: Promise<boolean> | null = null;
|
||||
let pendingRegistrationResolve: ((value: boolean) => void) | null = null;
|
||||
let pendingRegistrationTimeout: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
type TPushToken = {
|
||||
token: string;
|
||||
os: string; // its actually ('ios' | 'android'), but types for the lib are a bit more generic...
|
||||
os: 'ios' | 'android';
|
||||
};
|
||||
|
||||
// thats unwrapped `ReceivedNotification`, withall `data` fields inline
|
||||
type TPayload = {
|
||||
// inherited from `ReceivedNotification`:
|
||||
subText?: string;
|
||||
title?: string;
|
||||
identifier?: string;
|
||||
message?: string | object;
|
||||
foreground: boolean;
|
||||
userInteraction: boolean;
|
||||
// hopefully stuffed in `data` and uwrapped when received:
|
||||
address: string;
|
||||
txid: string;
|
||||
type: number;
|
||||
hash: string;
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
function deepClone<T>(obj: T): T {
|
||||
return JSON.parse(JSON.stringify(obj));
|
||||
}
|
||||
|
||||
const createPushToken = (deviceToken: string): TPushToken => ({
|
||||
token: deviceToken,
|
||||
os: Platform.OS as TPushToken['os'],
|
||||
});
|
||||
|
||||
const settlePendingRegistration = (value: boolean) => {
|
||||
if (!pendingRegistrationResolve) return;
|
||||
const resolve = pendingRegistrationResolve;
|
||||
pendingRegistrationResolve = null;
|
||||
pendingRegistrationPromise = null;
|
||||
if (pendingRegistrationTimeout) {
|
||||
clearTimeout(pendingRegistrationTimeout);
|
||||
pendingRegistrationTimeout = undefined;
|
||||
}
|
||||
resolve(value);
|
||||
};
|
||||
|
||||
const waitForRemoteRegistration = (timeoutMs = 10_000): Promise<boolean> => {
|
||||
if (pendingRegistrationPromise) return pendingRegistrationPromise;
|
||||
pendingRegistrationPromise = new Promise<boolean>(resolve => {
|
||||
pendingRegistrationResolve = resolve;
|
||||
pendingRegistrationTimeout = setTimeout(() => {
|
||||
settlePendingRegistration(false);
|
||||
}, timeoutMs);
|
||||
});
|
||||
Notifications.registerRemoteNotifications();
|
||||
return pendingRegistrationPromise;
|
||||
};
|
||||
|
||||
const ensureAndroidNotificationChannel = () => {
|
||||
if (Platform.OS !== 'android') return;
|
||||
|
||||
Notifications.setNotificationChannel({
|
||||
channelId: ANDROID_NOTIFICATION_CHANNEL_ID,
|
||||
name: 'BlueWallet notifications',
|
||||
description: 'Notifications about incoming payments',
|
||||
importance: 4,
|
||||
enableVibration: true,
|
||||
showBadge: true,
|
||||
});
|
||||
};
|
||||
|
||||
const getNotificationKey = (payload: Partial<TPayload>, notification?: RNNotification) => {
|
||||
return JSON.stringify({
|
||||
identifier: notification?.identifier ?? payload.identifier ?? '',
|
||||
type: payload.type ?? '',
|
||||
hash: payload.hash ?? '',
|
||||
txid: payload.txid ?? '',
|
||||
address: payload.address ?? '',
|
||||
message: payload.message ?? '',
|
||||
});
|
||||
};
|
||||
|
||||
const markNotificationHandled = (key: string) => {
|
||||
handledNotificationKeys.add(key);
|
||||
if (handledNotificationKeys.size > 100) {
|
||||
const oldestKey = handledNotificationKeys.values().next().value;
|
||||
if (oldestKey) handledNotificationKeys.delete(oldestKey);
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeNotificationPayload = (notification: RNNotification, status: Pick<TPayload, 'foreground' | 'userInteraction'>): TPayload => {
|
||||
const rawPayload =
|
||||
notification.payload && typeof notification.payload === 'object' ? (deepClone(notification.payload) as Record<string, any>) : {};
|
||||
const nestedPayload = rawPayload.data && typeof rawPayload.data === 'object' ? rawPayload.data : {};
|
||||
const nestedData = nestedPayload.data && typeof nestedPayload.data === 'object' ? nestedPayload.data : {};
|
||||
|
||||
const payload: TPayload = {
|
||||
...rawPayload,
|
||||
...nestedPayload,
|
||||
...nestedData,
|
||||
title: notification.title ?? rawPayload.title,
|
||||
subText: rawPayload.subText ?? rawPayload.subtitle ?? notification.title,
|
||||
message: rawPayload.message ?? notification.body,
|
||||
identifier: notification.identifier,
|
||||
foreground: status.foreground,
|
||||
userInteraction: status.userInteraction,
|
||||
} as TPayload;
|
||||
|
||||
delete payload.data;
|
||||
return payload;
|
||||
};
|
||||
|
||||
const storeIncomingNotification = async (
|
||||
notification: RNNotification,
|
||||
status: Pick<TPayload, 'foreground' | 'userInteraction'>,
|
||||
completion?: ((response: NotificationCompletion) => void) | ((response: NotificationBackgroundFetchResult) => void),
|
||||
) => {
|
||||
try {
|
||||
const payload = normalizeNotificationPayload(notification, status);
|
||||
const notificationKey = getNotificationKey(payload, notification);
|
||||
if (handledNotificationKeys.has(notificationKey)) {
|
||||
return;
|
||||
}
|
||||
markNotificationHandled(notificationKey);
|
||||
|
||||
if (!payload.subText && !payload.message) {
|
||||
console.warn('Notification missing required fields:', payload);
|
||||
return;
|
||||
}
|
||||
|
||||
await addNotification(payload);
|
||||
|
||||
if (payload.foreground && onProcessNotificationsHandler) {
|
||||
await onProcessNotificationsHandler();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to store incoming notification:', error);
|
||||
} finally {
|
||||
if (completion) {
|
||||
if (status.foreground) {
|
||||
(completion as (response: NotificationCompletion) => void)({ alert: false, sound: false, badge: false });
|
||||
} else {
|
||||
(completion as (response: NotificationBackgroundFetchResult) => void)(NotificationBackgroundFetchResult.NO_DATA);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const checkAndroidNotificationPermission = async () => {
|
||||
try {
|
||||
const { status } = await checkNotifications();
|
||||
console.debug('Notification permission check:', status);
|
||||
console.log('Notification permission check:', status);
|
||||
return status === RESULTS.GRANTED;
|
||||
} catch (err) {
|
||||
console.error('Failed to check notification permission:', err);
|
||||
@ -90,22 +219,14 @@ export const cleanUserOptOutFlag = async () => {
|
||||
* Should be called when user is most interested in receiving push notifications.
|
||||
* If we dont have a token it will show alert asking whether
|
||||
* user wants to receive notifications, and if yes - will configure push notifications.
|
||||
* FYI, on Android permissions are acquired when app is installed, so basically we dont need to ask,
|
||||
* we can just call `configure`. On iOS its different, and calling `configure` triggers system's dialog box.
|
||||
*
|
||||
* @returns {Promise<boolean>} TRUE if permissions were obtained, FALSE otherwise
|
||||
*/
|
||||
/**
|
||||
* Attempts to obtain permissions and configure notifications.
|
||||
* Shows a rationale on Android if permissions are needed.
|
||||
*
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
export const tryToObtainPermissions = async () => {
|
||||
console.debug('tryToObtainPermissions: Starting user-triggered permission request');
|
||||
export const tryToObtainPermissions = async (): Promise<boolean> => {
|
||||
console.log('tryToObtainPermissions: Starting user-triggered permission request');
|
||||
|
||||
if (!isNotificationsCapable) {
|
||||
console.debug('tryToObtainPermissions: Device not capable');
|
||||
console.log('tryToObtainPermissions: Device not capable');
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -122,7 +243,7 @@ export const tryToObtainPermissions = async () => {
|
||||
Platform.OS === 'android' && Platform.Version < 33 ? rationale : undefined,
|
||||
);
|
||||
if (status !== RESULTS.GRANTED) {
|
||||
console.debug('tryToObtainPermissions: Permission denied');
|
||||
console.log('tryToObtainPermissions: Permission denied');
|
||||
return false;
|
||||
}
|
||||
return configureNotifications();
|
||||
@ -141,7 +262,7 @@ export const tryToObtainPermissions = async () => {
|
||||
* @returns {Promise<object>} Response object from API rest call
|
||||
*/
|
||||
export const majorTomToGroundControl = async (addresses: string[], hashes: string[], txids: string[]) => {
|
||||
console.debug('majorTomToGroundControl: Starting notification registration', {
|
||||
console.log('majorTomToGroundControl: Starting notification registration', {
|
||||
addressCount: addresses?.length,
|
||||
hashCount: hashes?.length,
|
||||
txidCount: txids?.length,
|
||||
@ -159,7 +280,7 @@ export const majorTomToGroundControl = async (addresses: string[], hashes: strin
|
||||
}
|
||||
|
||||
const pushToken = await getPushToken();
|
||||
console.debug('majorTomToGroundControl: Retrieved push token:', !!pushToken);
|
||||
console.log('majorTomToGroundControl: Retrieved push token:', !!pushToken);
|
||||
if (!pushToken || !pushToken.token || !pushToken.os) {
|
||||
return;
|
||||
}
|
||||
@ -174,7 +295,7 @@ export const majorTomToGroundControl = async (addresses: string[], hashes: strin
|
||||
|
||||
let response;
|
||||
try {
|
||||
console.debug('majorTomToGroundControl: Sending request to:', `${baseURI}/majorTomToGroundControl`);
|
||||
console.log('majorTomToGroundControl: Sending request to:', `${baseURI}/majorTomToGroundControl`);
|
||||
response = await fetch(`${baseURI}/majorTomToGroundControl`, {
|
||||
method: 'POST',
|
||||
headers: _getHeaders(),
|
||||
@ -216,11 +337,18 @@ export const majorTomToGroundControl = async (addresses: string[], hashes: strin
|
||||
*/
|
||||
export const checkPermissions = async () => {
|
||||
try {
|
||||
return new Promise(function (resolve) {
|
||||
PushNotification.checkPermissions((result: any) => {
|
||||
resolve(result);
|
||||
});
|
||||
});
|
||||
if (Platform.OS === 'ios') {
|
||||
return Notifications.ios.checkPermissions();
|
||||
}
|
||||
|
||||
const { status } = await checkNotifications();
|
||||
const granted = status === RESULTS.GRANTED;
|
||||
return {
|
||||
alert: granted,
|
||||
badge: granted,
|
||||
sound: granted,
|
||||
status,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error checking permissions:', error);
|
||||
throw error;
|
||||
@ -255,12 +383,14 @@ export const setLevels = async (levelAll: boolean) => {
|
||||
}
|
||||
|
||||
if (!levelAll) {
|
||||
console.debug('Disabling notifications as user opted out...');
|
||||
PushNotification.removeAllDeliveredNotifications();
|
||||
PushNotification.setApplicationIconBadgeNumber(0);
|
||||
PushNotification.cancelAllLocalNotifications();
|
||||
console.log('Disabling notifications as user opted out...');
|
||||
Notifications.removeAllDeliveredNotifications();
|
||||
if (Platform.OS === 'ios') {
|
||||
Notifications.ios.setBadgeCount(0);
|
||||
Notifications.ios.cancelAllLocalNotifications();
|
||||
}
|
||||
await AsyncStorage.setItem(NOTIFICATIONS_NO_AND_DONT_ASK_FLAG, 'true');
|
||||
console.debug('Notifications disabled successfully');
|
||||
console.log('Notifications disabled successfully');
|
||||
} else {
|
||||
await AsyncStorage.removeItem(NOTIFICATIONS_NO_AND_DONT_ASK_FLAG); // Clear flag when enabling
|
||||
}
|
||||
@ -286,19 +416,19 @@ export const addNotification = async (notification: TPayload) => {
|
||||
};
|
||||
|
||||
const postTokenConfig = async () => {
|
||||
console.debug('postTokenConfig: Starting token configuration');
|
||||
console.log('postTokenConfig: Starting token configuration');
|
||||
const pushToken = await getPushToken();
|
||||
console.debug('postTokenConfig: Retrieved push token:', !!pushToken);
|
||||
console.log('postTokenConfig: Retrieved push token:', !!pushToken);
|
||||
|
||||
if (!pushToken || !pushToken.token || !pushToken.os) {
|
||||
console.debug('postTokenConfig: Invalid token or missing OS info');
|
||||
console.log('postTokenConfig: Invalid token or missing OS info');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const lang = (await AsyncStorage.getItem('lang')) || 'en';
|
||||
const appVersion = getSystemName() + ' ' + getSystemVersion() + ';' + getApplicationName() + ' ' + getVersion();
|
||||
console.debug('postTokenConfig: Posting configuration', { lang, appVersion });
|
||||
console.log('postTokenConfig: Posting configuration', { lang, appVersion });
|
||||
|
||||
await fetch(`${baseURI}/setTokenConfiguration`, {
|
||||
method: 'POST',
|
||||
@ -329,87 +459,74 @@ const _setPushToken = async (token: TPushToken) => {
|
||||
/**
|
||||
* Configures notifications. For Android, it will show a native rationale prompt if necessary.
|
||||
*
|
||||
* @returns {Promise<boolean>}
|
||||
* @returns {Promise<boolean>} whether successfully registered for remote push notifications
|
||||
*/
|
||||
export const configureNotifications = async (onProcessNotifications?: () => void) => {
|
||||
if (alreadyConfigured) {
|
||||
console.debug('configureNotifications: Already configured, skipping');
|
||||
return true;
|
||||
const configureNotifications = async (onProcessNotifications?: () => void): Promise<boolean> => {
|
||||
console.log('configureNotifications()');
|
||||
if (onProcessNotifications) {
|
||||
onProcessNotificationsHandler = onProcessNotifications;
|
||||
}
|
||||
|
||||
return new Promise(resolve => {
|
||||
const handleRegistration = async (token: TPushToken) => {
|
||||
if (__DEV__) {
|
||||
console.debug('configureNotifications: Token received:', token);
|
||||
}
|
||||
alreadyConfigured = true;
|
||||
await _setPushToken(token);
|
||||
resolve(true);
|
||||
};
|
||||
try {
|
||||
const { status } = await checkNotifications();
|
||||
if (status !== RESULTS.GRANTED) {
|
||||
console.log('configureNotifications: Permissions not granted');
|
||||
return false;
|
||||
}
|
||||
|
||||
// const handleNotification = async (notification: TPushNotification & { data: any }) => {
|
||||
const handleNotification = async (notification: Omit<ReceivedNotification, 'userInfo'>) => {
|
||||
// Deep clone to avoid modifying the original object
|
||||
// @ts-ignore some missing properties hopefully will be unwrapped from `.data`
|
||||
const payload: TPayload = deepClone({
|
||||
...notification,
|
||||
...notification.data,
|
||||
});
|
||||
ensureAndroidNotificationChannel();
|
||||
|
||||
if (notification.data?.data) {
|
||||
const validData = Object.fromEntries(Object.entries(notification.data.data).filter(([_, value]) => value != null));
|
||||
Object.assign(payload, validData);
|
||||
}
|
||||
if (notificationSubscriptions.length === 0) {
|
||||
notificationSubscriptions = [
|
||||
Notifications.events().registerRemoteNotificationsRegistered(async event => {
|
||||
console.log('processing event', event);
|
||||
const token = createPushToken(event.deviceToken);
|
||||
if (__DEV__) {
|
||||
console.log('configureNotifications: Token received:', token);
|
||||
}
|
||||
await _setPushToken(token);
|
||||
await postTokenConfig().catch(error => console.error('Failed to post token configuration:', error));
|
||||
settlePendingRegistration(true);
|
||||
}),
|
||||
Notifications.events().registerRemoteNotificationsRegistrationFailed(error => {
|
||||
console.error('Registration error:', error);
|
||||
settlePendingRegistration(false);
|
||||
}),
|
||||
Notifications.events().registerRemoteNotificationsRegistrationDenied(() => {
|
||||
console.log('Remote notification registration denied');
|
||||
settlePendingRegistration(false);
|
||||
}),
|
||||
Notifications.events().registerNotificationReceivedForeground(async (notification, completion) => {
|
||||
await storeIncomingNotification(notification, { foreground: true, userInteraction: false }, completion);
|
||||
}),
|
||||
Notifications.events().registerNotificationReceivedBackground(async (notification, completion) => {
|
||||
await storeIncomingNotification(notification, { foreground: false, userInteraction: false }, completion);
|
||||
}),
|
||||
Notifications.events().registerNotificationOpened(async (notification, completion) => {
|
||||
try {
|
||||
await storeIncomingNotification(notification, { foreground: false, userInteraction: true });
|
||||
} finally {
|
||||
completion();
|
||||
}
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
// @ts-ignore stfu ts, its cleanup
|
||||
payload.data = undefined;
|
||||
|
||||
if (!payload.subText && !payload.message) {
|
||||
console.warn('Notification missing required fields:', payload);
|
||||
return;
|
||||
}
|
||||
|
||||
await addNotification(payload);
|
||||
notification.finish(PushNotificationIOS.FetchResult.NoData);
|
||||
|
||||
if (payload.foreground && onProcessNotifications) {
|
||||
await onProcessNotifications();
|
||||
}
|
||||
};
|
||||
|
||||
const configure = async () => {
|
||||
try {
|
||||
const { status } = await checkNotifications();
|
||||
if (status !== RESULTS.GRANTED) {
|
||||
console.debug('configureNotifications: Permissions not granted');
|
||||
return resolve(false);
|
||||
Notifications.getInitialNotification()
|
||||
.then(async initialNotification => {
|
||||
if (initialNotification) {
|
||||
console.log('App was launched by a push notification:', initialNotification);
|
||||
await storeIncomingNotification(initialNotification, { foreground: false, userInteraction: true });
|
||||
}
|
||||
})
|
||||
.catch(error => console.error('Failed to retrieve initial notification:', error));
|
||||
|
||||
const existingToken = await getPushToken();
|
||||
if (existingToken) {
|
||||
alreadyConfigured = true;
|
||||
console.debug('Notifications already configured with existing token');
|
||||
return resolve(true);
|
||||
}
|
||||
|
||||
PushNotification.configure({
|
||||
onRegister: handleRegistration,
|
||||
onNotification: handleNotification,
|
||||
onRegistrationError: (error: any) => {
|
||||
console.error('Registration error:', error);
|
||||
resolve(false);
|
||||
},
|
||||
permissions: { alert: true, badge: true, sound: true },
|
||||
popInitialNotification: true,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error in configure:', error);
|
||||
resolve(false);
|
||||
}
|
||||
};
|
||||
|
||||
configure();
|
||||
});
|
||||
// waiting and returning actual result of remote pushes registration: success or failure
|
||||
return await waitForRemoteRegistration();
|
||||
} catch (error) {
|
||||
console.error('Error in configureNotifications:', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
@ -528,9 +645,15 @@ export const clearStoredNotifications = async () => {
|
||||
|
||||
export const getDeliveredNotifications: () => Promise<Record<string, any>[]> = () => {
|
||||
try {
|
||||
return new Promise(resolve => {
|
||||
PushNotification.getDeliveredNotifications((notifications: Record<string, any>[]) => resolve(notifications));
|
||||
});
|
||||
if (Platform.OS !== 'ios') {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
return Notifications.ios
|
||||
.getDeliveredNotifications()
|
||||
.then(notifications =>
|
||||
notifications.map(notification => normalizeNotificationPayload(notification, { foreground: true, userInteraction: false })),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error getting delivered notifications:', error);
|
||||
throw error;
|
||||
@ -538,15 +661,19 @@ export const getDeliveredNotifications: () => Promise<Record<string, any>[]> = (
|
||||
};
|
||||
|
||||
export const removeDeliveredNotifications = (identifiers = []) => {
|
||||
PushNotification.removeDeliveredNotifications(identifiers);
|
||||
if (Platform.OS === 'ios') {
|
||||
Notifications.ios.removeDeliveredNotifications(identifiers);
|
||||
}
|
||||
};
|
||||
|
||||
export const setApplicationIconBadgeNumber = (badges: number) => {
|
||||
PushNotification.setApplicationIconBadgeNumber(badges);
|
||||
if (Platform.OS === 'ios') {
|
||||
Notifications.ios.setBadgeCount(badges);
|
||||
}
|
||||
};
|
||||
|
||||
export const removeAllDeliveredNotifications = () => {
|
||||
PushNotification.removeAllDeliveredNotifications();
|
||||
Notifications.removeAllDeliveredNotifications();
|
||||
};
|
||||
|
||||
export const getDefaultUri = () => {
|
||||
@ -619,10 +746,11 @@ export const getStoredNotifications = async (): Promise<TPayload[]> => {
|
||||
|
||||
// on app launch (load module):
|
||||
export const initializeNotifications = async (onProcessNotifications?: () => void) => {
|
||||
console.debug('initializeNotifications: Starting initialization');
|
||||
console.log('initializeNotifications: Starting initialization');
|
||||
|
||||
try {
|
||||
const noAndDontAskFlag = await AsyncStorage.getItem(NOTIFICATIONS_NO_AND_DONT_ASK_FLAG);
|
||||
console.debug('initializeNotifications: No ask flag status:', noAndDontAskFlag);
|
||||
console.log('initializeNotifications: No ask flag status:', noAndDontAskFlag);
|
||||
|
||||
if (noAndDontAskFlag === 'true') {
|
||||
console.warn('User has opted out of notifications.');
|
||||
@ -631,13 +759,13 @@ export const initializeNotifications = async (onProcessNotifications?: () => voi
|
||||
|
||||
const baseUriStored = await AsyncStorage.getItem(GROUNDCONTROL_BASE_URI);
|
||||
baseURI = baseUriStored || groundControlUri;
|
||||
console.debug('Base URI set to:', baseURI);
|
||||
console.log('Base URI set to:', baseURI);
|
||||
|
||||
setApplicationIconBadgeNumber(0);
|
||||
|
||||
// Only check permissions, never request
|
||||
currentPermissionStatus = await checkNotificationPermissionStatus();
|
||||
console.debug('initializeNotifications: Permission status:', currentPermissionStatus);
|
||||
console.log('initializeNotifications: Permission status:', currentPermissionStatus);
|
||||
|
||||
// Handle Android 13+ permissions differently
|
||||
const canProceed =
|
||||
@ -646,19 +774,10 @@ export const initializeNotifications = async (onProcessNotifications?: () => voi
|
||||
: currentPermissionStatus === 'granted';
|
||||
|
||||
if (canProceed) {
|
||||
console.debug('initializeNotifications: Can proceed with notification setup');
|
||||
const token = await getPushToken();
|
||||
|
||||
if (token) {
|
||||
console.debug('initializeNotifications: Existing token found, configuring');
|
||||
await configureNotifications(onProcessNotifications);
|
||||
await postTokenConfig();
|
||||
} else {
|
||||
console.debug('initializeNotifications: No token found, will request permissions');
|
||||
await tryToObtainPermissions();
|
||||
}
|
||||
console.log('initializeNotifications: Can proceed with notification setup');
|
||||
await configureNotifications(onProcessNotifications);
|
||||
} else {
|
||||
console.debug('Notifications require user action to enable');
|
||||
console.log('Notifications require user action to enable');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize notifications:', error);
|
||||
|
||||
4
blue_modules/pako/dist/pako.esm.mjs
vendored
Normal file
4
blue_modules/pako/dist/pako.esm.mjs
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
import * as pako from '../index.js';
|
||||
|
||||
export * from '../index.js';
|
||||
export default pako;
|
||||
@ -1,7 +1,26 @@
|
||||
import { Dimensions, Platform, AppState, AppStateStatus } from 'react-native';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { AppState, AppStateStatus, Dimensions, NativeEventEmitter, NativeModules, Platform } from 'react-native';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { isDesktop } from './environment';
|
||||
|
||||
type NativeSizeClassPayload = {
|
||||
horizontal?: number;
|
||||
vertical?: number;
|
||||
sizeClass?: number;
|
||||
orientation?: string;
|
||||
isLargeScreen?: boolean;
|
||||
};
|
||||
|
||||
const sizeClassNativeModule = NativeModules.SizeClassEmitter as
|
||||
| {
|
||||
getCurrentSizeClass?: () => Promise<NativeSizeClassPayload>;
|
||||
addListener: (eventType: string) => any;
|
||||
removeListeners: (count: number) => void;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
const sizeClassNativeEmitter = sizeClassNativeModule ? new NativeEventEmitter(sizeClassNativeModule) : null;
|
||||
const NATIVE_EVENT_NAME = 'sizeClassDidChange';
|
||||
|
||||
// Size class definitions following iOS conventions
|
||||
export enum SizeClass {
|
||||
Compact, // Small size (iPhone width or height in landscape)
|
||||
@ -29,64 +48,40 @@ export interface SizeClassInfo {
|
||||
isLargeScreen: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current size class information based on device dimensions
|
||||
*/
|
||||
export function getSizeClass(): SizeClassInfo {
|
||||
// Get device dimensions
|
||||
const normalizeOrientation = (orientation?: string): 'portrait' | 'landscape' => (orientation === 'landscape' ? 'landscape' : 'portrait');
|
||||
|
||||
const coerceSizeClassValue = (value?: number): SizeClass => {
|
||||
if (value === SizeClass.Compact || value === SizeClass.Regular || value === SizeClass.Large) {
|
||||
return value;
|
||||
}
|
||||
return SizeClass.Regular;
|
||||
};
|
||||
|
||||
const calculateFromDimensions = (): SizeClassInfo => {
|
||||
const { width, height } = Dimensions.get('window');
|
||||
const isLandscape = width > height;
|
||||
const orientation = isLandscape ? 'landscape' : 'portrait';
|
||||
|
||||
// Determine horizontal size class (following iOS conventions)
|
||||
let horizontalSizeClass: SizeClass;
|
||||
const horizontalSizeClass =
|
||||
Platform.OS === 'ios' && Platform.isPad
|
||||
? SizeClass.Regular
|
||||
: isDesktop
|
||||
? SizeClass.Large
|
||||
: isLandscape && width >= 667
|
||||
? SizeClass.Regular
|
||||
: SizeClass.Compact;
|
||||
|
||||
if (Platform.OS === 'ios' && Platform.isPad) {
|
||||
// iPads always have Regular width
|
||||
horizontalSizeClass = SizeClass.Regular;
|
||||
} else if (isDesktop) {
|
||||
// Desktop systems get Large width
|
||||
horizontalSizeClass = SizeClass.Large;
|
||||
} else if (isLandscape && width >= 667) {
|
||||
// iPhone Plus models (and modern equivalent sizes) in landscape: Regular width
|
||||
// 667 points corresponds roughly to iPhone Plus models
|
||||
horizontalSizeClass = SizeClass.Regular;
|
||||
} else {
|
||||
// Regular iPhones: Compact width
|
||||
horizontalSizeClass = SizeClass.Compact;
|
||||
}
|
||||
const verticalSizeClass =
|
||||
Platform.OS === 'ios' && Platform.isPad
|
||||
? SizeClass.Regular
|
||||
: isDesktop
|
||||
? SizeClass.Large
|
||||
: isLandscape
|
||||
? SizeClass.Compact
|
||||
: SizeClass.Regular;
|
||||
|
||||
// Determine vertical size class (following iOS conventions)
|
||||
let verticalSizeClass: SizeClass;
|
||||
|
||||
if (Platform.OS === 'ios' && Platform.isPad) {
|
||||
// iPads always have Regular height
|
||||
verticalSizeClass = SizeClass.Regular;
|
||||
} else if (isDesktop) {
|
||||
// Desktop systems get Large height
|
||||
verticalSizeClass = SizeClass.Large;
|
||||
} else if (isLandscape) {
|
||||
// All iPhones in landscape: Compact height
|
||||
verticalSizeClass = SizeClass.Compact;
|
||||
} else {
|
||||
// iPhones in portrait: Regular height
|
||||
verticalSizeClass = SizeClass.Regular;
|
||||
}
|
||||
|
||||
// Derive overall size class - simplified logic to avoid redundant comparisons
|
||||
let sizeClass: SizeClass;
|
||||
|
||||
if (horizontalSizeClass === SizeClass.Compact) {
|
||||
// If width is compact, overall is compact
|
||||
sizeClass = SizeClass.Compact;
|
||||
} else {
|
||||
// Otherwise, width is Regular or Large, so overall is Large
|
||||
// (per requirements that any non-Compact width device is considered Large)
|
||||
sizeClass = SizeClass.Large;
|
||||
}
|
||||
|
||||
// Determine isLargeScreen property (true for Regular and Large widths)
|
||||
const isLargeScreen = horizontalSizeClass !== SizeClass.Compact;
|
||||
const sizeClass = coerceSizeClassValue(horizontalSizeClass);
|
||||
const isLargeScreen = sizeClass === SizeClass.Large;
|
||||
|
||||
return {
|
||||
horizontalSizeClass,
|
||||
@ -97,43 +92,126 @@ export function getSizeClass(): SizeClassInfo {
|
||||
isLarge: sizeClass === SizeClass.Large,
|
||||
isLargeScreen,
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeNativePayload = (payload?: NativeSizeClassPayload | null): SizeClassInfo | null => {
|
||||
if (!payload) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const horizontalSizeClass = coerceSizeClassValue(payload.horizontal);
|
||||
const verticalSizeClass = coerceSizeClassValue(payload.vertical);
|
||||
const sizeClass = coerceSizeClassValue(payload.sizeClass);
|
||||
|
||||
const isLargeScreen = payload.isLargeScreen ?? sizeClass === SizeClass.Large;
|
||||
const orientation = normalizeOrientation(payload.orientation);
|
||||
|
||||
return {
|
||||
horizontalSizeClass,
|
||||
verticalSizeClass,
|
||||
sizeClass,
|
||||
orientation,
|
||||
isCompact: sizeClass === SizeClass.Compact,
|
||||
isLarge: sizeClass === SizeClass.Large,
|
||||
isLargeScreen,
|
||||
};
|
||||
};
|
||||
|
||||
let cachedSizeClassInfo: SizeClassInfo = calculateFromDimensions();
|
||||
let nativeInitRequested = false;
|
||||
|
||||
const fetchNativeSizeClass = async (): Promise<SizeClassInfo | null> => {
|
||||
if (!sizeClassNativeModule?.getCurrentSizeClass) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await sizeClassNativeModule.getCurrentSizeClass();
|
||||
return normalizeNativePayload(result);
|
||||
} catch (error) {
|
||||
console.debug('[SizeClass] Failed to read native size class', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get current size class information.
|
||||
*/
|
||||
export function getSizeClass(): SizeClassInfo {
|
||||
if (!sizeClassNativeModule) {
|
||||
cachedSizeClassInfo = calculateFromDimensions();
|
||||
} else if (!nativeInitRequested) {
|
||||
nativeInitRequested = true;
|
||||
fetchNativeSizeClass().then(nativeInfo => {
|
||||
if (nativeInfo) {
|
||||
cachedSizeClassInfo = nativeInfo;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return cachedSizeClassInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* React hook to use size classes in components
|
||||
*/
|
||||
export function useSizeClass(): SizeClassInfo {
|
||||
const [sizeClassInfo, setSizeClassInfo] = useState<SizeClassInfo>(getSizeClass());
|
||||
const [sizeClassInfo, setSizeClassInfo] = useState<SizeClassInfo>(cachedSizeClassInfo);
|
||||
|
||||
useEffect(() => {
|
||||
// Update size class when dimensions change
|
||||
const updateSizeClass = () => {
|
||||
const newInfo = getSizeClass();
|
||||
setSizeClassInfo(newInfo);
|
||||
let isMounted = true;
|
||||
|
||||
const applySizeClass = (info: SizeClassInfo) => {
|
||||
if (!isMounted) return;
|
||||
cachedSizeClassInfo = info;
|
||||
setSizeClassInfo(info);
|
||||
console.debug(
|
||||
`[SizeClass] Updated:`,
|
||||
`horizontal=${SizeClass[newInfo.horizontalSizeClass]}`,
|
||||
`vertical=${SizeClass[newInfo.verticalSizeClass]}`,
|
||||
`orientation=${newInfo.orientation}`,
|
||||
`isLargeScreen=${newInfo.isLargeScreen}`,
|
||||
`horizontal=${SizeClass[info.horizontalSizeClass]}`,
|
||||
`vertical=${SizeClass[info.verticalSizeClass]}`,
|
||||
`orientation=${info.orientation}`,
|
||||
`isLargeScreen=${info.isLargeScreen}`,
|
||||
);
|
||||
};
|
||||
|
||||
const dimensionSubscription = Dimensions.addEventListener('change', updateSizeClass);
|
||||
const updateFromDimensions = () => {
|
||||
const calculated = calculateFromDimensions();
|
||||
applySizeClass(calculated);
|
||||
};
|
||||
|
||||
// Also update when app becomes active
|
||||
const handleAppStateChange = (nextAppState: AppStateStatus) => {
|
||||
if (nextAppState === 'active') {
|
||||
updateSizeClass();
|
||||
const requestNativeUpdate = async () => {
|
||||
const nativeInfo = await fetchNativeSizeClass();
|
||||
if (nativeInfo) {
|
||||
applySizeClass(nativeInfo);
|
||||
}
|
||||
};
|
||||
|
||||
const appStateSubscription = AppState.addEventListener('change', handleAppStateChange);
|
||||
const dimensionSubscription = Dimensions.addEventListener('change', () => {
|
||||
updateFromDimensions();
|
||||
requestNativeUpdate();
|
||||
});
|
||||
|
||||
const appStateSubscription = AppState.addEventListener('change', (nextAppState: AppStateStatus) => {
|
||||
if (nextAppState === 'active') {
|
||||
requestNativeUpdate();
|
||||
}
|
||||
});
|
||||
|
||||
const nativeSubscription = sizeClassNativeEmitter?.addListener(NATIVE_EVENT_NAME, (payload: NativeSizeClassPayload) => {
|
||||
const normalized = normalizeNativePayload(payload);
|
||||
if (normalized) {
|
||||
applySizeClass(normalized);
|
||||
}
|
||||
});
|
||||
|
||||
// Kick off an initial native fetch to override the heuristic when available.
|
||||
requestNativeUpdate();
|
||||
|
||||
// Clean up
|
||||
return () => {
|
||||
isMounted = false;
|
||||
dimensionSubscription.remove();
|
||||
appStateSubscription.remove();
|
||||
nativeSubscription?.remove();
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
import { useTheme } from '../components/themes';
|
||||
import { HDAezeedWallet } from './wallets/hd-aezeed-wallet';
|
||||
import { HDLegacyBreadwalletWallet } from './wallets/hd-legacy-breadwallet-wallet';
|
||||
import { HDLegacyElectrumSeedP2PKHWallet } from './wallets/hd-legacy-electrum-seed-p2pkh-wallet';
|
||||
@ -30,8 +29,7 @@ export default class WalletGradient {
|
||||
static aezeedWallet: string[] = ['#8584FF', '#5351FB'];
|
||||
|
||||
static createWallet = () => {
|
||||
const { colors } = useTheme();
|
||||
return colors.lightButton;
|
||||
return WalletGradient.defaultGradients[0];
|
||||
};
|
||||
|
||||
static gradientsFor(type: string): string[] {
|
||||
|
||||
@ -136,6 +136,14 @@ export class AbstractWallet {
|
||||
return BitcoinUnit.BTC;
|
||||
}
|
||||
|
||||
setPreferredBalanceUnit(unit: BitcoinUnit): void {
|
||||
if (Object.values(BitcoinUnit).includes(unit)) {
|
||||
this.preferredBalanceUnit = unit;
|
||||
return;
|
||||
}
|
||||
this.preferredBalanceUnit = BitcoinUnit.BTC;
|
||||
}
|
||||
|
||||
async allowOnchainAddress(): Promise<boolean> {
|
||||
throw new Error('allowOnchainAddress: Not implemented');
|
||||
}
|
||||
|
||||
14
codegen/NativeEventEmitter.ts
Normal file
14
codegen/NativeEventEmitter.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import type { TurboModule } from 'react-native';
|
||||
import { TurboModuleRegistry } from 'react-native';
|
||||
|
||||
import type { Double, UnsafeObject } from 'react-native/Libraries/Types/CodegenTypes';
|
||||
|
||||
export interface Spec extends TurboModule {
|
||||
addListener(eventName: string): void;
|
||||
removeListeners(count: Double): void;
|
||||
getMostRecentUserActivity(): Promise<UnsafeObject | null>;
|
||||
}
|
||||
|
||||
const moduleProxy = TurboModuleRegistry.getEnforcing<Spec>('EventEmitter');
|
||||
|
||||
export default moduleProxy;
|
||||
18
codegen/NativeMenuElementsEmitter.ts
Normal file
18
codegen/NativeMenuElementsEmitter.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import type { TurboModule } from 'react-native';
|
||||
import { TurboModuleRegistry } from 'react-native';
|
||||
|
||||
import type { Double } from 'react-native/Libraries/Types/CodegenTypes';
|
||||
|
||||
export interface Spec extends TurboModule {
|
||||
addListener(eventName: string): void;
|
||||
removeListeners(count: Double): void;
|
||||
openSettings(): void;
|
||||
addWalletMenuAction(): void;
|
||||
importWalletMenuAction(): void;
|
||||
reloadTransactionsMenuAction(): void;
|
||||
sharedInstance?(): void;
|
||||
}
|
||||
|
||||
const moduleProxy = TurboModuleRegistry.getEnforcing<Spec>('MenuElementsEmitter');
|
||||
|
||||
export default moduleProxy;
|
||||
17
codegen/NativeSettingsModule.ts
Normal file
17
codegen/NativeSettingsModule.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { TurboModuleRegistry } from 'react-native';
|
||||
import type { TurboModule } from 'react-native';
|
||||
|
||||
export interface Spec extends TurboModule {
|
||||
initializeDeviceUID(): Promise<string>;
|
||||
getDeviceUID(): Promise<string | null>;
|
||||
getDeviceUIDCopy(): Promise<string>;
|
||||
setClearFilesOnLaunch(value: boolean): Promise<boolean>;
|
||||
getClearFilesOnLaunch(): Promise<boolean>;
|
||||
setDoNotTrack(enabled: boolean): Promise<boolean>;
|
||||
getDoNotTrack(): Promise<boolean>;
|
||||
openSettings(): Promise<boolean>;
|
||||
}
|
||||
|
||||
const nativeModule = TurboModuleRegistry.get<Spec>('SettingsModule');
|
||||
|
||||
export default nativeModule;
|
||||
10
codegen/NativeWidgetHelper.ts
Normal file
10
codegen/NativeWidgetHelper.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import type { TurboModule } from 'react-native';
|
||||
import { TurboModuleRegistry } from 'react-native';
|
||||
|
||||
export interface Spec extends TurboModule {
|
||||
reloadAllWidgets(): void;
|
||||
}
|
||||
|
||||
const moduleProxy = TurboModuleRegistry.getEnforcing<Spec>('WidgetHelper');
|
||||
|
||||
export default moduleProxy;
|
||||
16
codegen/SegmentControlNativeComponent.ts
Normal file
16
codegen/SegmentControlNativeComponent.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import type { ViewProps } from 'react-native';
|
||||
import type { BubblingEventHandler, Int32 } from 'react-native/Libraries/Types/CodegenTypes';
|
||||
import codegenNativeComponent from 'react-native/Libraries/Utilities/codegenNativeComponent';
|
||||
|
||||
export interface NativeProps extends ViewProps {
|
||||
values: string[];
|
||||
selectedIndex: Int32;
|
||||
enabled: boolean;
|
||||
backgroundColor: string;
|
||||
tintColor: string;
|
||||
textColor: string;
|
||||
momentary: boolean;
|
||||
onChange?: BubblingEventHandler<Readonly<{ selectedIndex: Int32 }>>;
|
||||
}
|
||||
|
||||
export default codegenNativeComponent<NativeProps>('CustomSegmentedControl');
|
||||
@ -16,8 +16,8 @@ const styles = StyleSheet.create({
|
||||
width: 30,
|
||||
height: 30,
|
||||
borderRadius: 15,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
pressed: {
|
||||
opacity: 0.6,
|
||||
|
||||
@ -127,17 +127,18 @@ export const AddressInputScanButton = ({
|
||||
[onChangeText],
|
||||
);
|
||||
|
||||
const buttonStyle = useMemo(() => [styles.scan, stylesHook.scan], [stylesHook.scan]);
|
||||
const menuButtonStyle = useMemo(() => (type === 'default' ? [styles.scan, stylesHook.scan] : undefined), [stylesHook.scan, type]);
|
||||
|
||||
return (
|
||||
<ToolTipMenu
|
||||
actions={actions}
|
||||
isButton
|
||||
onPressMenuItem={onMenuItemPressed}
|
||||
testID={testID}
|
||||
shouldOpenOnLongPress
|
||||
disabled={isLoading}
|
||||
onPress={toolTipOnPress}
|
||||
buttonStyle={type === 'default' ? buttonStyle : undefined}
|
||||
testID={type === 'default' ? testID : undefined}
|
||||
buttonStyle={menuButtonStyle}
|
||||
accessibilityLabel={loc.send.details_scan}
|
||||
accessibilityHint={loc.send.details_scan_hint}
|
||||
>
|
||||
@ -149,7 +150,11 @@ export const AddressInputScanButton = ({
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<Text style={[styles.linkText, { color: colors.foregroundColor }]}>{loc.wallets.import_scan_qr}</Text>
|
||||
<View testID={testID} style={styles.contentRow}>
|
||||
<Text style={[styles.linkText, { color: colors.foregroundColor }]} numberOfLines={1} ellipsizeMode="tail">
|
||||
{loc.wallets.import_scan_qr}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</ToolTipMenu>
|
||||
);
|
||||
@ -164,22 +169,35 @@ const styles = StyleSheet.create({
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 6,
|
||||
minWidth: 82,
|
||||
flexShrink: 0,
|
||||
borderRadius: 4,
|
||||
paddingVertical: 4,
|
||||
paddingHorizontal: 8,
|
||||
marginHorizontal: 4,
|
||||
alignSelf: 'center',
|
||||
},
|
||||
scanText: {
|
||||
marginLeft: 4,
|
||||
flexShrink: 0,
|
||||
textAlignVertical: 'center',
|
||||
},
|
||||
scanContent: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 1,
|
||||
flexShrink: 0,
|
||||
},
|
||||
linkText: {
|
||||
textAlign: 'center',
|
||||
fontSize: 16,
|
||||
flexShrink: 1,
|
||||
textAlignVertical: 'center',
|
||||
},
|
||||
contentRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '100%',
|
||||
},
|
||||
});
|
||||
|
||||
@ -1,272 +0,0 @@
|
||||
import React, { forwardRef, useImperativeHandle, useRef, ReactElement, ComponentType } from 'react';
|
||||
import { SheetSize, SizeChangeEvent, TrueSheet, TrueSheetProps } from '@lodev09/react-native-true-sheet';
|
||||
import { Keyboard, Image, StyleSheet, View, Pressable, Platform, GestureResponderEvent, Text } from 'react-native';
|
||||
import SaveFileButton from './SaveFileButton';
|
||||
import { useTheme } from './themes';
|
||||
import Icon from './Icon';
|
||||
|
||||
interface BottomModalProps extends TrueSheetProps {
|
||||
children?: React.ReactNode;
|
||||
onClose?: () => void;
|
||||
onCloseModalPressed?: () => Promise<void>;
|
||||
isGrabberVisible?: boolean;
|
||||
sizes?: SheetSize[] | undefined;
|
||||
footer?: ReactElement | ComponentType<any> | null;
|
||||
footerDefaultMargins?: boolean | number;
|
||||
onPresent?: () => void;
|
||||
onSizeChange?: (event: SizeChangeEvent) => void;
|
||||
showCloseButton?: boolean;
|
||||
shareContent?: BottomModalShareContent;
|
||||
shareButtonOnPress?: (event: GestureResponderEvent) => void;
|
||||
header?: ReactElement | ComponentType<any> | null;
|
||||
headerTitle?: string;
|
||||
headerSubtitle?: string;
|
||||
}
|
||||
|
||||
type BottomModalShareContent = {
|
||||
fileName: string;
|
||||
fileContent: string;
|
||||
};
|
||||
|
||||
export interface BottomModalHandle {
|
||||
present: () => Promise<void>;
|
||||
dismiss: () => Promise<void>;
|
||||
}
|
||||
|
||||
const BottomModal = forwardRef<BottomModalHandle, BottomModalProps>(
|
||||
(
|
||||
{
|
||||
onClose,
|
||||
onCloseModalPressed,
|
||||
onPresent,
|
||||
onSizeChange,
|
||||
showCloseButton = true,
|
||||
isGrabberVisible = true,
|
||||
shareContent,
|
||||
shareButtonOnPress,
|
||||
sizes = ['auto'],
|
||||
footer,
|
||||
footerDefaultMargins,
|
||||
header,
|
||||
headerTitle,
|
||||
headerSubtitle,
|
||||
children,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const trueSheetRef = useRef<TrueSheet>(null);
|
||||
const { colors, closeImage } = useTheme();
|
||||
const stylesHook = StyleSheet.create({
|
||||
barButton: {
|
||||
backgroundColor: colors.lightButton,
|
||||
},
|
||||
headerTitle: {
|
||||
color: colors.foregroundColor,
|
||||
},
|
||||
});
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
present: async () => {
|
||||
Keyboard.dismiss();
|
||||
if (trueSheetRef.current?.present) {
|
||||
await trueSheetRef.current.present();
|
||||
} else {
|
||||
return Promise.resolve();
|
||||
}
|
||||
},
|
||||
dismiss: async () => {
|
||||
Keyboard.dismiss();
|
||||
if (trueSheetRef.current?.dismiss) {
|
||||
await trueSheetRef.current.dismiss();
|
||||
} else {
|
||||
return Promise.resolve();
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
const dismiss = async () => {
|
||||
try {
|
||||
await onCloseModalPressed?.();
|
||||
await trueSheetRef.current?.dismiss();
|
||||
} catch (error) {
|
||||
console.error('Error during dismiss:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const renderTopRightButton = () => {
|
||||
const buttons = [];
|
||||
if (shareContent || shareButtonOnPress) {
|
||||
if (shareContent) {
|
||||
buttons.push(
|
||||
<SaveFileButton
|
||||
style={[styles.topRightButton, stylesHook.barButton]}
|
||||
fileContent={shareContent.fileContent}
|
||||
fileName={shareContent.fileName}
|
||||
testID="ModalShareButton"
|
||||
key="ModalShareButton"
|
||||
>
|
||||
<Icon
|
||||
name={Platform.OS === 'android' ? 'share' : 'file-arrow-up'}
|
||||
type="font-awesome-6"
|
||||
size={20}
|
||||
color={colors.buttonTextColor}
|
||||
/>
|
||||
</SaveFileButton>,
|
||||
);
|
||||
} else if (shareButtonOnPress) {
|
||||
buttons.push(
|
||||
<Pressable
|
||||
testID="ModalShareButton"
|
||||
key="ModalShareButton"
|
||||
style={({ pressed }) => [pressed && styles.pressed, styles.topRightButton, stylesHook.barButton]}
|
||||
onPress={shareButtonOnPress}
|
||||
>
|
||||
<Icon
|
||||
name={Platform.OS === 'android' ? 'share' : 'file-arrow-up'}
|
||||
type="font-awesome-6"
|
||||
size={20}
|
||||
color={colors.buttonTextColor}
|
||||
/>
|
||||
</Pressable>,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (showCloseButton) {
|
||||
buttons.push(
|
||||
<Pressable
|
||||
style={({ pressed }) => [pressed && styles.pressed, styles.topRightButton, stylesHook.barButton]}
|
||||
onPress={dismiss}
|
||||
key="ModalDoneButton"
|
||||
testID="ModalDoneButton"
|
||||
>
|
||||
<Image source={closeImage} />
|
||||
</Pressable>,
|
||||
);
|
||||
}
|
||||
return <View style={styles.topRightButtonContainer}>{buttons}</View>;
|
||||
};
|
||||
|
||||
const renderHeader = () => {
|
||||
if (headerTitle || headerSubtitle) {
|
||||
return (
|
||||
<View style={styles.headerContainer}>
|
||||
<View style={styles.headerContent}>
|
||||
{headerTitle && <Text style={[styles.headerTitle, stylesHook.headerTitle]}>{headerTitle}</Text>}
|
||||
{headerSubtitle && <Text style={[styles.headerSubtitle, stylesHook.headerTitle]}>{headerSubtitle}</Text>}
|
||||
</View>
|
||||
{renderTopRightButton()}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (showCloseButton || shareContent)
|
||||
return (
|
||||
<View style={styles.headerContainer}>
|
||||
<View style={styles.headerContent}>{typeof header === 'function' ? <header /> : header}</View>
|
||||
{renderTopRightButton()}
|
||||
</View>
|
||||
);
|
||||
|
||||
if (React.isValidElement(header)) {
|
||||
return (
|
||||
<View style={styles.headerContainerWithCloseButton}>
|
||||
{header}
|
||||
{renderTopRightButton()}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const renderFooter = (): ReactElement | undefined => {
|
||||
if (React.isValidElement(footer)) {
|
||||
return footerDefaultMargins ? <View style={styles.footerContainer}>{footer}</View> : footer;
|
||||
} else if (typeof footer === 'function') {
|
||||
const ModalFooterComponent = footer as ComponentType<any>;
|
||||
return <ModalFooterComponent />;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const FooterComponent = renderFooter();
|
||||
|
||||
return (
|
||||
<TrueSheet
|
||||
ref={trueSheetRef}
|
||||
sizes={sizes}
|
||||
onDismiss={onClose}
|
||||
onPresent={onPresent}
|
||||
onSizeChange={onSizeChange}
|
||||
grabber={isGrabberVisible}
|
||||
FooterComponent={FooterComponent as ReactElement}
|
||||
{...props}
|
||||
>
|
||||
<View style={styles.childrenContainer}>{children}</View>
|
||||
{renderHeader()}
|
||||
</TrueSheet>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export default BottomModal;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
footerContainer: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
headerContainer: {
|
||||
position: 'absolute',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 8,
|
||||
minHeight: 22,
|
||||
right: 16,
|
||||
top: 16,
|
||||
},
|
||||
headerContainerWithCloseButton: {
|
||||
position: 'absolute',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 8,
|
||||
minHeight: 22,
|
||||
width: '100%',
|
||||
top: 16,
|
||||
left: 0,
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
headerContent: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
minHeight: 22,
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
headerSubtitle: {
|
||||
fontSize: 14,
|
||||
},
|
||||
topRightButton: {
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
width: 30,
|
||||
height: 30,
|
||||
borderRadius: 15,
|
||||
marginLeft: 22,
|
||||
},
|
||||
topRightButtonContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
childrenContainer: {
|
||||
paddingTop: 66,
|
||||
paddingHorizontal: 16,
|
||||
width: '100%',
|
||||
},
|
||||
pressed: {
|
||||
opacity: 0.6,
|
||||
},
|
||||
});
|
||||
@ -1,5 +1,5 @@
|
||||
import React, { createContext, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { InteractionManager, LayoutAnimation } from 'react-native';
|
||||
import { LayoutAnimation } from 'react-native';
|
||||
import { BlueApp as BlueAppClass, LegacyWallet, TCounterpartyMetadata, TTXMetadata, WatchOnlyWallet } from '../../class';
|
||||
import type { TWallet } from '../../class/wallets/types';
|
||||
import presentAlert from '../../components/Alert';
|
||||
@ -155,13 +155,11 @@ export const StorageProvider = ({ children }: { children: React.ReactNode }) =>
|
||||
console.debug('Not saving empty wallets array');
|
||||
return;
|
||||
}
|
||||
await InteractionManager.runAfterInteractions(async () => {
|
||||
BlueApp.tx_metadata = txMetadata.current;
|
||||
BlueApp.counterparty_metadata = counterpartyMetadata.current;
|
||||
await BlueApp.saveToDisk();
|
||||
const w: TWallet[] = [...BlueApp.getWallets()];
|
||||
setWallets(w);
|
||||
});
|
||||
BlueApp.tx_metadata = txMetadata.current;
|
||||
BlueApp.counterparty_metadata = counterpartyMetadata.current;
|
||||
await BlueApp.saveToDisk();
|
||||
const w: TWallet[] = [...BlueApp.getWallets()];
|
||||
setWallets(w);
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[txMetadata.current, counterpartyMetadata.current],
|
||||
@ -324,8 +322,6 @@ export const StorageProvider = ({ children }: { children: React.ReactNode }) =>
|
||||
console.debug('[refreshAllWalletTransactions] Starting refresh');
|
||||
refreshingRef.current = true;
|
||||
|
||||
await new Promise<void>(resolve => InteractionManager.runAfterInteractions(() => resolve()));
|
||||
|
||||
const TIMEOUT_DURATION = 30000;
|
||||
let refreshTimeout;
|
||||
const timeoutPromise = new Promise<never>(
|
||||
@ -398,36 +394,34 @@ export const StorageProvider = ({ children }: { children: React.ReactNode }) =>
|
||||
|
||||
const fetchAndSaveWalletTransactions = useCallback(
|
||||
async (walletID: string) => {
|
||||
await InteractionManager.runAfterInteractions(async () => {
|
||||
const index = wallets.findIndex(wallet => wallet.getID() === walletID);
|
||||
let noErr = true;
|
||||
try {
|
||||
if (Date.now() - (_lastTimeTriedToRefetchWallet[walletID] || 0) < 5000) {
|
||||
console.debug('[fetchAndSaveWalletTransactions] Re-fetch wallet happens too fast; NOP');
|
||||
return;
|
||||
}
|
||||
_lastTimeTriedToRefetchWallet[walletID] = Date.now();
|
||||
|
||||
await BlueElectrum.waitTillConnected();
|
||||
setWalletTransactionUpdateStatus(walletID);
|
||||
|
||||
const balanceStart = Date.now();
|
||||
await BlueApp.fetchWalletBalances(index);
|
||||
const balanceEnd = Date.now();
|
||||
console.debug('[fetchAndSaveWalletTransactions] fetch balance took', (balanceEnd - balanceStart) / 1000, 'sec');
|
||||
|
||||
const txStart = Date.now();
|
||||
await BlueApp.fetchWalletTransactions(index);
|
||||
const txEnd = Date.now();
|
||||
console.debug('[fetchAndSaveWalletTransactions] fetch tx took', (txEnd - txStart) / 1000, 'sec');
|
||||
} catch (err) {
|
||||
noErr = false;
|
||||
console.error('[fetchAndSaveWalletTransactions] Error:', err);
|
||||
} finally {
|
||||
setWalletTransactionUpdateStatus(WalletTransactionsStatus.NONE);
|
||||
const index = wallets.findIndex(wallet => wallet.getID() === walletID);
|
||||
let noErr = true;
|
||||
try {
|
||||
if (Date.now() - (_lastTimeTriedToRefetchWallet[walletID] || 0) < 5000) {
|
||||
console.debug('[fetchAndSaveWalletTransactions] Re-fetch wallet happens too fast; NOP');
|
||||
return;
|
||||
}
|
||||
if (noErr) await saveToDisk();
|
||||
});
|
||||
_lastTimeTriedToRefetchWallet[walletID] = Date.now();
|
||||
|
||||
await BlueElectrum.waitTillConnected();
|
||||
setWalletTransactionUpdateStatus(walletID);
|
||||
|
||||
const balanceStart = Date.now();
|
||||
await BlueApp.fetchWalletBalances(index);
|
||||
const balanceEnd = Date.now();
|
||||
console.debug('[fetchAndSaveWalletTransactions] fetch balance took', (balanceEnd - balanceStart) / 1000, 'sec');
|
||||
|
||||
const txStart = Date.now();
|
||||
await BlueApp.fetchWalletTransactions(index);
|
||||
const txEnd = Date.now();
|
||||
console.debug('[fetchAndSaveWalletTransactions] fetch tx took', (txEnd - txStart) / 1000, 'sec');
|
||||
} catch (err) {
|
||||
noErr = false;
|
||||
console.error('[fetchAndSaveWalletTransactions] Error:', err);
|
||||
} finally {
|
||||
setWalletTransactionUpdateStatus(WalletTransactionsStatus.NONE);
|
||||
}
|
||||
if (noErr) await saveToDisk();
|
||||
},
|
||||
[saveToDisk, wallets],
|
||||
);
|
||||
|
||||
@ -41,6 +41,10 @@ export class DynamicQRCode extends Component<DynamicQRCodeProps, DynamicQRCodeSt
|
||||
|
||||
fragments: string[] = [];
|
||||
|
||||
componentWillUnmount() {
|
||||
this.stopAutoMove();
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const { value, capacity = 175, hideControls = true, walletID } = this.props;
|
||||
try {
|
||||
|
||||
@ -417,12 +417,15 @@ const ButtonContent = ({ icon, text, textStyle, iconStyle }: ButtonContentProps)
|
||||
if (React.isValidElement(icon)) {
|
||||
const iconElement = icon as React.ReactElement;
|
||||
|
||||
scaledIcon = React.cloneElement(iconElement, {
|
||||
...iconElement.props,
|
||||
size: iconSize,
|
||||
width: iconSize,
|
||||
height: iconSize,
|
||||
});
|
||||
scaledIcon = React.cloneElement(
|
||||
iconElement as React.ReactElement<any>,
|
||||
{
|
||||
...(iconElement.props as Record<string, unknown>),
|
||||
size: iconSize,
|
||||
width: iconSize,
|
||||
height: iconSize,
|
||||
} as any,
|
||||
);
|
||||
} else {
|
||||
scaledIcon = icon;
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { Pressable, Platform } from 'react-native';
|
||||
import { Pressable, Platform, StyleSheet } from 'react-native';
|
||||
import ToolTipMenu from './TooltipMenu';
|
||||
import { useTheme } from './themes';
|
||||
import Icon from './Icon';
|
||||
@ -22,7 +22,8 @@ const HeaderMenuButton: React.FC<HeaderMenuButtonProps> = ({ onPressMenuItem, ac
|
||||
testID="HeaderMenuButton"
|
||||
disabled={disabled}
|
||||
android_ripple={{ color: colors.lightButton }}
|
||||
style={({ pressed }) => [{ opacity: pressed ? 0.5 : 1 }]}
|
||||
hitSlop={8}
|
||||
style={({ pressed }) => [styles.buttonCenter, pressed && styles.pressed]}
|
||||
>
|
||||
<Icon size={22} name="more-horiz" type="material" color={colors.foregroundColor} {...styleProps} />
|
||||
</Pressable>
|
||||
@ -36,14 +37,27 @@ const HeaderMenuButton: React.FC<HeaderMenuButtonProps> = ({ onPressMenuItem, ac
|
||||
testID="HeaderMenuButton"
|
||||
disabled={disabled}
|
||||
isButton
|
||||
isMenuPrimaryAction
|
||||
shouldOpenOnLongPress={false}
|
||||
onPressMenuItem={onPressMenuItem}
|
||||
actions={menuActions}
|
||||
title={title}
|
||||
buttonStyle={styles.buttonCenter}
|
||||
>
|
||||
<Icon size={22} name="more-horiz" type="material" color={colors.foregroundColor} {...styleProps} />
|
||||
</ToolTipMenu>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
buttonCenter: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 4,
|
||||
minWidth: 44,
|
||||
minHeight: 44,
|
||||
},
|
||||
pressed: { opacity: 0.5 },
|
||||
});
|
||||
|
||||
export default HeaderMenuButton;
|
||||
|
||||
@ -30,7 +30,10 @@ const styles = StyleSheet.create({
|
||||
save: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: 80,
|
||||
alignSelf: 'center',
|
||||
flexDirection: 'row',
|
||||
minWidth: 80,
|
||||
paddingHorizontal: 12,
|
||||
borderRadius: 8,
|
||||
height: 34,
|
||||
},
|
||||
|
||||
@ -15,6 +15,7 @@ export type MaterialDesignIconName = React.ComponentProps<typeof MaterialDesignI
|
||||
export type EntypoIconName = React.ComponentProps<typeof Entypo>['name'];
|
||||
|
||||
type IconType = 'font-awesome' | 'font-awesome-6' | 'ionicons' | 'material' | 'material-community' | 'entypo';
|
||||
type IconComponentType = React.ComponentType<any>;
|
||||
|
||||
type IconNameFor<T extends IconType> = T extends 'font-awesome'
|
||||
? FontAwesomeIconName
|
||||
@ -46,22 +47,13 @@ export interface IconProps<T extends IconType = IconType> {
|
||||
testID?: string;
|
||||
}
|
||||
|
||||
const resolveIconComponent = (type?: IconType): React.ComponentType<any> => {
|
||||
switch (type) {
|
||||
case 'font-awesome-6':
|
||||
return FontAwesome6;
|
||||
case 'ionicons':
|
||||
return Ionicons;
|
||||
case 'material':
|
||||
return MaterialIcons;
|
||||
case 'material-community':
|
||||
return MaterialDesignIcons;
|
||||
case 'entypo':
|
||||
return Entypo;
|
||||
case 'font-awesome':
|
||||
default:
|
||||
return FontAwesome;
|
||||
}
|
||||
const ICON_COMPONENTS: Record<IconType, IconComponentType> = {
|
||||
'font-awesome': FontAwesome,
|
||||
'font-awesome-6': FontAwesome6,
|
||||
ionicons: Ionicons,
|
||||
material: MaterialIcons,
|
||||
'material-community': MaterialDesignIcons,
|
||||
entypo: Entypo,
|
||||
};
|
||||
|
||||
const Icon = <T extends IconType = 'font-awesome'>({
|
||||
@ -76,7 +68,7 @@ const Icon = <T extends IconType = 'font-awesome'>({
|
||||
accessibilityLabel,
|
||||
testID,
|
||||
}: IconProps<T>): React.ReactElement | null => {
|
||||
const IconComponent = resolveIconComponent(type);
|
||||
const IconComponent = ICON_COMPONENTS[type ?? 'font-awesome'] as React.ComponentType<any>;
|
||||
const isFa6 = type === 'font-awesome-6';
|
||||
const fa6IconStyle = isFa6 ? (typeof iconStyle === 'string' ? iconStyle : 'solid') : undefined;
|
||||
const mergedStyle = isFa6 ? style : [style, iconStyle];
|
||||
|
||||
@ -23,6 +23,7 @@ interface ListItemProps {
|
||||
rightSubtitleStyle?: StyleProp<TextStyle>;
|
||||
chevron?: boolean;
|
||||
checkmark?: boolean;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
const ListItem: React.FC<ListItemProps> = React.memo(
|
||||
@ -44,6 +45,7 @@ const ListItem: React.FC<ListItemProps> = React.memo(
|
||||
rightSubtitleStyle,
|
||||
chevron,
|
||||
checkmark,
|
||||
isLoading,
|
||||
}: ListItemProps) => {
|
||||
const { colors } = useTheme();
|
||||
const { direction } = useLocale();
|
||||
|
||||
@ -40,7 +40,7 @@ interface ManageWalletsListItemProps {
|
||||
state: { wallets: TWallet[]; searchQuery: string; isSearchFocused?: boolean };
|
||||
navigateToWallet: (wallet: TWallet) => void;
|
||||
navigateToAddress?: (address: string, walletID: string) => void;
|
||||
renderHighlightedText: (text: string, query: string) => JSX.Element;
|
||||
renderHighlightedText: (text: string, query: string) => React.ReactElement;
|
||||
handleToggleHideBalance: (wallet: TWallet) => void;
|
||||
isActive?: boolean;
|
||||
style?: ViewStyle;
|
||||
@ -89,31 +89,24 @@ const ManageWalletsListItem: React.FC<ManageWalletsListItemProps> = ({
|
||||
const resetFunctionRef = useRef<(() => void) | null>(null);
|
||||
const swipeableRef = useRef<Swipeable | null>(null);
|
||||
|
||||
const CARD_SORT_ACTIVE = 1.06;
|
||||
const INACTIVE_SCALE_WHEN_ACTIVE = 0.9;
|
||||
const SCALE_DURATION = 200;
|
||||
const CARD_SORT_ACTIVE = 1.0;
|
||||
const scaleValue = useRef(new Animated.Value(1)).current;
|
||||
const handleOpacity = useRef(new Animated.Value(1)).current;
|
||||
const prevIsActive = useRef(isActive);
|
||||
|
||||
const DEFAULT_VERTICAL_MARGIN = -10;
|
||||
const REDUCED_VERTICAL_MARGIN = -50;
|
||||
const DEFAULT_VERTICAL_MARGIN = 0;
|
||||
const searchLocked = state.searchQuery.length > 0 || state.isSearchFocused === true;
|
||||
const swipeDisabled = item.type === ItemType.WalletSection ? isActive || globalDragActive || searchLocked : true;
|
||||
const hideHandle = item.type === ItemType.WalletSection ? swipeDisabled || isDraggingDisabled : true;
|
||||
|
||||
const animateItemIn = useCallback(() => {
|
||||
if (Platform.OS === 'ios') {
|
||||
Animated.spring(scaleValue, {
|
||||
toValue: isActive ? CARD_SORT_ACTIVE : globalDragActive ? INACTIVE_SCALE_WHEN_ACTIVE : 1,
|
||||
friction: 8,
|
||||
tension: 40,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
} else {
|
||||
Animated.timing(scaleValue, {
|
||||
toValue: isActive ? CARD_SORT_ACTIVE : globalDragActive ? INACTIVE_SCALE_WHEN_ACTIVE : 1,
|
||||
duration: SCALE_DURATION,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
}
|
||||
}, [isActive, globalDragActive, scaleValue, CARD_SORT_ACTIVE, INACTIVE_SCALE_WHEN_ACTIVE, SCALE_DURATION]);
|
||||
Animated.spring(scaleValue, {
|
||||
toValue: isActive ? CARD_SORT_ACTIVE : 1,
|
||||
friction: 7,
|
||||
tension: 80,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
}, [isActive, scaleValue, CARD_SORT_ACTIVE]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isActive !== prevIsActive.current) {
|
||||
@ -124,6 +117,14 @@ const ManageWalletsListItem: React.FC<ManageWalletsListItemProps> = ({
|
||||
animateItemIn();
|
||||
}, [isActive, globalDragActive, animateItemIn]);
|
||||
|
||||
useEffect(() => {
|
||||
Animated.timing(handleOpacity, {
|
||||
toValue: hideHandle ? 0 : 1,
|
||||
duration: 140,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
}, [hideHandle, handleOpacity]);
|
||||
|
||||
const onPress = useCallback(() => {
|
||||
if (item.type === ItemType.WalletSection) {
|
||||
setIsLoading(true);
|
||||
@ -153,12 +154,11 @@ const ManageWalletsListItem: React.FC<ManageWalletsListItemProps> = ({
|
||||
resetFunctionRef.current();
|
||||
}
|
||||
|
||||
scaleValue.setValue(CARD_SORT_ACTIVE);
|
||||
triggerHapticFeedback(HapticFeedbackTypes.ImpactMedium);
|
||||
if (drag) {
|
||||
drag();
|
||||
}
|
||||
}, [CARD_SORT_ACTIVE, drag, scaleValue, isSwipeActive]);
|
||||
}, [drag, isSwipeActive]);
|
||||
|
||||
if (isLoading) {
|
||||
return <ActivityIndicator size="large" color={colors.brandingColor} />;
|
||||
@ -166,15 +166,13 @@ const ManageWalletsListItem: React.FC<ManageWalletsListItemProps> = ({
|
||||
|
||||
if (item.type === ItemType.WalletSection) {
|
||||
const animatedStyle = {
|
||||
marginVertical: DEFAULT_VERTICAL_MARGIN,
|
||||
minHeight: 110,
|
||||
paddingHorizontal: 6,
|
||||
transform: [{ scale: scaleValue }],
|
||||
marginVertical: globalDragActive && !isActive ? REDUCED_VERTICAL_MARGIN : DEFAULT_VERTICAL_MARGIN,
|
||||
};
|
||||
|
||||
const backgroundColor = isActive || globalDragActive ? colors.brandingColor : colors.background;
|
||||
|
||||
// Disable swiping only when search bar is focused or during active dragging
|
||||
const swipeDisabled = isActive || globalDragActive || state.isSearchFocused === true;
|
||||
|
||||
const backgroundColor = isActive ? colors.brandingColor : colors.background;
|
||||
const content = (
|
||||
<View style={[style, { backgroundColor }, swipeDisabled ? styles.transparentBackground : {}]}>
|
||||
<WalletCarouselItem
|
||||
@ -293,7 +291,7 @@ interface WalletGroupProps {
|
||||
state: { wallets: TWallet[]; searchQuery: string };
|
||||
navigateToWallet: (wallet: TWallet) => void;
|
||||
navigateToAddress?: (address: string, walletID: string) => void;
|
||||
renderHighlightedText: (text: string, query: string) => JSX.Element;
|
||||
renderHighlightedText: (text: string, query: string) => React.ReactElement;
|
||||
isSearching: boolean;
|
||||
}
|
||||
|
||||
@ -323,8 +321,8 @@ const WalletGroupComponent: React.FC<WalletGroupProps> = ({
|
||||
const primaryColor = walletGradientColors[0];
|
||||
|
||||
const containerStyle: ViewStyle = {
|
||||
marginHorizontal: 10,
|
||||
marginVertical: 16,
|
||||
marginHorizontal: 8,
|
||||
marginVertical: 10,
|
||||
borderRadius: 10,
|
||||
overflow: 'hidden' as const,
|
||||
backgroundColor: colors.elevated,
|
||||
@ -398,6 +396,7 @@ const WalletGroupComponent: React.FC<WalletGroupProps> = ({
|
||||
isPlaceHolder={false}
|
||||
renderHighlightedText={renderHighlightedText}
|
||||
customStyle={styles.carouselItem}
|
||||
sizeVariant="compact"
|
||||
/>
|
||||
</View>
|
||||
|
||||
@ -486,9 +485,12 @@ const styles = StyleSheet.create({
|
||||
fontWeight: '600',
|
||||
},
|
||||
carouselItem: {
|
||||
width: '100%',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 8,
|
||||
flex: 1,
|
||||
flexBasis: 0,
|
||||
flexShrink: 1,
|
||||
maxWidth: '100%',
|
||||
alignSelf: 'stretch',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
transparentBackground: {
|
||||
backgroundColor: 'transparent',
|
||||
|
||||
@ -1,451 +0,0 @@
|
||||
import React, { useState, useRef, forwardRef, useImperativeHandle, useEffect } from 'react';
|
||||
import { View, Text, TextInput, StyleSheet, Animated, Easing, ViewStyle, Keyboard, Platform, UIManager } from 'react-native';
|
||||
import BottomModal, { BottomModalHandle } from './BottomModal';
|
||||
import { useTheme } from '../components/themes';
|
||||
import loc from '../loc';
|
||||
import { SecondButton } from './SecondButton';
|
||||
import triggerHapticFeedback, { HapticFeedbackTypes } from '../blue_modules/hapticFeedback';
|
||||
import { useKeyboard } from '../hooks/useKeyboard';
|
||||
|
||||
if (Platform.OS === 'android' && UIManager.setLayoutAnimationEnabledExperimental) {
|
||||
UIManager.setLayoutAnimationEnabledExperimental(true);
|
||||
}
|
||||
|
||||
export const MODAL_TYPES = {
|
||||
ENTER_PASSWORD: 'ENTER_PASSWORD',
|
||||
CREATE_PASSWORD: 'CREATE_PASSWORD',
|
||||
CREATE_FAKE_STORAGE: 'CREATE_FAKE_STORAGE',
|
||||
SUCCESS: 'SUCCESS',
|
||||
} as const;
|
||||
|
||||
export type ModalType = (typeof MODAL_TYPES)[keyof typeof MODAL_TYPES];
|
||||
|
||||
interface PromptPasswordConfirmationModalProps {
|
||||
modalType: ModalType;
|
||||
onConfirmationSuccess: (password: string) => Promise<boolean>;
|
||||
onConfirmationFailure: () => void;
|
||||
}
|
||||
|
||||
export interface PromptPasswordConfirmationModalHandle {
|
||||
present: () => Promise<void>;
|
||||
dismiss: () => Promise<void>;
|
||||
}
|
||||
|
||||
const PromptPasswordConfirmationModal = forwardRef<PromptPasswordConfirmationModalHandle, PromptPasswordConfirmationModalProps>(
|
||||
({ modalType, onConfirmationSuccess, onConfirmationFailure }, ref) => {
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isSuccess, setIsSuccess] = useState(false);
|
||||
const [showExplanation, setShowExplanation] = useState(false); // State to toggle between explanation and password input for CREATE_PASSWORD and CREATE_FAKE_STORAGE
|
||||
const modalRef = useRef<BottomModalHandle>(null);
|
||||
const fadeOutAnimation = useRef(new Animated.Value(1)).current;
|
||||
const fadeInAnimation = useRef(new Animated.Value(0)).current;
|
||||
const scaleAnimation = useRef(new Animated.Value(1)).current;
|
||||
const shakeAnimation = useRef(new Animated.Value(0)).current;
|
||||
const explanationOpacity = useRef(new Animated.Value(1)).current;
|
||||
const { colors } = useTheme();
|
||||
const passwordInputRef = useRef<TextInput>(null);
|
||||
const confirmPasswordInputRef = useRef<TextInput>(null);
|
||||
const { isVisible } = useKeyboard();
|
||||
|
||||
const stylesHook = StyleSheet.create({
|
||||
modalContent: {
|
||||
backgroundColor: colors.elevated,
|
||||
width: '100%',
|
||||
},
|
||||
input: {
|
||||
backgroundColor: colors.inputBackgroundColor,
|
||||
borderColor: colors.formBorder,
|
||||
color: colors.foregroundColor,
|
||||
width: '100%',
|
||||
},
|
||||
feeModalCustomText: {
|
||||
color: colors.buttonAlternativeTextColor,
|
||||
},
|
||||
feeModalLabel: {
|
||||
color: colors.successColor,
|
||||
},
|
||||
});
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
present: async () => {
|
||||
resetState();
|
||||
modalRef.current?.present();
|
||||
if (modalType === MODAL_TYPES.CREATE_PASSWORD || (modalType === MODAL_TYPES.CREATE_FAKE_STORAGE && !showExplanation)) {
|
||||
passwordInputRef.current?.focus();
|
||||
} else if (modalType === MODAL_TYPES.ENTER_PASSWORD) {
|
||||
passwordInputRef.current?.focus();
|
||||
}
|
||||
},
|
||||
dismiss: async () => {
|
||||
await modalRef.current?.dismiss();
|
||||
resetState();
|
||||
},
|
||||
}));
|
||||
|
||||
const resetState = () => {
|
||||
setPassword('');
|
||||
setConfirmPassword('');
|
||||
setIsSuccess(false);
|
||||
setIsLoading(false);
|
||||
fadeOutAnimation.setValue(1);
|
||||
fadeInAnimation.setValue(0);
|
||||
scaleAnimation.setValue(1);
|
||||
shakeAnimation.setValue(0);
|
||||
explanationOpacity.setValue(1);
|
||||
setShowExplanation(modalType === MODAL_TYPES.CREATE_PASSWORD);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
resetState();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [modalType]);
|
||||
|
||||
const performShake = (shakeAnimRef: Animated.Value) => {
|
||||
Animated.sequence([
|
||||
Animated.timing(shakeAnimRef, {
|
||||
toValue: 10,
|
||||
duration: 100,
|
||||
easing: Easing.inOut(Easing.ease),
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(shakeAnimRef, {
|
||||
toValue: -10,
|
||||
duration: 100,
|
||||
easing: Easing.inOut(Easing.ease),
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(shakeAnimRef, {
|
||||
toValue: 5,
|
||||
duration: 100,
|
||||
easing: Easing.inOut(Easing.ease),
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(shakeAnimRef, {
|
||||
toValue: -5,
|
||||
duration: 100,
|
||||
easing: Easing.inOut(Easing.ease),
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(shakeAnimRef, {
|
||||
toValue: 0,
|
||||
duration: 100,
|
||||
easing: Easing.inOut(Easing.ease),
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start();
|
||||
};
|
||||
|
||||
const handleShakeAnimation = () => {
|
||||
performShake(shakeAnimation);
|
||||
};
|
||||
|
||||
const handleSuccessAnimation = () => {
|
||||
// Step 1: Cross-fade current content out and success content in
|
||||
Animated.timing(fadeOutAnimation, {
|
||||
toValue: 0, // Fade out current content
|
||||
duration: 300,
|
||||
easing: Easing.out(Easing.ease),
|
||||
useNativeDriver: true,
|
||||
}).start(() => {
|
||||
setIsSuccess(true);
|
||||
|
||||
Animated.timing(fadeInAnimation, {
|
||||
toValue: 1, // Fade in success content
|
||||
duration: 300,
|
||||
easing: Easing.out(Easing.ease),
|
||||
useNativeDriver: true,
|
||||
}).start(() => {
|
||||
// Step 2: Perform any additional animations like scaling if necessary
|
||||
Animated.timing(scaleAnimation, {
|
||||
toValue: 1.1,
|
||||
duration: 300,
|
||||
easing: Easing.out(Easing.ease),
|
||||
useNativeDriver: true,
|
||||
}).start(() => {
|
||||
Animated.timing(scaleAnimation, {
|
||||
toValue: 1, // Return scale to normal size
|
||||
duration: 300,
|
||||
easing: Easing.out(Easing.ease),
|
||||
useNativeDriver: true,
|
||||
}).start(() => {
|
||||
// Optional delay before dismissing the modal
|
||||
setTimeout(async () => {
|
||||
await modalRef.current?.dismiss();
|
||||
}, 1000);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleConfirmationFailure = () => {
|
||||
triggerHapticFeedback(HapticFeedbackTypes.NotificationError);
|
||||
if (!isSuccess) handleShakeAnimation();
|
||||
onConfirmationFailure();
|
||||
};
|
||||
|
||||
const handleConfirmSuccess = () => {
|
||||
triggerHapticFeedback(HapticFeedbackTypes.NotificationSuccess);
|
||||
handleSuccessAnimation();
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
Keyboard.dismiss();
|
||||
setIsLoading(true);
|
||||
let success = false;
|
||||
|
||||
try {
|
||||
if (modalType === MODAL_TYPES.CREATE_PASSWORD || modalType === MODAL_TYPES.CREATE_FAKE_STORAGE) {
|
||||
if (password === confirmPassword && password) {
|
||||
success = await onConfirmationSuccess(password);
|
||||
success ? handleConfirmSuccess() : handleConfirmationFailure();
|
||||
} else {
|
||||
handleConfirmationFailure();
|
||||
}
|
||||
} else if (modalType === MODAL_TYPES.ENTER_PASSWORD) {
|
||||
success = await onConfirmationSuccess(password);
|
||||
success ? handleConfirmSuccess() : handleConfirmationFailure();
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false); // Ensure loading state is reset
|
||||
if (success) {
|
||||
// Ensure shake animation is reset before starting the success animation
|
||||
shakeAnimation.setValue(0);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleTransitionToCreatePassword = () => {
|
||||
Animated.timing(explanationOpacity, {
|
||||
toValue: 0,
|
||||
duration: 300,
|
||||
useNativeDriver: true,
|
||||
}).start(() => {
|
||||
setShowExplanation(false);
|
||||
explanationOpacity.setValue(1); // Reset opacity for when transitioning back
|
||||
passwordInputRef.current?.focus();
|
||||
});
|
||||
};
|
||||
|
||||
const handleCancel = async () => {
|
||||
onConfirmationFailure();
|
||||
await modalRef.current?.dismiss();
|
||||
};
|
||||
|
||||
const animatedViewStyle: Animated.WithAnimatedObject<ViewStyle> = {
|
||||
opacity: fadeOutAnimation,
|
||||
transform: [{ scale: scaleAnimation }],
|
||||
width: '100%',
|
||||
};
|
||||
|
||||
const onModalDismiss = () => {
|
||||
resetState();
|
||||
onConfirmationFailure();
|
||||
};
|
||||
|
||||
const opacity = isVisible ? 0 : 1;
|
||||
return (
|
||||
<BottomModal
|
||||
ref={modalRef}
|
||||
onClose={onModalDismiss}
|
||||
grabber={false}
|
||||
showCloseButton={!isSuccess}
|
||||
onCloseModalPressed={handleCancel}
|
||||
backgroundColor={colors.modal}
|
||||
isGrabberVisible={!isSuccess}
|
||||
dismissible={false}
|
||||
sizes={Platform.OS === 'ios' ? ['auto'] : [460, '90%']}
|
||||
footer={
|
||||
!isSuccess ? (
|
||||
showExplanation && modalType === MODAL_TYPES.CREATE_PASSWORD ? (
|
||||
<Animated.View style={[{ opacity: explanationOpacity }, styles.feeModalFooterSpacing]}>
|
||||
<SecondButton
|
||||
title={loc.settings.i_understand}
|
||||
onPress={handleTransitionToCreatePassword}
|
||||
disabled={isLoading}
|
||||
testID="IUnderstandButton"
|
||||
/>
|
||||
</Animated.View>
|
||||
) : (
|
||||
<Animated.View
|
||||
style={[
|
||||
{ opacity: isVisible ? opacity : fadeOutAnimation, transform: [{ scale: scaleAnimation }] },
|
||||
styles.feeModalFooterSpacing,
|
||||
]}
|
||||
>
|
||||
<SecondButton
|
||||
title={isLoading ? '' : loc._.ok}
|
||||
onPress={handleSubmit}
|
||||
testID="OKButton"
|
||||
loading={isLoading}
|
||||
disabled={isLoading || !password || (modalType === MODAL_TYPES.CREATE_PASSWORD && !confirmPassword)}
|
||||
/>
|
||||
</Animated.View>
|
||||
)
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{!isSuccess && (
|
||||
<Animated.View style={[animatedViewStyle, styles.minHeight]}>
|
||||
{modalType === MODAL_TYPES.CREATE_PASSWORD && showExplanation && (
|
||||
<Animated.View style={{ opacity: explanationOpacity }}>
|
||||
<Text style={[styles.textLabel, stylesHook.feeModalLabel]}>{loc.settings.encrypt_storage_explanation_headline}</Text>
|
||||
<Animated.View>
|
||||
<Text style={[styles.description, stylesHook.feeModalCustomText]} maxFontSizeMultiplier={1.2}>
|
||||
{loc.settings.encrypt_storage_explanation_description_line1}
|
||||
</Text>
|
||||
<Text style={[styles.description, stylesHook.feeModalCustomText]} maxFontSizeMultiplier={1.2}>
|
||||
{loc.settings.encrypt_storage_explanation_description_line2}
|
||||
</Text>
|
||||
</Animated.View>
|
||||
<View style={styles.feeModalFooter} />
|
||||
</Animated.View>
|
||||
)}
|
||||
{(modalType === MODAL_TYPES.ENTER_PASSWORD ||
|
||||
((modalType === MODAL_TYPES.CREATE_PASSWORD || modalType === MODAL_TYPES.CREATE_FAKE_STORAGE) && !showExplanation)) && (
|
||||
<>
|
||||
<Text adjustsFontSizeToFit style={[styles.textLabel, stylesHook.feeModalLabel]}>
|
||||
{modalType === MODAL_TYPES.CREATE_PASSWORD
|
||||
? loc.settings.password_explain
|
||||
: modalType === MODAL_TYPES.CREATE_FAKE_STORAGE
|
||||
? `${loc.settings.password_explain} ${loc.plausibledeniability.create_password_explanation}`
|
||||
: loc._.enter_password}
|
||||
</Text>
|
||||
<View style={styles.inputContainer}>
|
||||
<Animated.View style={{ transform: [{ translateX: shakeAnimation }] }}>
|
||||
<TextInput
|
||||
testID="PasswordInput"
|
||||
ref={passwordInputRef}
|
||||
secureTextEntry
|
||||
placeholder="Password"
|
||||
value={password}
|
||||
autoCapitalize="none"
|
||||
autoComplete="off"
|
||||
autoCorrect={false}
|
||||
onChangeText={setPassword}
|
||||
style={[styles.input, stylesHook.input]}
|
||||
clearTextOnFocus
|
||||
clearButtonMode="while-editing"
|
||||
autoFocus
|
||||
/>
|
||||
</Animated.View>
|
||||
{(modalType === MODAL_TYPES.CREATE_PASSWORD || modalType === MODAL_TYPES.CREATE_FAKE_STORAGE) && (
|
||||
<Animated.View style={{ transform: [{ translateX: shakeAnimation }] }}>
|
||||
<TextInput
|
||||
testID="ConfirmPasswordInput"
|
||||
ref={confirmPasswordInputRef}
|
||||
secureTextEntry
|
||||
placeholder="Confirm Password"
|
||||
value={confirmPassword}
|
||||
clearTextOnFocus
|
||||
autoCorrect={false}
|
||||
autoComplete="off"
|
||||
autoCapitalize="none"
|
||||
clearButtonMode="while-editing"
|
||||
onChangeText={setConfirmPassword}
|
||||
style={[styles.input, stylesHook.input]}
|
||||
/>
|
||||
</Animated.View>
|
||||
)}
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</Animated.View>
|
||||
)}
|
||||
{isSuccess && (
|
||||
<Animated.View
|
||||
style={{
|
||||
opacity: fadeInAnimation,
|
||||
transform: [{ scale: scaleAnimation }],
|
||||
}}
|
||||
>
|
||||
<View style={styles.successContainer}>
|
||||
<View style={styles.circle}>
|
||||
<Animated.Text
|
||||
style={[
|
||||
styles.checkmark,
|
||||
{
|
||||
transform: [
|
||||
{
|
||||
scale: scaleAnimation.interpolate({
|
||||
inputRange: [0.8, 1],
|
||||
outputRange: [0.8, 1],
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
>
|
||||
✔️
|
||||
</Animated.Text>
|
||||
</View>
|
||||
</View>
|
||||
</Animated.View>
|
||||
)}
|
||||
</BottomModal>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export default PromptPasswordConfirmationModal;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
modalContent: {
|
||||
padding: 22,
|
||||
width: '100%',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
minHeight: {
|
||||
minHeight: 420,
|
||||
},
|
||||
feeModalFooter: {
|
||||
padding: 16,
|
||||
},
|
||||
feeModalFooterSpacing: {
|
||||
padding: 16,
|
||||
marginVertical: 24,
|
||||
},
|
||||
inputContainer: {
|
||||
marginBottom: 10,
|
||||
width: '100%',
|
||||
},
|
||||
input: {
|
||||
borderRadius: 4,
|
||||
padding: 8,
|
||||
marginVertical: 8,
|
||||
fontSize: 16,
|
||||
width: '100%',
|
||||
},
|
||||
textLabel: {
|
||||
fontSize: 20,
|
||||
fontWeight: '600',
|
||||
marginBottom: 16,
|
||||
textAlign: 'center',
|
||||
},
|
||||
description: {
|
||||
fontSize: 16,
|
||||
marginBottom: 12,
|
||||
textAlign: 'center',
|
||||
},
|
||||
successContainer: {
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
margin: 24,
|
||||
marginBottom: 48,
|
||||
},
|
||||
circle: {
|
||||
width: 60,
|
||||
height: 60,
|
||||
borderRadius: 30,
|
||||
backgroundColor: 'green',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
checkmark: {
|
||||
color: 'white',
|
||||
fontSize: 30,
|
||||
},
|
||||
});
|
||||
@ -1,6 +1,6 @@
|
||||
import Clipboard from '@react-native-clipboard/clipboard';
|
||||
import React, { useCallback, useRef } from 'react';
|
||||
import { Platform, StyleSheet, View } from 'react-native';
|
||||
import { Platform, StyleSheet, View, ViewStyle } from 'react-native';
|
||||
import QRCode from 'react-native-qrcode-svg';
|
||||
import Share from 'react-native-share';
|
||||
|
||||
@ -63,7 +63,7 @@ const QRCodeComponent: React.FC<QRCodeComponentProps> = ({
|
||||
ecl = 'H',
|
||||
onError = () => {},
|
||||
}) => {
|
||||
const qrCode = useRef<any>();
|
||||
const qrCode = useRef<any>(null);
|
||||
const { colors, dark } = useTheme();
|
||||
|
||||
const handleShareQRCode = () => {
|
||||
@ -90,6 +90,13 @@ const QRCodeComponent: React.FC<QRCodeComponentProps> = ({
|
||||
container: { borderWidth: dark ? BORDER_WIDTH : 0 },
|
||||
});
|
||||
|
||||
const qrButtonStyle: ViewStyle = {
|
||||
width: newSize,
|
||||
height: newSize,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
};
|
||||
|
||||
const renderQRCode = (
|
||||
<QRCode
|
||||
value={value}
|
||||
@ -102,20 +109,27 @@ const QRCodeComponent: React.FC<QRCodeComponentProps> = ({
|
||||
ecl={ecl}
|
||||
getRef={(c: any) => (qrCode.current = c)}
|
||||
onError={onError}
|
||||
testID="BitcoinAddressQRCode"
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[styles.container, stylesHook.container]}
|
||||
testID="BitcoinAddressQRCodeContainer"
|
||||
accessibilityIgnoresInvertColors
|
||||
importantForAccessibility="no-hide-descendants"
|
||||
accessibilityRole="image"
|
||||
accessibilityLabel={loc.receive.qrcode_for_the_address}
|
||||
>
|
||||
{isMenuAvailable ? (
|
||||
<ToolTipMenu actions={menuActions} onPressMenuItem={onPressMenuItem}>
|
||||
<ToolTipMenu
|
||||
actions={menuActions}
|
||||
onPressMenuItem={onPressMenuItem}
|
||||
shouldOpenOnLongPress
|
||||
isButton
|
||||
enableAndroidRipple={false}
|
||||
buttonStyle={qrButtonStyle}
|
||||
>
|
||||
{renderQRCode}
|
||||
</ToolTipMenu>
|
||||
) : (
|
||||
@ -128,5 +142,5 @@ const QRCodeComponent: React.FC<QRCodeComponentProps> = ({
|
||||
export default QRCodeComponent;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { borderColor: '#FFFFFF' },
|
||||
container: { borderColor: '#FFFFFF', alignItems: 'center', justifyContent: 'center' },
|
||||
});
|
||||
|
||||
@ -53,7 +53,7 @@ const SaveFileButton: React.FC<SaveFileButtonProps> = ({
|
||||
onMenuWillHide={onMenuWillHide}
|
||||
onMenuWillShow={onMenuWillShow}
|
||||
isButton
|
||||
isMenuPrimaryAction
|
||||
shouldOpenOnLongPress={false}
|
||||
actions={actions}
|
||||
onPressMenuItem={handlePressMenuItem}
|
||||
buttonStyle={style as ViewStyle} // Type assertion to match ViewStyle
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { requireNativeComponent, View, StyleSheet, NativeSyntheticEvent } from 'react-native';
|
||||
import { View, StyleSheet, NativeSyntheticEvent } from 'react-native';
|
||||
import NativeSegmentedControl from '../codegen/SegmentControlNativeComponent';
|
||||
|
||||
interface SegmentedControlProps {
|
||||
values: string[];
|
||||
@ -11,15 +12,6 @@ interface SegmentedControlEvent {
|
||||
selectedIndex: number;
|
||||
}
|
||||
|
||||
interface NativeSegmentedControlProps {
|
||||
values: string[];
|
||||
selectedIndex: number;
|
||||
onChangeEvent: (event: NativeSyntheticEvent<SegmentedControlEvent>) => void;
|
||||
style?: object;
|
||||
}
|
||||
|
||||
const NativeSegmentedControl = requireNativeComponent<NativeSegmentedControlProps>('CustomSegmentedControl');
|
||||
|
||||
const SegmentedControl: React.FC<SegmentedControlProps> = ({ values, selectedIndex, onChange }) => {
|
||||
const handleChange = useMemo(
|
||||
() => (event: NativeSyntheticEvent<SegmentedControlEvent>) => {
|
||||
@ -36,7 +28,17 @@ const SegmentedControl: React.FC<SegmentedControlProps> = ({ values, selectedInd
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<NativeSegmentedControl values={values} selectedIndex={selectedIndex} style={styles.segmentedControl} onChangeEvent={handleChange} />
|
||||
<NativeSegmentedControl
|
||||
values={values}
|
||||
selectedIndex={selectedIndex}
|
||||
enabled
|
||||
backgroundColor="transparent"
|
||||
tintColor="#007AFF"
|
||||
textColor="#007AFF"
|
||||
momentary={false}
|
||||
style={styles.segmentedControl}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@ -44,7 +46,7 @@ const SegmentedControl: React.FC<SegmentedControlProps> = ({ values, selectedInd
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
width: '100%',
|
||||
marginHorizontal: 18,
|
||||
marginHorizontal: 0,
|
||||
marginBottom: 18,
|
||||
minHeight: 40,
|
||||
},
|
||||
|
||||
@ -10,7 +10,7 @@ interface SettingsBlockExplorerCustomUrlItemProps {
|
||||
customUrl: string;
|
||||
onCustomUrlChange: (url: string) => void;
|
||||
onSubmitCustomUrl: () => void;
|
||||
inputRef?: React.RefObject<TextInput>;
|
||||
inputRef?: React.RefObject<TextInput | null>;
|
||||
}
|
||||
|
||||
const SettingsBlockExplorerCustomUrlItem: React.FC<SettingsBlockExplorerCustomUrlItemProps> = ({
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import React, { useCallback, useMemo, useRef } from 'react';
|
||||
import { Animated, Platform, TouchableOpacity } from 'react-native';
|
||||
import { Platform, Pressable, StyleSheet, ViewStyle } from 'react-native';
|
||||
import { MenuView, MenuAction, NativeActionEvent } from '@react-native-menu/menu';
|
||||
import { ToolTipMenuProps, Action } from './types';
|
||||
import { useSettings } from '../hooks/context/useSettings';
|
||||
@ -7,153 +7,183 @@ import { useSettings } from '../hooks/context/useSettings';
|
||||
const ToolTipMenu = (props: ToolTipMenuProps) => {
|
||||
const {
|
||||
title = '',
|
||||
isMenuPrimaryAction = false,
|
||||
shouldOpenOnLongPress = true,
|
||||
disabled = false,
|
||||
onPress,
|
||||
buttonStyle,
|
||||
onPressMenuItem,
|
||||
children,
|
||||
isButton = false,
|
||||
...restProps
|
||||
actions,
|
||||
accessibilityLabel,
|
||||
accessibilityHint,
|
||||
accessibilityRole,
|
||||
accessibilityState,
|
||||
testID,
|
||||
onMenuWillShow,
|
||||
onMenuWillHide,
|
||||
enableAndroidRipple = true,
|
||||
} = props;
|
||||
|
||||
const { language } = useSettings();
|
||||
const scaleAnim = useRef(new Animated.Value(1)).current;
|
||||
const openedRef = useRef(false);
|
||||
const menuRef = useRef<any>(null);
|
||||
|
||||
const handlePressIn = useCallback(() => {
|
||||
Animated.spring(scaleAnim, {
|
||||
toValue: 0.98,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
}, [scaleAnim]);
|
||||
|
||||
const handlePressOut = useCallback(() => {
|
||||
Animated.spring(scaleAnim, {
|
||||
toValue: 1,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
}, [scaleAnim]);
|
||||
|
||||
// Map Menu Items for RN Menu (supports subactions and displayInline)
|
||||
const mapMenuItemForMenuView = useCallback((action: Action): MenuAction | null => {
|
||||
if (!action.id) return null;
|
||||
|
||||
// Check for subactions
|
||||
const subactions =
|
||||
action.subactions?.map(subaction => {
|
||||
const subMenuItem: MenuAction = {
|
||||
id: subaction.id.toString(),
|
||||
title: subaction.text,
|
||||
subtitle: subaction.subtitle,
|
||||
image: subaction.icon?.iconValue ? subaction.icon.iconValue : undefined,
|
||||
attributes: { disabled: subaction.disabled, destructive: subaction.destructive, hidden: subaction.hidden },
|
||||
};
|
||||
if ('menuState' in subaction) {
|
||||
subMenuItem.state = subaction.menuState ? 'on' : 'off';
|
||||
}
|
||||
if (subaction.subactions && subaction.subactions.length > 0) {
|
||||
const deepSubactions = subaction.subactions.map(deepSub => {
|
||||
const deepMenuItem: MenuAction = {
|
||||
id: deepSub.id.toString(),
|
||||
title: deepSub.text,
|
||||
subtitle: deepSub.subtitle,
|
||||
image: deepSub.icon?.iconValue ? deepSub.icon.iconValue : undefined,
|
||||
attributes: { disabled: deepSub.disabled, destructive: deepSub.destructive, hidden: deepSub.hidden },
|
||||
};
|
||||
if ('menuState' in deepSub) {
|
||||
deepMenuItem.state = deepSub.menuState ? 'on' : 'off';
|
||||
}
|
||||
return deepMenuItem;
|
||||
});
|
||||
subMenuItem.subactions = deepSubactions;
|
||||
}
|
||||
return subMenuItem;
|
||||
}) || [];
|
||||
|
||||
const menuItem: MenuAction = {
|
||||
id: action.id.toString(),
|
||||
title: action.text,
|
||||
subtitle: action.subtitle,
|
||||
image: action.icon?.iconValue ? action.icon.iconValue : undefined,
|
||||
attributes: { disabled: action.disabled, destructive: action.destructive, hidden: action.hidden },
|
||||
displayInline: action.displayInline || false,
|
||||
};
|
||||
if ('menuState' in action) {
|
||||
menuItem.state = action.menuState ? 'on' : 'off';
|
||||
const normalizeMenuState = useCallback((menuState?: Action['menuState']): MenuAction['state'] | undefined => {
|
||||
if (menuState === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (subactions.length > 0) {
|
||||
menuItem.subactions = subactions;
|
||||
if (menuState === 'mixed') {
|
||||
return 'mixed';
|
||||
}
|
||||
return menuItem;
|
||||
return menuState ? 'on' : 'off';
|
||||
}, []);
|
||||
|
||||
const menuViewItemsIOS = useMemo(() => {
|
||||
return props.actions
|
||||
.map(actionGroup => {
|
||||
if (Array.isArray(actionGroup) && actionGroup.length > 0) {
|
||||
return {
|
||||
id: actionGroup[0].id.toString(),
|
||||
title: '',
|
||||
subactions: actionGroup
|
||||
.filter(action => action.id)
|
||||
.map(mapMenuItemForMenuView)
|
||||
.filter(item => item !== null) as MenuAction[],
|
||||
displayInline: true,
|
||||
};
|
||||
} else if (!Array.isArray(actionGroup) && actionGroup.id) {
|
||||
return mapMenuItemForMenuView(actionGroup);
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter(item => item !== null) as MenuAction[];
|
||||
}, [props.actions, mapMenuItemForMenuView]);
|
||||
const buildAttributes = useCallback((action: Action): MenuAction['attributes'] | undefined => {
|
||||
const attributes = {
|
||||
destructive: Boolean(action.destructive),
|
||||
disabled: Boolean(action.disabled),
|
||||
hidden: Boolean(action.hidden),
|
||||
};
|
||||
|
||||
const menuViewItemsAndroid = useMemo(() => {
|
||||
const mergedActions = props.actions.flat().filter(action => action.id);
|
||||
return mergedActions.map(mapMenuItemForMenuView).filter(item => item !== null) as MenuAction[];
|
||||
}, [props.actions, mapMenuItemForMenuView]);
|
||||
if (!attributes.destructive && !attributes.disabled && !attributes.hidden) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const handlePressMenuItemForMenuView = useCallback(
|
||||
({ nativeEvent }: NativeActionEvent) => {
|
||||
onPressMenuItem(nativeEvent.event);
|
||||
return attributes;
|
||||
}, []);
|
||||
|
||||
const mapMenuItemForMenuView = useCallback(
|
||||
(action: Action): MenuAction | null => {
|
||||
if (!action?.id) return null;
|
||||
|
||||
const mappedSubactions = (action.subactions || [])
|
||||
.map(subaction => mapMenuItemForMenuView(subaction))
|
||||
.filter((item): item is MenuAction => item !== null);
|
||||
|
||||
const menuItem: MenuAction = {
|
||||
id: action.id.toString(),
|
||||
title: action.text,
|
||||
subtitle: action.subtitle,
|
||||
image: action.icon?.iconValue ?? action.image,
|
||||
imageColor: action.imageColor,
|
||||
attributes: buildAttributes(action),
|
||||
displayInline: Platform.OS === 'ios' ? action.displayInline : undefined,
|
||||
};
|
||||
|
||||
const state = normalizeMenuState(action.menuState);
|
||||
if (state) {
|
||||
menuItem.state = state;
|
||||
}
|
||||
|
||||
if (mappedSubactions.length > 0) {
|
||||
menuItem.subactions = mappedSubactions;
|
||||
}
|
||||
|
||||
return menuItem;
|
||||
},
|
||||
[onPressMenuItem],
|
||||
[buildAttributes, normalizeMenuState],
|
||||
);
|
||||
|
||||
const menuViewItemsIOS = useMemo(() => {
|
||||
return actions
|
||||
.map(actionGroup => {
|
||||
if (Array.isArray(actionGroup) && actionGroup.length > 0) {
|
||||
const inlineActions = actionGroup.map(mapMenuItemForMenuView).filter((item): item is MenuAction => item !== null);
|
||||
if (inlineActions.length === 0) return null;
|
||||
const group: MenuAction = {
|
||||
id: inlineActions[0].id,
|
||||
title: '',
|
||||
subactions: inlineActions,
|
||||
displayInline: true,
|
||||
};
|
||||
return group;
|
||||
}
|
||||
|
||||
if (!Array.isArray(actionGroup)) {
|
||||
return mapMenuItemForMenuView(actionGroup);
|
||||
}
|
||||
|
||||
return null;
|
||||
})
|
||||
.filter((item): item is MenuAction => item !== null);
|
||||
}, [actions, mapMenuItemForMenuView]);
|
||||
|
||||
const menuViewItemsAndroid = useMemo(() => {
|
||||
const mergedActions = actions.flat().filter(action => action.id);
|
||||
return mergedActions.map(mapMenuItemForMenuView).filter((item): item is MenuAction => item !== null);
|
||||
}, [actions, mapMenuItemForMenuView]);
|
||||
|
||||
const handlePressMenuItemForMenuView = ({ nativeEvent }: NativeActionEvent) => {
|
||||
if (nativeEvent?.event) {
|
||||
onPressMenuItem(nativeEvent.event);
|
||||
}
|
||||
};
|
||||
|
||||
const renderMenuView = () => {
|
||||
if (disabled || (!isButton && !onPress)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<MenuView
|
||||
title={title}
|
||||
isAnchoredToRight
|
||||
onPressAction={handlePressMenuItemForMenuView}
|
||||
actions={Platform.OS === 'ios' ? menuViewItemsIOS : menuViewItemsAndroid}
|
||||
shouldOpenOnLongPress={!isMenuPrimaryAction}
|
||||
// @ts-ignore: Not exposed in types
|
||||
accessibilityLabel={props.accessibilityLabel}
|
||||
accessibilityHint={props.accessibilityHint}
|
||||
accessibilityRole={props.accessibilityRole}
|
||||
<Pressable
|
||||
android_ripple={enableAndroidRipple ? { color: '#d9d9d9', foreground: true } : undefined}
|
||||
style={({ pressed }) => {
|
||||
const base: ViewStyle[] = [styles.pressable];
|
||||
if (buttonStyle) {
|
||||
if (Array.isArray(buttonStyle)) {
|
||||
base.push(...buttonStyle);
|
||||
} else {
|
||||
base.push(buttonStyle);
|
||||
}
|
||||
}
|
||||
if (pressed && enableAndroidRipple) base.push(styles.pressed);
|
||||
return base;
|
||||
}}
|
||||
disabled={disabled}
|
||||
onPress={onPress}
|
||||
onLongPress={shouldOpenOnLongPress ? () => {} : undefined}
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
accessibilityHint={accessibilityHint}
|
||||
accessibilityRole={accessibilityRole}
|
||||
accessibilityState={accessibilityState}
|
||||
accessibilityLanguage={language}
|
||||
testID={testID}
|
||||
hitSlop={8}
|
||||
>
|
||||
{isMenuPrimaryAction || isButton ? (
|
||||
<TouchableOpacity
|
||||
style={buttonStyle}
|
||||
disabled={disabled}
|
||||
onPress={onPress}
|
||||
onPressIn={handlePressIn}
|
||||
onPressOut={handlePressOut}
|
||||
activeOpacity={1}
|
||||
{...restProps}
|
||||
>
|
||||
<Animated.View style={{ transform: [{ scale: scaleAnim }] }}>{children}</Animated.View>
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</MenuView>
|
||||
<MenuView
|
||||
ref={menuRef}
|
||||
title={title}
|
||||
isAnchoredToRight
|
||||
onOpenMenu={() => {
|
||||
openedRef.current = true;
|
||||
onMenuWillShow?.();
|
||||
}}
|
||||
onCloseMenu={() => {
|
||||
if (!openedRef.current) {
|
||||
return;
|
||||
}
|
||||
openedRef.current = false;
|
||||
onMenuWillHide?.();
|
||||
}}
|
||||
onPressAction={handlePressMenuItemForMenuView}
|
||||
actions={Platform.OS === 'ios' ? menuViewItemsIOS : menuViewItemsAndroid}
|
||||
shouldOpenOnLongPress={shouldOpenOnLongPress}
|
||||
style={buttonStyle ? styles.menuViewFlex : undefined}
|
||||
>
|
||||
{children}
|
||||
</MenuView>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
return props.actions.length > 0 ? renderMenuView() : null;
|
||||
return actions.length > 0 ? renderMenuView() : null;
|
||||
};
|
||||
|
||||
export default ToolTipMenu;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
menuViewFlex: { flex: 1 },
|
||||
pressable: { alignSelf: 'center' },
|
||||
pressed: { opacity: 0.6 },
|
||||
});
|
||||
|
||||
@ -90,10 +90,10 @@ const TotalWalletsBalance: React.FC = React.memo(() => {
|
||||
await setTotalBalancePreferredUnitStorage(nextUnit);
|
||||
}, [totalBalancePreferredUnit, setTotalBalancePreferredUnitStorage]);
|
||||
|
||||
if (wallets.length <= 1 || !isTotalBalanceEnabled) return null;
|
||||
if (!isTotalBalanceEnabled) return null;
|
||||
|
||||
return (
|
||||
<ToolTipMenu actions={toolTipActions} onPressMenuItem={onPressMenuItem}>
|
||||
<ToolTipMenu actions={toolTipActions} onPressMenuItem={onPressMenuItem} shouldOpenOnLongPress>
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.label}>{loc.wallets.total_balance}</Text>
|
||||
<TouchableOpacity onPress={handleBalanceOnPress}>
|
||||
|
||||
@ -1,8 +1,7 @@
|
||||
import React, { useCallback, useMemo, useRef, memo } from 'react';
|
||||
import React, { useCallback, useMemo, memo } from 'react';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import Clipboard from '@react-native-clipboard/clipboard';
|
||||
import { uint8ArrayToHex } from '../blue_modules/uint8array-extras';
|
||||
import { Linking, Text, TextStyle, ViewStyle, StyleSheet } from 'react-native';
|
||||
import { Linking, Text, TextStyle, ViewStyle, StyleSheet, View } from 'react-native';
|
||||
import Lnurl from '../class/lnurl';
|
||||
import { LightningTransaction, Transaction } from '../class/wallets/types';
|
||||
import TransactionExpiredIcon from '../components/icons/TransactionExpiredIcon';
|
||||
@ -15,9 +14,8 @@ import TransactionPendingIcon from '../components/icons/TransactionPendingIcon';
|
||||
import loc, { formatBalanceWithoutSuffix, formatTransactionListDate, transactionTimeToReadable } from '../loc';
|
||||
import { BitcoinUnit } from '../models/bitcoinUnits';
|
||||
import { useSettings } from '../hooks/context/useSettings';
|
||||
import ListItem from './ListItem';
|
||||
import { useTheme } from './themes';
|
||||
import { Action, ToolTipMenuProps } from './types';
|
||||
import { Action } from './types';
|
||||
import { useExtendedNavigation } from '../hooks/useExtendedNavigation';
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { DetailViewStackParamList } from '../navigation/DetailViewStackParamList';
|
||||
@ -26,16 +24,51 @@ import ToolTipMenu from './TooltipMenu';
|
||||
import { CommonToolTipActions } from '../typings/CommonToolTipActions';
|
||||
import { pop } from '../NavigationService';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { uint8ArrayToHex } from '../blue_modules/uint8array-extras';
|
||||
import ListItem from './ListItem';
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
pressable: {
|
||||
paddingVertical: 12,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
width: '100%',
|
||||
},
|
||||
dateLine: {
|
||||
fontSize: 13,
|
||||
},
|
||||
highlight: {
|
||||
backgroundColor: '#FFF5C0',
|
||||
color: '#000000',
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
fullWidthButton: {
|
||||
width: '100%',
|
||||
alignSelf: 'stretch',
|
||||
},
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
},
|
||||
avatarContainer: {
|
||||
marginRight: 12,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
textContainer: {
|
||||
flex: 1,
|
||||
paddingRight: 8,
|
||||
},
|
||||
title: {
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
},
|
||||
rightColumn: {
|
||||
marginLeft: 8,
|
||||
alignItems: 'flex-end',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
rightTitle: {
|
||||
textAlign: 'right',
|
||||
},
|
||||
});
|
||||
|
||||
@ -45,7 +78,7 @@ interface TransactionListItemProps {
|
||||
item: Transaction & LightningTransaction; // using type intersection to have less issues with ts
|
||||
searchQuery?: string;
|
||||
style?: ViewStyle;
|
||||
renderHighlightedText?: (text: string, query: string) => JSX.Element;
|
||||
renderHighlightedText?: (text: string, query: string) => React.ReactElement;
|
||||
onPress?: () => void;
|
||||
disableNavigation?: boolean;
|
||||
}
|
||||
@ -65,7 +98,6 @@ export const TransactionListItem: React.FC<TransactionListItemProps> = memo(
|
||||
}: TransactionListItemProps) => {
|
||||
const { colors } = useTheme();
|
||||
const { navigate } = useExtendedNavigation<NavigationProps>();
|
||||
const menuRef = useRef<ToolTipMenuProps>();
|
||||
const { txMetadata, counterpartyMetadata, wallets } = useStorage();
|
||||
const { language, selectedBlockExplorer } = useSettings();
|
||||
const insets = useSafeAreaInsets();
|
||||
@ -118,8 +150,6 @@ export const TransactionListItem: React.FC<TransactionListItemProps> = memo(
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isPending, item.timestamp, language]);
|
||||
|
||||
const dateLineStyle = useMemo(() => [styles.dateLine, { color: colors.alternativeTextColor }], [colors.alternativeTextColor]);
|
||||
|
||||
const formattedAmount = useMemo(() => {
|
||||
return formatBalanceWithoutSuffix(item.value && item.value, itemPriceUnit, true).toString();
|
||||
}, [item.value, itemPriceUnit]);
|
||||
@ -138,12 +168,7 @@ export const TransactionListItem: React.FC<TransactionListItemProps> = memo(
|
||||
return formattedAmount;
|
||||
}, [item, formattedAmount]);
|
||||
|
||||
const rightMemoStyle = useMemo(
|
||||
() => [styles.dateLine, { color: colors.alternativeTextColor, textAlign: 'right' as const }],
|
||||
[colors.alternativeTextColor],
|
||||
);
|
||||
|
||||
const rowTitleStyle = useMemo(() => {
|
||||
const rowTitleStyle = useMemo<TextStyle>(() => {
|
||||
let color = colors.successColor;
|
||||
|
||||
if (item.type === 'user_invoice' || item.type === 'payment_request') {
|
||||
@ -254,7 +279,6 @@ export const TransactionListItem: React.FC<TransactionListItemProps> = memo(
|
||||
}, [formattedAmount, itemPriceUnit]);
|
||||
|
||||
const onPress = useCallback(async () => {
|
||||
menuRef?.current?.dismissMenu?.();
|
||||
// If a custom onPress handler was provided, use it and return
|
||||
if (customOnPress) {
|
||||
customOnPress();
|
||||
@ -272,16 +296,16 @@ export const TransactionListItem: React.FC<TransactionListItemProps> = memo(
|
||||
try {
|
||||
// is it a successful lnurl-pay?
|
||||
const LN = new Lnurl(false, AsyncStorage);
|
||||
let paymentHash = item.payment_hash!;
|
||||
if (typeof paymentHash === 'object') {
|
||||
paymentHash = uint8ArrayToHex(new Uint8Array((paymentHash as any).data));
|
||||
}
|
||||
const loaded = await LN.loadSuccessfulPayment(paymentHash);
|
||||
const rawPaymentHash = item.payment_hash;
|
||||
if (!rawPaymentHash) throw new Error('Missing payment hash');
|
||||
const normalizedPaymentHash =
|
||||
typeof rawPaymentHash === 'string' ? rawPaymentHash : uint8ArrayToHex(new Uint8Array((rawPaymentHash as any).data));
|
||||
const loaded = await LN.loadSuccessfulPayment(normalizedPaymentHash);
|
||||
if (loaded) {
|
||||
navigate('ScanLNDInvoiceRoot', {
|
||||
screen: 'LnurlPaySuccess',
|
||||
params: {
|
||||
paymentHash,
|
||||
paymentHash: normalizedPaymentHash,
|
||||
justPaid: false,
|
||||
fromWalletID: lightningWallet[0].getID(),
|
||||
},
|
||||
@ -380,28 +404,84 @@ export const TransactionListItem: React.FC<TransactionListItemProps> = memo(
|
||||
return actions as Action[];
|
||||
}, [rowTitle, noteForCopy, item.hash]);
|
||||
|
||||
const title = listTitle;
|
||||
const subtitle = dateLine;
|
||||
const subtitleNumberOfLines: number = 1;
|
||||
|
||||
const titleStyle = useMemo(() => ({ color: colors.foregroundColor }), [colors.foregroundColor]);
|
||||
const subtitleStyle = useMemo(() => ({ color: colors.alternativeTextColor }), [colors.alternativeTextColor]);
|
||||
|
||||
const subtitleContent = useMemo(() => {
|
||||
if (!subtitle) return null;
|
||||
const maxLines = subtitleNumberOfLines === 0 ? undefined : subtitleNumberOfLines;
|
||||
|
||||
if (renderHighlightedText && searchQuery) {
|
||||
const highlighted = renderHighlightedText(subtitle, searchQuery);
|
||||
if (React.isValidElement(highlighted)) {
|
||||
const highlightedElement = highlighted as React.ReactElement<{ numberOfLines?: number; style?: TextStyle | TextStyle[] }>;
|
||||
const existingStyle = highlightedElement.props?.style;
|
||||
const mergedStyle: TextStyle[] = (
|
||||
Array.isArray(existingStyle)
|
||||
? [styles.subtitle, subtitleStyle, ...existingStyle]
|
||||
: [styles.subtitle, subtitleStyle, existingStyle]
|
||||
).filter(Boolean) as TextStyle[];
|
||||
|
||||
return React.cloneElement(highlightedElement, {
|
||||
numberOfLines: maxLines,
|
||||
style: mergedStyle,
|
||||
});
|
||||
}
|
||||
return highlighted;
|
||||
}
|
||||
|
||||
return (
|
||||
<Text style={[styles.subtitle, subtitleStyle]} numberOfLines={maxLines}>
|
||||
{subtitle}
|
||||
</Text>
|
||||
);
|
||||
}, [subtitle, subtitleNumberOfLines, renderHighlightedText, searchQuery, subtitleStyle]);
|
||||
|
||||
return (
|
||||
<ToolTipMenu
|
||||
isButton
|
||||
actions={toolTipActions}
|
||||
onPressMenuItem={onToolTipPress}
|
||||
onPress={onPress}
|
||||
accessibilityLabel={`${transactionTypeLabel}, ${amountWithUnit}, ${dateLine}`}
|
||||
shouldOpenOnLongPress
|
||||
buttonStyle={styles.fullWidthButton}
|
||||
accessibilityLabel={`${transactionTypeLabel}, ${amountWithUnit}, ${subtitle ?? title}`}
|
||||
accessibilityRole="button"
|
||||
>
|
||||
{/* @ts-ignore - MenuView types can be overly strict about child element props */}
|
||||
<ListItem
|
||||
leftAvatar={avatar}
|
||||
title={listTitle}
|
||||
subtitle={<Text style={dateLineStyle}>{dateLine}</Text>}
|
||||
subtitle={<Text style={styles.dateLine}>{dateLine}</Text>}
|
||||
chevron={false}
|
||||
rightTitle={rowTitle}
|
||||
rightTitleStyle={rowTitleStyle}
|
||||
rightSubtitle={noteForCopy}
|
||||
rightSubtitleStyle={rightMemoStyle}
|
||||
rightSubtitleStyle={styles.rightColumn}
|
||||
containerStyle={combinedStyle}
|
||||
testID="TransactionListItem"
|
||||
/>
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`${transactionTypeLabel}, ${amountWithUnit}, ${subtitle ?? title}`}
|
||||
>
|
||||
<View style={styles.row}>
|
||||
<View style={styles.avatarContainer}>{avatar}</View>
|
||||
<View style={styles.textContainer}>
|
||||
<Text style={[styles.title, titleStyle]} numberOfLines={1}>
|
||||
{title}
|
||||
</Text>
|
||||
{subtitleContent}
|
||||
</View>
|
||||
<View style={styles.rightColumn}>
|
||||
<Text style={[styles.rightTitle, rowTitleStyle]} numberOfLines={1}>
|
||||
{rowTitle}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</ListItem>
|
||||
</ToolTipMenu>
|
||||
);
|
||||
},
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import Clipboard from '@react-native-clipboard/clipboard';
|
||||
import { ImageBackground, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import Animated, { useAnimatedStyle, useSharedValue, withSpring, withTiming } from 'react-native-reanimated';
|
||||
import LinearGradient from 'react-native-linear-gradient';
|
||||
import { LightningArkWallet, LightningCustodianWallet, MultisigHDWallet } from '../class';
|
||||
import WalletGradient from '../class/wallet-gradient';
|
||||
@ -20,6 +21,7 @@ interface TransactionsNavigationHeaderProps {
|
||||
onWalletUnitChange: (unit: BitcoinUnit) => void;
|
||||
onManageFundsPressed?: (id?: string) => void;
|
||||
onWalletBalanceVisibilityChange?: (isShouldBeVisible: boolean) => void;
|
||||
unitSwitching?: boolean;
|
||||
}
|
||||
|
||||
const TransactionsNavigationHeader: React.FC<TransactionsNavigationHeaderProps> = ({
|
||||
@ -28,11 +30,15 @@ const TransactionsNavigationHeader: React.FC<TransactionsNavigationHeaderProps>
|
||||
onManageFundsPressed,
|
||||
onWalletBalanceVisibilityChange,
|
||||
unit = BitcoinUnit.BTC,
|
||||
unitSwitching = false,
|
||||
}) => {
|
||||
const { hideBalance } = wallet;
|
||||
const [allowOnchainAddress, setAllowOnchainAddress] = useState(false);
|
||||
const { preferredFiatCurrency } = useSettings();
|
||||
const { direction } = useLocale();
|
||||
const balanceOpacity = useSharedValue(1);
|
||||
const balanceTranslateY = useSharedValue(0);
|
||||
const previousBalance = useRef<string | undefined>(undefined);
|
||||
|
||||
const verifyIfWalletAllowsOnchainAddress = useCallback(() => {
|
||||
if (wallet.type === LightningCustodianWallet.type || wallet.type === LightningArkWallet.type) {
|
||||
@ -64,6 +70,8 @@ const TransactionsNavigationHeader: React.FC<TransactionsNavigationHeaderProps>
|
||||
const changeWalletBalanceUnit = () => {
|
||||
let newWalletPreferredUnit = wallet.getPreferredBalanceUnit();
|
||||
|
||||
console.debug('[UnitSwitch/UI] tap unit change', { walletID: wallet.getID?.(), current: newWalletPreferredUnit });
|
||||
|
||||
if (newWalletPreferredUnit === BitcoinUnit.BTC) {
|
||||
newWalletPreferredUnit = BitcoinUnit.SATS;
|
||||
} else if (newWalletPreferredUnit === BitcoinUnit.SATS) {
|
||||
@ -72,6 +80,7 @@ const TransactionsNavigationHeader: React.FC<TransactionsNavigationHeaderProps>
|
||||
newWalletPreferredUnit = BitcoinUnit.BTC;
|
||||
}
|
||||
|
||||
console.debug('[UnitSwitch/UI] next unit resolved', { walletID: wallet.getID?.(), next: newWalletPreferredUnit });
|
||||
onWalletUnitChange(newWalletPreferredUnit);
|
||||
};
|
||||
|
||||
@ -118,6 +127,36 @@ const TransactionsNavigationHeader: React.FC<TransactionsNavigationHeaderProps>
|
||||
}, [unit, currentBalance]);
|
||||
|
||||
const balance = !wallet.hideBalance && formattedBalance;
|
||||
const safeBalance = balance ? String(balance) : undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (hideBalance) {
|
||||
previousBalance.current = undefined;
|
||||
balanceOpacity.value = 1;
|
||||
balanceTranslateY.value = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
if (previousBalance.current !== undefined && previousBalance.current !== safeBalance) {
|
||||
balanceOpacity.value = 0;
|
||||
balanceTranslateY.value = 6;
|
||||
balanceOpacity.value = withTiming(1, { duration: 180 });
|
||||
balanceTranslateY.value = withSpring(0, { damping: 16, stiffness: 220 });
|
||||
}
|
||||
|
||||
previousBalance.current = safeBalance;
|
||||
}, [safeBalance, hideBalance, balanceOpacity, balanceTranslateY]);
|
||||
|
||||
const balanceAnimationKey = useMemo(
|
||||
() => `${wallet.getID?.() ?? ''}-${unit}-${hideBalance}-${safeBalance ?? ''}`,
|
||||
[safeBalance, hideBalance, unit, wallet],
|
||||
);
|
||||
const balanceAnimatedStyle = useAnimateOnChange(balanceAnimationKey);
|
||||
|
||||
const animatedBalanceTextStyle = useAnimatedStyle(() => ({
|
||||
opacity: balanceOpacity.value,
|
||||
transform: [{ translateY: balanceTranslateY.value }],
|
||||
}));
|
||||
|
||||
const toolTipWalletBalanceActions = useMemo(() => {
|
||||
return hideBalance
|
||||
@ -160,78 +199,87 @@ const TransactionsNavigationHeader: React.FC<TransactionsNavigationHeaderProps>
|
||||
}
|
||||
}, [direction, wallet.type]);
|
||||
|
||||
useAnimateOnChange(balance);
|
||||
useAnimateOnChange(hideBalance);
|
||||
useAnimateOnChange(unit);
|
||||
useAnimateOnChange(wallet.getID?.());
|
||||
useEffect(() => {
|
||||
console.debug('[UnitSwitch/UI] render state', {
|
||||
walletID: wallet.getID?.(),
|
||||
unit,
|
||||
hideBalance,
|
||||
preferredFiat: preferredFiatCurrency?.endPointKey,
|
||||
switching: unitSwitching,
|
||||
});
|
||||
}, [wallet, unit, hideBalance, preferredFiatCurrency, unitSwitching]);
|
||||
|
||||
return (
|
||||
<LinearGradient colors={WalletGradient.gradientsFor(wallet.type)} style={styles.lineaderGradient}>
|
||||
<ImageBackground source={imageSource} style={styles.chainIcon} />
|
||||
|
||||
<Text testID="WalletLabel" numberOfLines={1} style={[styles.walletLabel, { writingDirection: direction }]}>
|
||||
{wallet.getLabel()}
|
||||
</Text>
|
||||
<View style={styles.walletBalanceAndUnitContainer}>
|
||||
<ToolTipMenu
|
||||
isMenuPrimaryAction
|
||||
isButton
|
||||
enableAndroidRipple={false}
|
||||
buttonStyle={styles.walletBalance}
|
||||
onPressMenuItem={onPressMenuItem}
|
||||
actions={toolTipWalletBalanceActions}
|
||||
>
|
||||
<View style={styles.walletBalance}>
|
||||
{hideBalance ? (
|
||||
<BlurredBalanceView />
|
||||
) : (
|
||||
<View>
|
||||
<Text
|
||||
key={String(balance)} // force component recreation on balance change. To fix right-to-left languages, like Farsis
|
||||
testID="WalletBalance"
|
||||
numberOfLines={1}
|
||||
minimumFontScale={0.5}
|
||||
adjustsFontSizeToFit
|
||||
style={styles.walletBalanceText}
|
||||
>
|
||||
{balance}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</ToolTipMenu>
|
||||
<TouchableOpacity style={styles.walletPreferredUnitView} onPress={changeWalletBalanceUnit}>
|
||||
<Text style={styles.walletPreferredUnitText}>
|
||||
{unit === BitcoinUnit.LOCAL_CURRENCY ? (preferredFiatCurrency?.endPointKey ?? FiatUnit.USD) : unit}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<View style={styles.contentContainer}>
|
||||
<Text testID="WalletLabel" numberOfLines={1} style={[styles.walletLabel, { writingDirection: direction }]}>
|
||||
{wallet.getLabel()}
|
||||
</Text>
|
||||
<Animated.View style={[styles.walletBalanceAndUnitContainer, balanceAnimatedStyle]}>
|
||||
<ToolTipMenu
|
||||
shouldOpenOnLongPress
|
||||
isButton
|
||||
enableAndroidRipple={false}
|
||||
buttonStyle={styles.walletBalance}
|
||||
onPressMenuItem={onPressMenuItem}
|
||||
actions={toolTipWalletBalanceActions}
|
||||
>
|
||||
<View style={styles.walletBalance}>
|
||||
{hideBalance ? (
|
||||
<BlurredBalanceView />
|
||||
) : (
|
||||
<View key={`wallet-balance-textwrap-${wallet.getID?.() ?? ''}-${String(balance)}`}>
|
||||
<Animated.Text
|
||||
key={`wallet-balance-text-${wallet.getID?.() ?? ''}-${String(balance)}`} // force recreation on balance change for RTL correctness
|
||||
testID="WalletBalance"
|
||||
numberOfLines={1}
|
||||
minimumFontScale={0.5}
|
||||
adjustsFontSizeToFit
|
||||
style={[styles.walletBalanceText, animatedBalanceTextStyle]}
|
||||
>
|
||||
{balance}
|
||||
</Animated.Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</ToolTipMenu>
|
||||
<TouchableOpacity style={styles.walletPreferredUnitView} onPress={changeWalletBalanceUnit} disabled={unitSwitching}>
|
||||
<Text style={styles.walletPreferredUnitText}>
|
||||
{unit === BitcoinUnit.LOCAL_CURRENCY ? (preferredFiatCurrency?.endPointKey ?? FiatUnit.USD) : unit}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
{(wallet.type === LightningCustodianWallet.type || wallet.type === LightningArkWallet.type) && allowOnchainAddress && (
|
||||
<ToolTipMenu
|
||||
shouldOpenOnLongPress
|
||||
isButton
|
||||
onPressMenuItem={handleManageFundsPressed}
|
||||
actions={toolTipActions}
|
||||
buttonStyle={styles.manageFundsButton}
|
||||
>
|
||||
<Text style={styles.manageFundsButtonText}>{loc.lnd.title}</Text>
|
||||
</ToolTipMenu>
|
||||
)}
|
||||
{wallet.type === MultisigHDWallet.type && (
|
||||
<TouchableOpacity style={styles.manageFundsButton} accessibilityRole="button" onPress={() => handleManageFundsPressed()}>
|
||||
<Text style={styles.manageFundsButtonText}>{loc.multisig.manage_keys}</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
{(wallet.type === LightningCustodianWallet.type || wallet.type === LightningArkWallet.type) && allowOnchainAddress && (
|
||||
<ToolTipMenu
|
||||
isMenuPrimaryAction
|
||||
isButton
|
||||
onPressMenuItem={handleManageFundsPressed}
|
||||
actions={toolTipActions}
|
||||
buttonStyle={styles.manageFundsButton}
|
||||
>
|
||||
<Text style={styles.manageFundsButtonText}>{loc.lnd.title}</Text>
|
||||
</ToolTipMenu>
|
||||
)}
|
||||
{wallet.type === MultisigHDWallet.type && (
|
||||
<TouchableOpacity style={styles.manageFundsButton} accessibilityRole="button" onPress={() => handleManageFundsPressed()}>
|
||||
<Text style={styles.manageFundsButtonText}>{loc.multisig.manage_keys}</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</LinearGradient>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
lineaderGradient: {
|
||||
padding: 15,
|
||||
minHeight: 140,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
contentContainer: {
|
||||
padding: 15,
|
||||
},
|
||||
chainIcon: {
|
||||
width: 99,
|
||||
height: 94,
|
||||
|
||||
@ -1,20 +1,27 @@
|
||||
import React, { forwardRef, useCallback, useImperativeHandle, useMemo, useRef, useEffect, createRef } from 'react';
|
||||
import React, { forwardRef, useCallback, useImperativeHandle, useMemo, useRef, useEffect } from 'react';
|
||||
import {
|
||||
Animated,
|
||||
FlatList,
|
||||
ImageBackground,
|
||||
Platform,
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
Text,
|
||||
useWindowDimensions,
|
||||
View,
|
||||
useWindowDimensions,
|
||||
FlatListProps,
|
||||
ListRenderItemInfo,
|
||||
ViewStyle,
|
||||
LayoutAnimation,
|
||||
UIManager,
|
||||
} from 'react-native';
|
||||
import Animated, {
|
||||
Easing,
|
||||
FadeIn,
|
||||
FadeOut,
|
||||
LinearTransition,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withSpring,
|
||||
withTiming,
|
||||
} from 'react-native-reanimated';
|
||||
import LinearGradient from 'react-native-linear-gradient';
|
||||
import { LightningArkWallet, LightningCustodianWallet, MultisigHDWallet } from '../class';
|
||||
import WalletGradient from '../class/wallet-gradient';
|
||||
@ -25,13 +32,11 @@ import { useTheme } from './themes';
|
||||
import { useStorage } from '../hooks/context/useStorage';
|
||||
import { WalletTransactionsStatus } from './Context/StorageProvider';
|
||||
import { Transaction, TWallet } from '../class/wallets/types';
|
||||
import HighlightedText from './HighlightedText';
|
||||
import { BlueSpacing10 } from './BlueSpacing';
|
||||
import { useLocale } from '@react-navigation/native';
|
||||
|
||||
if (Platform.OS === 'android' && UIManager.setLayoutAnimationEnabledExperimental) {
|
||||
UIManager.setLayoutAnimationEnabledExperimental(true);
|
||||
}
|
||||
// Horizontal carousel shows a small peek of the next card; adjust overlap to control that spacing.
|
||||
const CARD_OVERLAP = 24;
|
||||
|
||||
interface NewWalletPanelProps {
|
||||
onPress: () => void;
|
||||
@ -78,22 +83,18 @@ const NewWalletPanel: React.FC<NewWalletPanelProps> = ({ onPress }) => {
|
||||
: { paddingVertical: 16, paddingHorizontal: 24 },
|
||||
});
|
||||
|
||||
const scale = useRef(new Animated.Value(1)).current;
|
||||
const scale = useSharedValue(1);
|
||||
|
||||
const animatedScaleStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ scale: scale.value }],
|
||||
}));
|
||||
|
||||
const handlePressIn = useCallback(() => {
|
||||
Animated.spring(scale, {
|
||||
toValue: 0.97,
|
||||
useNativeDriver: true,
|
||||
friction: 4,
|
||||
}).start();
|
||||
scale.value = withSpring(0.97, { damping: 14, stiffness: 180 });
|
||||
}, [scale]);
|
||||
|
||||
const handlePressOut = useCallback(() => {
|
||||
Animated.spring(scale, {
|
||||
toValue: 1,
|
||||
useNativeDriver: true,
|
||||
friction: 4,
|
||||
}).start();
|
||||
scale.value = withSpring(1, { damping: 14, stiffness: 180 });
|
||||
}, [scale]);
|
||||
|
||||
return (
|
||||
@ -115,9 +116,9 @@ const NewWalletPanel: React.FC<NewWalletPanelProps> = ({ onPress }) => {
|
||||
style={[
|
||||
nStyles.container,
|
||||
nStylesHooks.container,
|
||||
{ backgroundColor: WalletGradient.createWallet() },
|
||||
{ backgroundColor: colors.borderTopColor },
|
||||
isLarge ? {} : { width: itemWidth },
|
||||
{ transform: [{ scale }] },
|
||||
animatedScaleStyle,
|
||||
]}
|
||||
>
|
||||
<Text style={[nStyles.addAWAllet, { color: colors.foregroundColor }]}>{loc.wallets.list_create_a_wallet}</Text>
|
||||
@ -139,25 +140,40 @@ interface WalletCarouselItemProps {
|
||||
horizontal?: boolean;
|
||||
isPlaceHolder?: boolean;
|
||||
searchQuery?: string;
|
||||
renderHighlightedText?: (text: string, query: string) => JSX.Element;
|
||||
renderHighlightedText?: (text: string, query: string) => React.ReactElement;
|
||||
animationsEnabled?: boolean;
|
||||
onPressIn?: () => void;
|
||||
onPressOut?: () => void;
|
||||
isNewWallet?: boolean;
|
||||
isExiting?: boolean;
|
||||
isDraggingActive?: boolean;
|
||||
dragActiveScale?: number;
|
||||
sizeVariant?: 'default' | 'compact';
|
||||
}
|
||||
|
||||
const iStyles = StyleSheet.create({
|
||||
root: { paddingRight: 20 },
|
||||
rootLargeDevice: { marginVertical: 20 },
|
||||
grad: {
|
||||
padding: 15,
|
||||
borderRadius: 12,
|
||||
minHeight: 164,
|
||||
},
|
||||
gradCompact: {
|
||||
borderRadius: 10,
|
||||
minHeight: 132,
|
||||
},
|
||||
gradContent: {
|
||||
padding: 15,
|
||||
},
|
||||
gradContentCompact: {
|
||||
padding: 12,
|
||||
},
|
||||
balanceContainer: {
|
||||
height: 40,
|
||||
},
|
||||
balanceContainerCompact: {
|
||||
height: 32,
|
||||
},
|
||||
image: {
|
||||
width: 99,
|
||||
height: 94,
|
||||
@ -165,6 +181,12 @@ const iStyles = StyleSheet.create({
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
},
|
||||
imageCompact: {
|
||||
width: 78,
|
||||
height: 74,
|
||||
right: 4,
|
||||
bottom: 4,
|
||||
},
|
||||
br: {
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
@ -172,20 +194,33 @@ const iStyles = StyleSheet.create({
|
||||
backgroundColor: 'transparent',
|
||||
fontSize: 19,
|
||||
},
|
||||
labelCompact: {
|
||||
fontSize: 16,
|
||||
},
|
||||
balance: {
|
||||
backgroundColor: 'transparent',
|
||||
fontWeight: 'bold',
|
||||
fontSize: 36,
|
||||
},
|
||||
balanceCompact: {
|
||||
fontSize: 28,
|
||||
lineHeight: 34,
|
||||
},
|
||||
latestTx: {
|
||||
backgroundColor: 'transparent',
|
||||
fontSize: 13,
|
||||
},
|
||||
latestTxCompact: {
|
||||
fontSize: 12,
|
||||
},
|
||||
latestTxTime: {
|
||||
backgroundColor: 'transparent',
|
||||
fontWeight: 'bold',
|
||||
fontSize: 16,
|
||||
},
|
||||
latestTxTimeCompact: {
|
||||
fontSize: 14,
|
||||
},
|
||||
shadowContainer: {
|
||||
...Platform.select({
|
||||
ios: {
|
||||
@ -200,6 +235,20 @@ const iStyles = StyleSheet.create({
|
||||
},
|
||||
}),
|
||||
},
|
||||
shadowContainerCompact: {
|
||||
...Platform.select({
|
||||
ios: {
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 20 / 100,
|
||||
shadowRadius: 6,
|
||||
borderRadius: 10,
|
||||
},
|
||||
android: {
|
||||
elevation: 6,
|
||||
borderRadius: 10,
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
export const WalletCarouselItem: React.FC<WalletCarouselItemProps> = React.memo(
|
||||
@ -218,51 +267,59 @@ export const WalletCarouselItem: React.FC<WalletCarouselItemProps> = React.memo(
|
||||
onPressOut,
|
||||
isNewWallet = false,
|
||||
isExiting = false,
|
||||
isDraggingActive = false,
|
||||
dragActiveScale = 1.02,
|
||||
sizeVariant = 'default',
|
||||
}: WalletCarouselItemProps) => {
|
||||
const scaleValue = useRef(new Animated.Value(1.0)).current;
|
||||
const opacityValue = useRef(new Animated.Value(isSelectedWallet === false ? 0.5 : 1.0)).current;
|
||||
const translateYValue = useRef(new Animated.Value(isNewWallet ? 20 : 0)).current;
|
||||
const walletLabel = item.getLabel ? item.getLabel() : '';
|
||||
const pressScale = useSharedValue(1.0);
|
||||
const dragScale = useSharedValue(isDraggingActive ? dragActiveScale : 1.0);
|
||||
const opacityValue = useSharedValue(isSelectedWallet === false ? 0.5 : 1.0);
|
||||
const translateYValue = useSharedValue(isNewWallet ? 20 : 0);
|
||||
const balanceOpacity = useSharedValue(1);
|
||||
const balanceTranslateY = useSharedValue(0);
|
||||
const { colors } = useTheme();
|
||||
const { walletTransactionUpdateStatus } = useStorage();
|
||||
const { width } = useWindowDimensions();
|
||||
const itemWidth = width * 0.82 > 375 ? 375 : width * 0.82;
|
||||
const { sizeClass } = useSizeClass();
|
||||
const isCompact = sizeVariant === 'compact';
|
||||
const { direction } = useLocale();
|
||||
const previousBalance = useRef<string | undefined>(undefined);
|
||||
const balance = !item.hideBalance && formatBalance(Number(item.getBalance()), item.getPreferredBalanceUnit(), true);
|
||||
const safeBalance = balance || undefined;
|
||||
|
||||
const springConfig = useMemo(() => ({ useNativeDriver: true, tension: 100 }), []);
|
||||
const animateScale = useCallback(
|
||||
(toValue: number, callback?: () => void) => {
|
||||
Animated.spring(scaleValue, { toValue, ...springConfig }).start(callback);
|
||||
const animatePressScale = useCallback(
|
||||
(toValue: number) => {
|
||||
pressScale.value = withSpring(toValue, { damping: 13, stiffness: 180, mass: 0.9 });
|
||||
},
|
||||
[scaleValue, springConfig],
|
||||
[pressScale],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
dragScale.value = withSpring(isDraggingActive ? dragActiveScale : 1, { damping: 16, stiffness: 200, mass: 1 });
|
||||
}, [isDraggingActive, dragActiveScale, dragScale]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!animationsEnabled) return;
|
||||
|
||||
const targetOpacity = isSelectedWallet === false ? 0.5 : 1.0;
|
||||
Animated.spring(opacityValue, {
|
||||
toValue: targetOpacity,
|
||||
useNativeDriver: true,
|
||||
tension: 30,
|
||||
friction: 7,
|
||||
velocity: 0.1,
|
||||
}).start();
|
||||
opacityValue.value = withSpring(targetOpacity, { damping: 18, stiffness: 240 });
|
||||
}, [isSelectedWallet, opacityValue, animationsEnabled]);
|
||||
|
||||
const onPressedIn = useCallback(() => {
|
||||
if (animationsEnabled) {
|
||||
animateScale(0.95);
|
||||
animatePressScale(0.97);
|
||||
}
|
||||
if (onPressIn) onPressIn();
|
||||
}, [animateScale, animationsEnabled, onPressIn]);
|
||||
}, [animatePressScale, animationsEnabled, onPressIn]);
|
||||
|
||||
const onPressedOut = useCallback(() => {
|
||||
if (animationsEnabled) {
|
||||
animateScale(1.0);
|
||||
animatePressScale(1.0);
|
||||
}
|
||||
if (onPressOut) onPressOut();
|
||||
}, [animateScale, animationsEnabled, onPressOut]);
|
||||
}, [animatePressScale, animationsEnabled, onPressOut]);
|
||||
|
||||
const handlePress = useCallback(() => {
|
||||
onPress(item);
|
||||
@ -270,38 +327,45 @@ export const WalletCarouselItem: React.FC<WalletCarouselItemProps> = React.memo(
|
||||
|
||||
useEffect(() => {
|
||||
if (isNewWallet && animationsEnabled) {
|
||||
Animated.parallel([
|
||||
Animated.timing(translateYValue, {
|
||||
toValue: 0,
|
||||
duration: 300,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.spring(opacityValue, {
|
||||
toValue: isSelectedWallet === false ? 0.5 : 1.0,
|
||||
useNativeDriver: true,
|
||||
friction: 7,
|
||||
}),
|
||||
]).start();
|
||||
translateYValue.value = withTiming(0, { duration: 300 });
|
||||
opacityValue.value = withSpring(isSelectedWallet === false ? 0.5 : 1.0, { damping: 18, stiffness: 240 });
|
||||
}
|
||||
}, [isNewWallet, animationsEnabled, translateYValue, opacityValue, isSelectedWallet]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!animationsEnabled) {
|
||||
previousBalance.current = safeBalance;
|
||||
return;
|
||||
}
|
||||
|
||||
if (previousBalance.current !== undefined && previousBalance.current !== safeBalance) {
|
||||
// Subtle currency-like transition on balance updates.
|
||||
balanceOpacity.value = 0;
|
||||
balanceTranslateY.value = 6;
|
||||
balanceOpacity.value = withTiming(1, { duration: 180 });
|
||||
balanceTranslateY.value = withSpring(0, { damping: 16, stiffness: 220 });
|
||||
}
|
||||
|
||||
previousBalance.current = safeBalance;
|
||||
}, [safeBalance, animationsEnabled, balanceOpacity, balanceTranslateY]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isExiting && animationsEnabled) {
|
||||
Animated.parallel([
|
||||
Animated.timing(translateYValue, {
|
||||
toValue: -20,
|
||||
duration: 200,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(opacityValue, {
|
||||
toValue: 0,
|
||||
duration: 200,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start();
|
||||
translateYValue.value = withTiming(-20, { duration: 200 });
|
||||
opacityValue.value = withTiming(0, { duration: 200 });
|
||||
}
|
||||
}, [isExiting, animationsEnabled, translateYValue, opacityValue]);
|
||||
|
||||
const animatedCardStyle = useAnimatedStyle(() => ({
|
||||
opacity: opacityValue.value,
|
||||
transform: [{ scale: pressScale.value * dragScale.value }, { translateY: translateYValue.value }],
|
||||
}));
|
||||
|
||||
const animatedBalanceStyle = useAnimatedStyle(() => ({
|
||||
opacity: balanceOpacity.value,
|
||||
transform: [{ translateY: balanceTranslateY.value }],
|
||||
}));
|
||||
|
||||
let image;
|
||||
switch (item.type) {
|
||||
case LightningCustodianWallet.type:
|
||||
@ -327,23 +391,18 @@ export const WalletCarouselItem: React.FC<WalletCarouselItemProps> = React.memo(
|
||||
latestTransactionText = transactionTimeToReadable(item.getLatestTransactionTime());
|
||||
}
|
||||
|
||||
const balance = !item.hideBalance && formatBalance(Number(item.getBalance()), item.getPreferredBalanceUnit(), true);
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
style={[
|
||||
sizeClass === SizeClass.Large || !horizontal
|
||||
? [iStyles.rootLargeDevice, customStyle]
|
||||
: (customStyle ?? { ...iStyles.root, width: itemWidth }),
|
||||
{
|
||||
opacity: opacityValue,
|
||||
transform: [{ scale: scaleValue }, { translateY: translateYValue }],
|
||||
},
|
||||
: [iStyles.root, { width: itemWidth }, customStyle],
|
||||
animatedCardStyle,
|
||||
]}
|
||||
>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
testID={item.getLabel()}
|
||||
testID={walletLabel}
|
||||
onPressIn={onPressedIn}
|
||||
onPressOut={onPressedOut}
|
||||
onLongPress={() => {
|
||||
@ -353,52 +412,75 @@ export const WalletCarouselItem: React.FC<WalletCarouselItemProps> = React.memo(
|
||||
delayHoverIn={0}
|
||||
delayHoverOut={0}
|
||||
>
|
||||
<View style={[iStyles.shadowContainer, { backgroundColor: colors.background, shadowColor: colors.shadowColor }]}>
|
||||
<LinearGradient colors={WalletGradient.gradientsFor(item.type)} style={iStyles.grad}>
|
||||
<ImageBackground source={image} style={iStyles.image} />
|
||||
<Text style={iStyles.br} />
|
||||
{!isPlaceHolder && (
|
||||
<>
|
||||
<Text numberOfLines={1} style={[iStyles.label, { color: colors.inverseForegroundColor, writingDirection: direction }]}>
|
||||
{renderHighlightedText && searchQuery ? (
|
||||
<HighlightedText
|
||||
text={item.getLabel()}
|
||||
query={searchQuery}
|
||||
style={[iStyles.label, { color: colors.inverseForegroundColor, writingDirection: direction }]}
|
||||
/>
|
||||
) : (
|
||||
item.getLabel()
|
||||
)}
|
||||
</Text>
|
||||
<View style={iStyles.balanceContainer}>
|
||||
{item.hideBalance ? (
|
||||
<>
|
||||
<BlueSpacing10 />
|
||||
<BlurredBalanceView />
|
||||
</>
|
||||
) : (
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
adjustsFontSizeToFit
|
||||
key={`${balance}`} // force component recreation on balance change. To fix right-to-left languages, like Farsi
|
||||
style={[iStyles.balance, { color: colors.inverseForegroundColor, writingDirection: direction }]}
|
||||
>
|
||||
{`${balance} `}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
<Text style={iStyles.br} />
|
||||
<Text numberOfLines={1} style={[iStyles.latestTx, { color: colors.inverseForegroundColor, writingDirection: direction }]}>
|
||||
{loc.wallets.list_latest_transaction}
|
||||
</Text>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={[iStyles.latestTxTime, { color: colors.inverseForegroundColor, writingDirection: direction }]}
|
||||
>
|
||||
{latestTransactionText}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
<View
|
||||
style={[
|
||||
iStyles.shadowContainer,
|
||||
isCompact && iStyles.shadowContainerCompact,
|
||||
{ backgroundColor: colors.background, shadowColor: colors.shadowColor },
|
||||
]}
|
||||
>
|
||||
<LinearGradient colors={WalletGradient.gradientsFor(item.type)} style={[iStyles.grad, isCompact && iStyles.gradCompact]}>
|
||||
<View style={[iStyles.gradContent, isCompact && iStyles.gradContentCompact]}>
|
||||
<ImageBackground source={image} style={[iStyles.image, isCompact && iStyles.imageCompact]} />
|
||||
<Text style={iStyles.br} />
|
||||
{!isPlaceHolder && (
|
||||
<>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={[
|
||||
iStyles.label,
|
||||
isCompact && iStyles.labelCompact,
|
||||
{ color: colors.inverseForegroundColor, writingDirection: direction },
|
||||
]}
|
||||
>
|
||||
{renderHighlightedText ? renderHighlightedText(walletLabel, searchQuery || '') : walletLabel}
|
||||
</Text>
|
||||
<View style={[iStyles.balanceContainer, isCompact && iStyles.balanceContainerCompact]}>
|
||||
{item.hideBalance ? (
|
||||
<>
|
||||
<BlueSpacing10 />
|
||||
<BlurredBalanceView />
|
||||
</>
|
||||
) : (
|
||||
<Animated.Text
|
||||
numberOfLines={1}
|
||||
adjustsFontSizeToFit
|
||||
key={`${balance}`} // force component recreation on balance change. To fix right-to-left languages, like Farsi
|
||||
style={[
|
||||
iStyles.balance,
|
||||
isCompact && iStyles.balanceCompact,
|
||||
{ color: colors.inverseForegroundColor, writingDirection: direction },
|
||||
animatedBalanceStyle,
|
||||
]}
|
||||
>
|
||||
{`${balance} `}
|
||||
</Animated.Text>
|
||||
)}
|
||||
</View>
|
||||
<Text style={iStyles.br} />
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={[
|
||||
iStyles.latestTx,
|
||||
isCompact && iStyles.latestTxCompact,
|
||||
{ color: colors.inverseForegroundColor, writingDirection: direction },
|
||||
]}
|
||||
>
|
||||
{loc.wallets.list_latest_transaction}
|
||||
</Text>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={[
|
||||
iStyles.latestTxTime,
|
||||
isCompact && iStyles.latestTxTimeCompact,
|
||||
{ color: colors.inverseForegroundColor, writingDirection: direction },
|
||||
]}
|
||||
>
|
||||
{latestTransactionText}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</LinearGradient>
|
||||
</View>
|
||||
</Pressable>
|
||||
@ -417,7 +499,7 @@ interface WalletsCarouselProps extends Partial<FlatListProps<any>> {
|
||||
data: TWallet[];
|
||||
scrollEnabled?: boolean;
|
||||
searchQuery?: string;
|
||||
renderHighlightedText?: (text: string, query: string) => JSX.Element;
|
||||
renderHighlightedText?: (text: string, query: string) => React.ReactElement;
|
||||
animateChanges?: boolean;
|
||||
}
|
||||
|
||||
@ -457,8 +539,10 @@ const WalletsCarousel = forwardRef<FlatListRefType, WalletsCarouselProps>((props
|
||||
|
||||
const { width } = useWindowDimensions();
|
||||
const itemWidth = React.useMemo(() => (width * 0.82 > 375 ? 375 : width * 0.82), [width]);
|
||||
const layoutTransition = useMemo(() => LinearTransition.duration(240).easing(Easing.inOut(Easing.quad)), []);
|
||||
const enteringTransition = useMemo(() => FadeIn.duration(180), []);
|
||||
const exitingTransition = useMemo(() => FadeOut.duration(150), []);
|
||||
|
||||
const prevDataLength = useRef(data.length);
|
||||
const prevWalletIds = useRef<string[]>([]);
|
||||
const newWalletsMap = useRef<Record<string, boolean>>({});
|
||||
const lastAddedWalletId = useRef<string | null>(null);
|
||||
@ -467,7 +551,7 @@ const WalletsCarousel = forwardRef<FlatListRefType, WalletsCarouselProps>((props
|
||||
const isInitialMount = useRef(true);
|
||||
|
||||
const flatListRef = useRef<FlatList<any>>(null);
|
||||
const walletRefs = useRef<Record<string, React.RefObject<View>>>({});
|
||||
const walletRefs = useRef<Record<string, React.MutableRefObject<View | null>>>({});
|
||||
|
||||
const { sizeClass } = useSizeClass();
|
||||
|
||||
@ -508,7 +592,7 @@ const WalletsCarousel = forwardRef<FlatListRefType, WalletsCarouselProps>((props
|
||||
const walletRef = walletRefs.current[walletId];
|
||||
if (walletRef?.current) {
|
||||
return new Promise<{ x: number; y: number; width: number; height: number }>(resolve => {
|
||||
walletRef.current?.measure((x, y, widthVal, heightVal, pageX, pageY) => {
|
||||
walletRef.current?.measure((x: number, y: number, widthVal: number, heightVal: number, pageX: number, pageY: number) => {
|
||||
resolve({ x: pageX, y: pageY, width: widthVal, height: heightVal });
|
||||
});
|
||||
});
|
||||
@ -530,7 +614,7 @@ const WalletsCarousel = forwardRef<FlatListRefType, WalletsCarouselProps>((props
|
||||
useEffect(() => {
|
||||
data.forEach(wallet => {
|
||||
if (!walletRefs.current[wallet.getID()]) {
|
||||
walletRefs.current[wallet.getID()] = createRef<View>();
|
||||
walletRefs.current[wallet.getID()] = { current: null };
|
||||
}
|
||||
});
|
||||
}, [data]);
|
||||
@ -584,7 +668,6 @@ const WalletsCarousel = forwardRef<FlatListRefType, WalletsCarouselProps>((props
|
||||
if (isInitialMount.current) {
|
||||
isInitialMount.current = false;
|
||||
prevWalletIds.current = currentWalletIds;
|
||||
prevDataLength.current = data.length;
|
||||
return;
|
||||
}
|
||||
|
||||
@ -598,9 +681,6 @@ const WalletsCarousel = forwardRef<FlatListRefType, WalletsCarouselProps>((props
|
||||
newWalletsMap.current[id] = true;
|
||||
});
|
||||
|
||||
// Always animate layout changes
|
||||
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);
|
||||
|
||||
// Auto-scroll to new wallet after mount (no condition, always scroll)
|
||||
if (scrollTimeoutRef.current) {
|
||||
clearTimeout(scrollTimeoutRef.current);
|
||||
@ -614,14 +694,8 @@ const WalletsCarousel = forwardRef<FlatListRefType, WalletsCarouselProps>((props
|
||||
}, 300);
|
||||
}
|
||||
|
||||
// Handle wallet removals
|
||||
if (prevDataLength.current > data.length) {
|
||||
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);
|
||||
}
|
||||
|
||||
// Update refs for next comparison
|
||||
prevWalletIds.current = currentWalletIds;
|
||||
prevDataLength.current = data.length;
|
||||
|
||||
// Clear animation states
|
||||
if (addedWallets.length > 0) {
|
||||
@ -646,8 +720,9 @@ const WalletsCarousel = forwardRef<FlatListRefType, WalletsCarouselProps>((props
|
||||
};
|
||||
|
||||
const renderItem = useCallback(
|
||||
({ item, index }: ListRenderItemInfo<TWallet>) =>
|
||||
item ? (
|
||||
({ item }: ListRenderItemInfo<TWallet>) => {
|
||||
if (!item) return null;
|
||||
const content = (
|
||||
<WalletCarouselItem
|
||||
isSelectedWallet={!horizontal && selectedWallet ? selectedWallet === item.getID() : undefined}
|
||||
item={item}
|
||||
@ -659,8 +734,28 @@ const WalletsCarousel = forwardRef<FlatListRefType, WalletsCarouselProps>((props
|
||||
isNewWallet={animateChanges && newWalletsMap.current[item.getID()]}
|
||||
animationsEnabled={animateChanges}
|
||||
/>
|
||||
) : null,
|
||||
[horizontal, selectedWallet, handleLongPress, onPress, searchQuery, renderHighlightedText, animateChanges],
|
||||
);
|
||||
|
||||
if (!animateChanges) return content;
|
||||
|
||||
return (
|
||||
<Animated.View layout={layoutTransition} entering={enteringTransition} exiting={exitingTransition}>
|
||||
{content}
|
||||
</Animated.View>
|
||||
);
|
||||
},
|
||||
[
|
||||
horizontal,
|
||||
selectedWallet,
|
||||
handleLongPress,
|
||||
onPress,
|
||||
searchQuery,
|
||||
renderHighlightedText,
|
||||
animateChanges,
|
||||
layoutTransition,
|
||||
enteringTransition,
|
||||
exitingTransition,
|
||||
],
|
||||
);
|
||||
|
||||
const keyExtractor = useCallback((item: TWallet, index: number) => (item?.getID ? item.getID() : index.toString()), []);
|
||||
@ -674,16 +769,24 @@ const WalletsCarousel = forwardRef<FlatListRefType, WalletsCarouselProps>((props
|
||||
}, []);
|
||||
|
||||
const renderNonFlatListWallets = useCallback(() => {
|
||||
return data.map((item, index) =>
|
||||
item ? (
|
||||
return data.map(item => {
|
||||
if (!item) return null;
|
||||
|
||||
const content = (
|
||||
<View
|
||||
key={item.getID()}
|
||||
ref={walletRefs.current[item.getID()]}
|
||||
key={!animateChanges ? item.getID() : undefined}
|
||||
ref={(node: View | null) => {
|
||||
// Keep existing ref object in map
|
||||
walletRefs.current[item.getID()] ??= { current: null };
|
||||
walletRefs.current[item.getID()].current = node;
|
||||
}}
|
||||
onLayout={() => {
|
||||
if (walletRefs.current[item.getID()]?.current && newWalletsMap.current[item.getID()]) {
|
||||
walletRefs.current[item.getID()].current?.measure((x, y, widthVal, heightVal, pageX, pageY) => {
|
||||
console.debug(`[WalletsCarousel] New wallet ${item.getID()} positioned at y=${y}, pageY=${pageY}`);
|
||||
});
|
||||
walletRefs.current[item.getID()].current?.measure(
|
||||
(x: number, y: number, widthVal: number, heightVal: number, pageX: number, pageY: number) => {
|
||||
console.debug(`[WalletsCarousel] New wallet ${item.getID()} positioned at y=${y}, pageY=${pageY}`);
|
||||
},
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
@ -698,9 +801,29 @@ const WalletsCarousel = forwardRef<FlatListRefType, WalletsCarouselProps>((props
|
||||
animationsEnabled={animateChanges}
|
||||
/>
|
||||
</View>
|
||||
) : null,
|
||||
);
|
||||
}, [data, horizontal, selectedWallet, handleLongPress, onPress, props.searchQuery, props.renderHighlightedText, animateChanges]);
|
||||
);
|
||||
|
||||
if (!animateChanges) return content;
|
||||
|
||||
return (
|
||||
<Animated.View key={item.getID()} layout={layoutTransition} entering={enteringTransition} exiting={exitingTransition}>
|
||||
{content}
|
||||
</Animated.View>
|
||||
);
|
||||
});
|
||||
}, [
|
||||
data,
|
||||
horizontal,
|
||||
selectedWallet,
|
||||
handleLongPress,
|
||||
onPress,
|
||||
props.searchQuery,
|
||||
props.renderHighlightedText,
|
||||
animateChanges,
|
||||
layoutTransition,
|
||||
enteringTransition,
|
||||
exitingTransition,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
// We check the current values inside the effect, but don't include them as dependencies
|
||||
@ -735,7 +858,7 @@ const WalletsCarousel = forwardRef<FlatListRefType, WalletsCarouselProps>((props
|
||||
showsVerticalScrollIndicator={false}
|
||||
pagingEnabled={horizontal}
|
||||
disableIntervalMomentum={horizontal}
|
||||
snapToInterval={itemWidth}
|
||||
snapToInterval={horizontal ? itemWidth - CARD_OVERLAP : undefined}
|
||||
decelerationRate="fast"
|
||||
contentContainerStyle={cStyles.content}
|
||||
directionalLockEnabled
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import React, { useMemo, useCallback } from 'react';
|
||||
import React, { useMemo, useCallback, useEffect, useRef } from 'react';
|
||||
import Clipboard from '@react-native-clipboard/clipboard';
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
import Share from 'react-native-share';
|
||||
@ -18,6 +18,7 @@ import ToolTipMenu from '../TooltipMenu';
|
||||
import { CommonToolTipActions } from '../../typings/CommonToolTipActions';
|
||||
import { useExtendedNavigation } from '../../hooks/useExtendedNavigation';
|
||||
import HighlightedText from '../HighlightedText';
|
||||
import { useSharedValue, withSpring, withTiming } from 'react-native-reanimated';
|
||||
|
||||
interface AddressItemProps {
|
||||
item: any;
|
||||
@ -26,7 +27,7 @@ interface AddressItemProps {
|
||||
allowSignVerifyMessage: boolean;
|
||||
onPress?: () => void; // example: ManageWallets uses this
|
||||
searchQuery?: string;
|
||||
renderHighlightedText?: (text: string, query: string) => JSX.Element;
|
||||
renderHighlightedText?: (text: string, query: string) => React.ReactElement;
|
||||
}
|
||||
|
||||
type NavigationProps = NativeStackNavigationProp<DetailViewStackParamList>;
|
||||
@ -43,6 +44,9 @@ const AddressItem = ({
|
||||
const { wallets } = useStorage();
|
||||
const { colors } = useTheme();
|
||||
const { isBiometricUseCapableAndEnabled } = useBiometrics();
|
||||
const balanceOpacity = useSharedValue(1);
|
||||
const balanceTranslateY = useSharedValue(0);
|
||||
const previousBalance = useRef<string | undefined>(undefined);
|
||||
|
||||
const hasTransactions = item.transactions > 0;
|
||||
|
||||
@ -50,6 +54,7 @@ const AddressItem = ({
|
||||
container: {
|
||||
borderBottomColor: colors.lightBorder,
|
||||
backgroundColor: colors.elevated,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
},
|
||||
|
||||
index: {
|
||||
@ -104,6 +109,17 @@ const AddressItem = ({
|
||||
|
||||
const balance = formatBalance(item.balance, balanceUnit, true);
|
||||
|
||||
useEffect(() => {
|
||||
if (previousBalance.current !== undefined && previousBalance.current !== balance) {
|
||||
balanceOpacity.value = 0;
|
||||
balanceTranslateY.value = 6;
|
||||
balanceOpacity.value = withTiming(1, { duration: 180 });
|
||||
balanceTranslateY.value = withSpring(0, { damping: 16, stiffness: 220 });
|
||||
}
|
||||
|
||||
previousBalance.current = balance;
|
||||
}, [balance, balanceOpacity, balanceTranslateY]);
|
||||
|
||||
const handleCopyPress = useCallback(() => {
|
||||
Clipboard.setString(item.address);
|
||||
}, [item.address]);
|
||||
@ -189,6 +205,8 @@ const AddressItem = ({
|
||||
renderPreview={renderPreview}
|
||||
onPress={navigateToReceive}
|
||||
isButton
|
||||
buttonStyle={styles.tooltipButton}
|
||||
shouldOpenOnLongPress
|
||||
>
|
||||
<View key={item.key} style={[styles.container, stylesHook.container]}>
|
||||
<View style={styles.row}>
|
||||
@ -223,26 +241,36 @@ const styles = StyleSheet.create({
|
||||
fontWeight: 'bold',
|
||||
marginHorizontal: 4,
|
||||
},
|
||||
tooltipButton: {
|
||||
width: '100%',
|
||||
alignSelf: 'stretch',
|
||||
},
|
||||
index: {
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
},
|
||||
balance: {
|
||||
marginTop: 4,
|
||||
marginTop: 6,
|
||||
fontWeight: '600',
|
||||
},
|
||||
row: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
},
|
||||
leftSection: {
|
||||
marginRight: 8,
|
||||
marginRight: 10,
|
||||
paddingTop: 2,
|
||||
},
|
||||
middleSection: {
|
||||
flex: 1,
|
||||
paddingRight: 12,
|
||||
},
|
||||
rightContainer: {
|
||||
justifyContent: 'center',
|
||||
alignItems: 'flex-end',
|
||||
minWidth: 96,
|
||||
paddingLeft: 8,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { StyleSheet, TouchableOpacity } from 'react-native';
|
||||
import { StyleSheet, View } from 'react-native';
|
||||
import Icon from '../Icon';
|
||||
import { useTheme } from '../themes';
|
||||
import { useExtendedNavigation } from '../../hooks/useExtendedNavigation';
|
||||
@ -29,16 +29,20 @@ const SettingsButton = () => {
|
||||
|
||||
const actions = useMemo(() => [CommonToolTipActions.ManageWallet], []);
|
||||
return (
|
||||
<ToolTipMenu onPressMenuItem={onPressMenuItem} actions={actions}>
|
||||
<TouchableOpacity
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={loc.settings.default_title}
|
||||
testID="SettingsButton"
|
||||
style={[style.buttonStyle, { backgroundColor: colors.lightButton }]}
|
||||
onPress={onPress}
|
||||
>
|
||||
<Icon size={22} name="more-horiz" type="material" color={colors.foregroundColor} />
|
||||
</TouchableOpacity>
|
||||
<ToolTipMenu
|
||||
isButton
|
||||
onPress={onPress}
|
||||
buttonStyle={[style.buttonStyle, { backgroundColor: colors.lightButton }]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={loc.settings.default_title}
|
||||
testID="SettingsButton"
|
||||
onPressMenuItem={onPressMenuItem}
|
||||
actions={actions}
|
||||
shouldOpenOnLongPress
|
||||
>
|
||||
<View style={style.iconContainer}>
|
||||
<Icon size={22} name="more-horiz" type="material" color={colors.foregroundColor} iconStyle={style.icon} />
|
||||
</View>
|
||||
</ToolTipMenu>
|
||||
);
|
||||
};
|
||||
@ -53,4 +57,9 @@ const style = StyleSheet.create({
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
iconContainer: {
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
icon: {},
|
||||
});
|
||||
|
||||
@ -124,12 +124,17 @@ const navigationStyle = (
|
||||
|
||||
const leftCloseButtonStyle = isLeftCloseButtonAndroid ? { headerBackImageSource: theme.closeImage } : { headerLeft };
|
||||
|
||||
// statusBarStyle: auto is not supported on Android, so we get it based on the theme.barStyle
|
||||
const statusBarStyle: NativeStackNavigationOptions['statusBarStyle'] =
|
||||
opts.statusBarStyle && opts.statusBarStyle !== 'auto' ? opts.statusBarStyle : theme.barStyle === 'light-content' ? 'light' : 'dark';
|
||||
|
||||
let options: NativeStackNavigationOptions = {
|
||||
...baseHeaderStyle,
|
||||
...leftCloseButtonStyle,
|
||||
headerBackButtonDisplayMode: 'minimal',
|
||||
headerRight,
|
||||
...opts,
|
||||
statusBarStyle,
|
||||
};
|
||||
|
||||
if (formatter) {
|
||||
|
||||
@ -69,7 +69,6 @@ export const BlueDefaultTheme = {
|
||||
changeText: '#F38C47',
|
||||
receiveBackground: '#D1F9D6',
|
||||
receiveText: '#37C0A1',
|
||||
navigationBarColor: '#FFFFFF',
|
||||
androidRippleColor: '#CCCCCC',
|
||||
},
|
||||
};
|
||||
@ -127,7 +126,6 @@ export const BlueDarkTheme: Theme = {
|
||||
changeText: '#F38C47',
|
||||
receiveBackground: 'rgba(210,248,214,.2)',
|
||||
receiveText: '#37C0A1',
|
||||
navigationBarColor: '#3A3A3C',
|
||||
androidRippleColor: '#444444',
|
||||
},
|
||||
};
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { AccessibilityRole, ViewStyle, ColorValue } from 'react-native';
|
||||
import { AccessibilityRole, ViewStyle, ColorValue, GestureResponderEvent } from 'react-native';
|
||||
|
||||
export interface Action {
|
||||
id: string | number;
|
||||
@ -25,10 +25,10 @@ export interface ToolTipMenuProps {
|
||||
dismissMenu?: () => void;
|
||||
onPressMenuItem: (id: string) => void;
|
||||
title?: string;
|
||||
isMenuPrimaryAction?: boolean;
|
||||
shouldOpenOnLongPress?: boolean;
|
||||
isButton?: boolean;
|
||||
renderPreview?: () => React.ReactNode;
|
||||
onPress?: () => void;
|
||||
onPress?: (event: GestureResponderEvent) => void;
|
||||
previewValue?: string;
|
||||
accessibilityRole?: AccessibilityRole;
|
||||
disabled?: boolean;
|
||||
|
||||
@ -24,8 +24,125 @@ def app_store_state_readable(state)
|
||||
states[state] || state
|
||||
end
|
||||
|
||||
require 'securerandom'
|
||||
|
||||
default_platform(:android)
|
||||
project_root = File.expand_path("..", __dir__)
|
||||
PROJECT_ROOT = File.expand_path("..", __dir__)
|
||||
project_root = PROJECT_ROOT
|
||||
|
||||
module AndroidHelpers
|
||||
module_function
|
||||
|
||||
def require_env!(keys)
|
||||
missing = Array(keys).select { |key| ENV[key].nil? || ENV[key].empty? }
|
||||
UI.user_error!("Missing required env vars: #{missing.join(', ')}") unless missing.empty?
|
||||
end
|
||||
|
||||
def project_root
|
||||
PROJECT_ROOT
|
||||
end
|
||||
|
||||
def keystore_paths
|
||||
{
|
||||
hex: File.join(project_root, 'bluewallet-release-key.keystore.hex'),
|
||||
file: File.join(project_root, 'android', 'bluewallet-release-key.keystore'),
|
||||
}
|
||||
end
|
||||
|
||||
def write_keystore_from_hex!(hex_value, paths)
|
||||
UI.user_error!("KEYSTORE_FILE_HEX environment variable is missing") if hex_value.nil? || hex_value.empty?
|
||||
File.write(paths[:hex], hex_value)
|
||||
Actions.sh("xxd -plain -revert #{paths[:hex]} > #{paths[:file]}") do |status|
|
||||
UI.user_error!("Error reverting hex to keystore") unless status.success?
|
||||
end
|
||||
File.delete(paths[:hex])
|
||||
end
|
||||
|
||||
def version_name_and_update_code!(build_gradle_path, build_number)
|
||||
gradle_contents = File.read(build_gradle_path)
|
||||
File.write(build_gradle_path, gradle_contents.gsub(/versionCode\s+\d+/, "versionCode #{build_number}"))
|
||||
version_name = File.read(build_gradle_path)[/versionName\s+"([^"]+)"/, 1]
|
||||
UI.user_error!("Failed to extract versionName from #{build_gradle_path}") if version_name.nil? || version_name.empty?
|
||||
version_name
|
||||
end
|
||||
|
||||
def branch_name
|
||||
raw = ENV['GITHUB_HEAD_REF'] || ENV['GITHUB_REF_NAME'] || `git rev-parse --abbrev-ref HEAD`.strip
|
||||
sanitized = raw.to_s.gsub(/[^a-zA-Z0-9_-]/, '_')
|
||||
sanitized.empty? ? 'master' : sanitized
|
||||
end
|
||||
|
||||
def apk_name(version_name, build_number, branch)
|
||||
branch != 'master' ? "BlueWallet-#{version_name}-#{build_number}-#{branch}.apk" : "BlueWallet-#{version_name}-#{build_number}.apk"
|
||||
end
|
||||
|
||||
def apksigner_path
|
||||
sdk_root = ENV['ANDROID_HOME'] || ENV['ANDROID_SDK_ROOT']
|
||||
Dir.glob(File.join(sdk_root.to_s, 'build-tools', '*', 'apksigner')).sort.last
|
||||
end
|
||||
|
||||
def gradle_log_path
|
||||
path = File.join(project_root, 'fastlane', 'logs', 'gradle-build.log')
|
||||
FileUtils.mkdir_p(File.dirname(path))
|
||||
path
|
||||
end
|
||||
|
||||
def assemble_release!(log_path:)
|
||||
Actions.sh("cd android && ./gradlew assembleRelease --no-daemon --stacktrace --console=plain | tee #{log_path}") do |status|
|
||||
UI.user_error!("Gradle assembleRelease failed") unless status.success?
|
||||
end
|
||||
end
|
||||
|
||||
def resolve_apk_paths(version_name:, build_number:, branch_name:)
|
||||
apk_dir = File.join(project_root, 'android', 'app', 'build', 'outputs', 'apk', 'release')
|
||||
unsigned = File.join(apk_dir, 'app-release-unsigned.apk')
|
||||
fallback = File.join(apk_dir, 'app-release.apk')
|
||||
signed = File.join(apk_dir, apk_name(version_name, build_number, branch_name))
|
||||
{ unsigned: unsigned, fallback: fallback, signed: signed }
|
||||
end
|
||||
|
||||
def finalize_apk!(paths)
|
||||
candidate = File.exist?(paths[:unsigned]) ? paths[:unsigned] : paths[:fallback]
|
||||
UI.user_error!("Unsigned APK not found at path: #{paths[:unsigned]} or #{paths[:fallback]}") unless File.exist?(candidate)
|
||||
FileUtils.mv(candidate, paths[:signed])
|
||||
paths[:signed]
|
||||
end
|
||||
|
||||
def sign_apk!(apk_path, keystore_path, keystore_password)
|
||||
signer = apksigner_path
|
||||
UI.user_error!("apksigner not found in Android build-tools") if signer.nil? || signer.empty?
|
||||
Actions.sh("#{signer} sign --ks #{keystore_path} --ks-pass=pass:#{keystore_password} #{apk_path}")
|
||||
end
|
||||
|
||||
def write_github_output(hash)
|
||||
return unless ENV['GITHUB_OUTPUT'] && !ENV['GITHUB_OUTPUT'].empty?
|
||||
|
||||
File.open(ENV['GITHUB_OUTPUT'], 'a') do |f|
|
||||
hash.each { |key, value| f.puts("#{key}=#{value}") }
|
||||
end
|
||||
end
|
||||
|
||||
def ensure_temp_credentials!(auto: false)
|
||||
return unless auto
|
||||
|
||||
temp_dir = Dir.mktmpdir('bw-temp-keystore')
|
||||
keystore_path = File.join(temp_dir, 'temp.keystore')
|
||||
password = "temp#{SecureRandom.hex(6)}"
|
||||
alias_name = "bluewallet-temp"
|
||||
|
||||
Actions.sh(
|
||||
"keytool -genkeypair -v -keystore #{keystore_path} -storepass #{password} -keypass #{password} " \
|
||||
"-alias #{alias_name} -keyalg RSA -keysize 2048 -validity 10000 " \
|
||||
"-dname \"CN=Temp,O=BlueWallet,OU=CI,L=NY,ST=NY,C=US\""
|
||||
) do |status|
|
||||
UI.user_error!("Failed to create temporary keystore with keytool") unless status.success?
|
||||
end
|
||||
|
||||
keystore_hex = File.binread(keystore_path).unpack1('H*')
|
||||
ENV['KEYSTORE_FILE_HEX'] = keystore_hex
|
||||
ENV['KEYSTORE_PASSWORD'] = password
|
||||
end
|
||||
end
|
||||
|
||||
# Add session caching for App Store Connect
|
||||
def cached_app_store_connect_login
|
||||
@ -112,82 +229,87 @@ platform :android do
|
||||
|
||||
desc "Prepare the keystore file"
|
||||
lane :prepare_keystore do
|
||||
Dir.chdir(project_root) do
|
||||
keystore_file_hex = ENV['KEYSTORE_FILE_HEX']
|
||||
UI.user_error!("KEYSTORE_FILE_HEX environment variable is missing") if keystore_file_hex.nil?
|
||||
|
||||
UI.message("Creating keystore from HEX...")
|
||||
File.write("bluewallet-release-key.keystore.hex", keystore_file_hex)
|
||||
|
||||
sh("xxd -plain -revert bluewallet-release-key.keystore.hex > bluewallet-release-key.keystore") do |status|
|
||||
UI.user_error!("Error reverting hex to keystore") unless status.success?
|
||||
end
|
||||
Dir.chdir(PROJECT_ROOT) do
|
||||
paths = AndroidHelpers.keystore_paths
|
||||
AndroidHelpers.write_keystore_from_hex!(ENV['KEYSTORE_FILE_HEX'], paths)
|
||||
UI.message("Keystore created successfully.")
|
||||
|
||||
File.delete("bluewallet-release-key.keystore.hex")
|
||||
end
|
||||
end
|
||||
|
||||
desc "Update version, build number, and sign APK"
|
||||
lane :update_version_build_and_sign_apk do
|
||||
Dir.chdir(project_root) do
|
||||
build_number = ENV['BUILD_NUMBER']
|
||||
UI.user_error!("BUILD_NUMBER environment variable is missing") if build_number.nil?
|
||||
|
||||
# Extract versionName from build.gradle
|
||||
version_name = sh("grep versionName android/app/build.gradle | awk '{print $2}' | tr -d '\"'").strip
|
||||
UI.user_error!("Failed to extract versionName from build.gradle") if version_name.nil? || version_name.empty?
|
||||
|
||||
# Update versionCode in build.gradle
|
||||
UI.message("Updating versionCode in build.gradle to #{build_number}...")
|
||||
build_gradle_path = "android/app/build.gradle"
|
||||
build_gradle_contents = File.read(build_gradle_path)
|
||||
new_build_gradle_contents = build_gradle_contents.gsub(/versionCode\s+\d+/, "versionCode #{build_number}")
|
||||
File.write(build_gradle_path, new_build_gradle_contents)
|
||||
|
||||
# Determine branch name and sanitize it
|
||||
branch_name = ENV['GITHUB_HEAD_REF'] || `git rev-parse --abbrev-ref HEAD`.strip
|
||||
branch_name = branch_name.gsub(/[^a-zA-Z0-9_-]/, '_') # Replace non-alphanumeric characters with underscore
|
||||
branch_name = 'master' if branch_name.nil? || branch_name.empty?
|
||||
|
||||
# Define APK name based on branch
|
||||
signed_apk_name = branch_name != 'master' ?
|
||||
"BlueWallet-#{version_name}-#{build_number}-#{branch_name}.apk" :
|
||||
"BlueWallet-#{version_name}-#{build_number}.apk"
|
||||
|
||||
# Define paths
|
||||
unsigned_apk_path = "android/app/build/outputs/apk/release/app-release-unsigned.apk"
|
||||
signed_apk_path = "android/app/build/outputs/apk/release/#{signed_apk_name}"
|
||||
|
||||
# Build APK
|
||||
lane :update_version_build_and_sign_apk do |options|
|
||||
Dir.chdir(PROJECT_ROOT) do
|
||||
AndroidHelpers.ensure_temp_credentials!(auto: options[:auto_credentials])
|
||||
AndroidHelpers.require_env!(%w[BUILD_NUMBER KEYSTORE_PASSWORD KEYSTORE_FILE_HEX])
|
||||
|
||||
keystore_paths = AndroidHelpers.keystore_paths
|
||||
AndroidHelpers.write_keystore_from_hex!(ENV['KEYSTORE_FILE_HEX'], keystore_paths)
|
||||
|
||||
build_gradle_path = File.join('android', 'app', 'build.gradle')
|
||||
version_name = AndroidHelpers.version_name_and_update_code!(build_gradle_path, ENV['BUILD_NUMBER'])
|
||||
branch = AndroidHelpers.branch_name
|
||||
apk_paths = AndroidHelpers.resolve_apk_paths(version_name: version_name, build_number: ENV['BUILD_NUMBER'], branch_name: branch)
|
||||
|
||||
UI.message("Building APK...")
|
||||
sh("cd android && ./gradlew assembleRelease --no-daemon")
|
||||
AndroidHelpers.assemble_release!(log_path: AndroidHelpers.gradle_log_path)
|
||||
UI.message("APK build completed.")
|
||||
|
||||
# Rename APK
|
||||
if File.exist?(unsigned_apk_path)
|
||||
UI.message("Renaming APK to #{signed_apk_name}...")
|
||||
FileUtils.mv(unsigned_apk_path, signed_apk_path)
|
||||
ENV['APK_OUTPUT_PATH'] = File.expand_path(signed_apk_path)
|
||||
else
|
||||
UI.error("Unsigned APK not found at path: #{unsigned_apk_path}")
|
||||
next
|
||||
end
|
||||
|
||||
# Sign APK
|
||||
|
||||
signed_apk_path = AndroidHelpers.finalize_apk!(apk_paths)
|
||||
ENV['APK_OUTPUT_PATH'] = File.expand_path(signed_apk_path)
|
||||
|
||||
UI.message("Signing APK with apksigner...")
|
||||
apksigner_path = Dir.glob("#{ENV['ANDROID_HOME']}/build-tools/*/apksigner").sort.last
|
||||
UI.user_error!("apksigner not found in Android build-tools") if apksigner_path.nil? || apksigner_path.empty?
|
||||
sh("#{apksigner_path} sign --ks #{project_root}/bluewallet-release-key.keystore --ks-pass=pass:#{ENV['KEYSTORE_PASSWORD']} #{signed_apk_path}")
|
||||
AndroidHelpers.sign_apk!(signed_apk_path, keystore_paths[:file], ENV['KEYSTORE_PASSWORD'])
|
||||
UI.message("APK signed successfully: #{signed_apk_path}")
|
||||
|
||||
FileUtils.rm_f(keystore_paths[:file])
|
||||
end
|
||||
end
|
||||
|
||||
desc "Build and sign release APK"
|
||||
lane :build_release_apk do |options|
|
||||
Dir.chdir(PROJECT_ROOT) do
|
||||
# Allow caller to pass a build_number; otherwise fall back to env or timestamp
|
||||
build_number = options[:build_number] || ENV['BUILD_NUMBER'] || Time.now.to_i.to_s
|
||||
ENV['BUILD_NUMBER'] = build_number
|
||||
|
||||
AndroidHelpers.ensure_temp_credentials!(auto: options[:auto_credentials])
|
||||
AndroidHelpers.require_env!(%w[KEYSTORE_FILE_HEX KEYSTORE_PASSWORD])
|
||||
|
||||
keystore_paths = AndroidHelpers.keystore_paths
|
||||
AndroidHelpers.write_keystore_from_hex!(ENV['KEYSTORE_FILE_HEX'], keystore_paths)
|
||||
|
||||
build_gradle_path = File.join('android', 'app', 'build.gradle')
|
||||
version_name = AndroidHelpers.version_name_and_update_code!(build_gradle_path, build_number)
|
||||
branch = AndroidHelpers.branch_name
|
||||
|
||||
UI.message("Building release APK...")
|
||||
AndroidHelpers.assemble_release!(log_path: AndroidHelpers.gradle_log_path)
|
||||
|
||||
apk_paths = AndroidHelpers.resolve_apk_paths(version_name: version_name, build_number: build_number, branch_name: branch)
|
||||
signed_apk_path = AndroidHelpers.finalize_apk!(apk_paths)
|
||||
|
||||
UI.message("Signing APK with apksigner...")
|
||||
AndroidHelpers.sign_apk!(signed_apk_path, keystore_paths[:file], ENV['KEYSTORE_PASSWORD'])
|
||||
UI.success("APK signed successfully: #{signed_apk_path}")
|
||||
|
||||
FileUtils.rm_f(keystore_paths[:file])
|
||||
|
||||
apk_absolute_path = File.expand_path(signed_apk_path)
|
||||
ENV['APK_OUTPUT_PATH'] = apk_absolute_path
|
||||
ENV['APK_VERSION_NAME'] = version_name
|
||||
ENV['APK_VERSION_CODE'] = build_number
|
||||
|
||||
AndroidHelpers.write_github_output(
|
||||
apk_output_path: apk_absolute_path,
|
||||
apk_version_name: version_name,
|
||||
apk_version_code: build_number,
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
desc "Upload APK to BrowserStack and post result as PR comment"
|
||||
lane :upload_to_browserstack_and_comment do
|
||||
Dir.chdir(project_root) do
|
||||
# Determine APK path
|
||||
Dir.chdir(PROJECT_ROOT) do
|
||||
apk_path = ENV['APK_PATH']
|
||||
if apk_path.nil? || apk_path.empty?
|
||||
UI.message("No APK path provided, searching for APK...")
|
||||
@ -195,7 +317,6 @@ end
|
||||
UI.user_error!("No APK file found") if apk_path.nil? || apk_path.empty?
|
||||
end
|
||||
|
||||
# Upload to BrowserStack
|
||||
UI.message("Uploading APK to BrowserStack: #{apk_path}...")
|
||||
upload_to_browserstack_app_live(
|
||||
file_path: apk_path,
|
||||
@ -203,13 +324,11 @@ end
|
||||
browserstack_access_key: ENV['BROWSERSTACK_ACCESS_KEY']
|
||||
)
|
||||
|
||||
# Extract BrowserStack URL
|
||||
app_url = ENV['BROWSERSTACK_LIVE_APP_ID']
|
||||
UI.user_error!("BrowserStack upload failed, no app URL returned") if app_url.nil? || app_url.empty?
|
||||
|
||||
# Prepare PR comment
|
||||
apk_filename = File.basename(apk_path)
|
||||
apk_download_url = ENV['APK_OUTPUT_PATH'] # Ensure this path is accessible
|
||||
apk_download_url = ENV['APK_OUTPUT_PATH']
|
||||
browserstack_hashed_id = app_url.gsub('bs://', '')
|
||||
pr_number = ENV['GITHUB_PR_NUMBER']
|
||||
|
||||
@ -236,10 +355,9 @@ end
|
||||
**BrowserStack App URL**: #{app_url}
|
||||
COMMENT
|
||||
|
||||
# Delete Previous BrowserStack Comments
|
||||
if pr_number
|
||||
begin
|
||||
repo = ENV['GITHUB_REPOSITORY'] # Format: "owner/repo"
|
||||
repo = ENV['GITHUB_REPOSITORY']
|
||||
repo_owner, repo_name = repo.split('/')
|
||||
|
||||
UI.message("Fetching existing comments for PR ##{pr_number}...")
|
||||
@ -263,7 +381,6 @@ end
|
||||
UI.important("No PR number found. Skipping deletion of previous comments.")
|
||||
end
|
||||
|
||||
# Post New Comment to PR
|
||||
if pr_number
|
||||
begin
|
||||
escaped_comment = comment.gsub("'", "'\\''")
|
||||
@ -276,6 +393,7 @@ end
|
||||
UI.important("No PR number found. Skipping PR comment.")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
|
||||
@ -1,13 +1,37 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { LayoutAnimation } from 'react-native';
|
||||
import { useAnimatedReaction, useAnimatedStyle, useSharedValue, withTiming, Easing, interpolate } from 'react-native-reanimated';
|
||||
|
||||
const useAnimateOnChange = <T>(value: T) => {
|
||||
const prevValue = useRef<T | undefined>(undefined);
|
||||
useEffect(() => {
|
||||
if (prevValue.current !== undefined && prevValue.current !== value) {
|
||||
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);
|
||||
}
|
||||
prevValue.current = value;
|
||||
}, [value]);
|
||||
const progress = useSharedValue(1);
|
||||
|
||||
useAnimatedReaction(
|
||||
() => {
|
||||
return value;
|
||||
},
|
||||
(current, previous) => {
|
||||
if (previous === null || previous === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (current !== previous) {
|
||||
progress.value = 0;
|
||||
progress.value = withTiming(1, { duration: 220, easing: Easing.out(Easing.quad) });
|
||||
}
|
||||
},
|
||||
[value],
|
||||
);
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => {
|
||||
return {
|
||||
opacity: interpolate(progress.value, [0, 1], [0.85, 1]),
|
||||
transform: [
|
||||
{
|
||||
scale: interpolate(progress.value, [0, 1], [0.98, 1]),
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
return animatedStyle;
|
||||
};
|
||||
|
||||
export default useAnimateOnChange;
|
||||
|
||||
@ -47,7 +47,7 @@ const useCompanionListeners = (skipIfNotInitialized = true) => {
|
||||
walletsInitialized,
|
||||
} = useStorage();
|
||||
const appState = useRef<AppStateStatus>(AppState.currentState);
|
||||
const clipboardContent = useRef<undefined | string>();
|
||||
const clipboardContent = useRef<undefined | string>(undefined);
|
||||
const navigation = useExtendedNavigation();
|
||||
|
||||
// We need to call hooks unconditionally before any conditional logic
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { useEffect, useCallback } from 'react';
|
||||
import { NativeEventEmitter, NativeModules } from 'react-native';
|
||||
import { NativeEventEmitter } from 'react-native';
|
||||
import EventEmitterModule from '../blue_modules/NativeEventEmitter';
|
||||
import { useStorage } from '../hooks/context/useStorage';
|
||||
import { useExtendedNavigation } from '../hooks/useExtendedNavigation';
|
||||
import { HandOffActivityType } from '../components/types';
|
||||
@ -13,8 +14,7 @@ interface UserActivityData {
|
||||
};
|
||||
}
|
||||
|
||||
const EventEmitter = NativeModules.EventEmitter;
|
||||
const eventEmitter = EventEmitter ? new NativeEventEmitter(EventEmitter) : null;
|
||||
const eventEmitter = EventEmitterModule ? new NativeEventEmitter(EventEmitterModule as any) : null;
|
||||
|
||||
const useHandoffListener = () => {
|
||||
const { walletsInitialized } = useStorage();
|
||||
@ -50,8 +50,9 @@ const useHandoffListener = () => {
|
||||
|
||||
const activitySubscription = eventEmitter?.addListener('onUserActivityOpen', handleUserActivity);
|
||||
|
||||
if (EventEmitter && EventEmitter.getMostRecentUserActivity) {
|
||||
EventEmitter.getMostRecentUserActivity()
|
||||
if (EventEmitterModule && (EventEmitterModule as any).getMostRecentUserActivity) {
|
||||
(EventEmitterModule as any)
|
||||
.getMostRecentUserActivity()
|
||||
.then(handleUserActivity)
|
||||
.catch(() => console.debug('No valid user activity object received'));
|
||||
} else {
|
||||
|
||||
@ -1,18 +1,18 @@
|
||||
import { useEffect, useCallback, useRef } from 'react';
|
||||
import { NativeEventEmitter, NativeModules, Platform } from 'react-native';
|
||||
import { NativeEventEmitter, Platform } from 'react-native';
|
||||
import MenuElementsEmitter from '../blue_modules/NativeMenuElementsEmitter';
|
||||
import { navigationRef } from '../NavigationService';
|
||||
|
||||
type MenuActionHandler = () => void;
|
||||
|
||||
const { MenuElementsEmitter } = NativeModules;
|
||||
let eventEmitter: NativeEventEmitter | null = null;
|
||||
const handlerRegistry = new Map<string, MenuActionHandler>();
|
||||
|
||||
try {
|
||||
if (Platform.OS === 'ios' && MenuElementsEmitter) {
|
||||
eventEmitter = new NativeEventEmitter(MenuElementsEmitter);
|
||||
if (typeof MenuElementsEmitter.sharedInstance === 'function') {
|
||||
MenuElementsEmitter.sharedInstance();
|
||||
eventEmitter = new NativeEventEmitter(MenuElementsEmitter as any);
|
||||
if (typeof (MenuElementsEmitter as any).sharedInstance === 'function') {
|
||||
(MenuElementsEmitter as any).sharedInstance();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@ -9,7 +9,7 @@ const useWalletSubscribe = (walletID: string): TWallet => {
|
||||
const { wallets } = useStorage();
|
||||
|
||||
// get wallet by ID or used cached wallet
|
||||
const previousWallet = useRef<TWallet | undefined>();
|
||||
const previousWallet = useRef<TWallet | undefined>(undefined);
|
||||
const origWallet = wallets.find(w => w.getID() === walletID) ?? previousWallet.current;
|
||||
if (!origWallet) {
|
||||
throw new Error(`Wallet with ID ${walletID} not found`);
|
||||
|
||||
@ -6,5 +6,8 @@
|
||||
// Copyright © 2025 BlueWallet. All rights reserved.
|
||||
//
|
||||
|
||||
#import <RNCPushNotificationIOS.h>
|
||||
#import "RNNotifications.h"
|
||||
#import "RNQuickActionManager.h"
|
||||
#import "NativeEventEmitterSpec.h"
|
||||
#import "NativeMenuElementsEmitterSpec.h"
|
||||
#import "NativeWidgetHelperSpec.h"
|
||||
|
||||
@ -163,6 +163,8 @@
|
||||
B4D899942DCAE67700B959AA /* CustomSegmentedControl.m in Sources */ = {isa = PBXBuildFile; fileRef = B4D899932DCAE67700B959AA /* CustomSegmentedControl.m */; };
|
||||
B4EE583C226703320003363C /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B40D4E35225841ED00428FCC /* Assets.xcassets */; };
|
||||
B4EFF73B2C3F6C5E0095D655 /* MockData.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4EFF73A2C3F6C5E0095D655 /* MockData.swift */; };
|
||||
B4F0A4A22FA1BC0000AAAA01 /* WidgetHelper.mm in Sources */ = {isa = PBXBuildFile; fileRef = B4F0A4A12FA1BC0000AAAA00 /* WidgetHelper.mm */; };
|
||||
B4F0A4A42FA1BC0000AAAA03 /* EventEmitter.mm in Sources */ = {isa = PBXBuildFile; fileRef = B4F0A4A32FA1BC0000AAAA02 /* EventEmitter.mm */; };
|
||||
C978A716948AB7DEC5B6F677 /* BuildFile in Frameworks */ = {isa = PBXBuildFile; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
@ -361,6 +363,7 @@
|
||||
B49038D82B8FBAD300A8164A /* BlueWalletUITest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlueWalletUITest.swift; sourceTree = "<group>"; };
|
||||
B49A28BA2CD18999006B08E4 /* CompactPriceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompactPriceView.swift; sourceTree = "<group>"; };
|
||||
B49A28BD2CD189B0006B08E4 /* FiatUnitEnum.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FiatUnitEnum.swift; sourceTree = "<group>"; };
|
||||
B49D99932EFE2F3500A718AC /* NativeWidgetHelperSpec.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = NativeWidgetHelperSpec.h; sourceTree = "<group>"; };
|
||||
B4AA75232DAA339E00CF5CBE /* MenuElementsEmitter.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MenuElementsEmitter.m; sourceTree = "<group>"; };
|
||||
B4AB225C2B02AD12001F4328 /* XMLParserDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = XMLParserDelegate.swift; sourceTree = "<group>"; };
|
||||
B4B1A4612BFA73110072E3BB /* WidgetHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetHelper.swift; sourceTree = "<group>"; };
|
||||
@ -376,6 +379,10 @@
|
||||
B4D59C262D8C5D6E00B7025B /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = "<group>"; };
|
||||
B4D899932DCAE67700B959AA /* CustomSegmentedControl.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = CustomSegmentedControl.m; sourceTree = "<group>"; };
|
||||
B4EFF73A2C3F6C5E0095D655 /* MockData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockData.swift; sourceTree = "<group>"; };
|
||||
B4F0A4A12FA1BC0000AAAA00 /* WidgetHelper.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = WidgetHelper.mm; sourceTree = "<group>"; };
|
||||
B4F0A4A32FA1BC0000AAAA02 /* EventEmitter.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = EventEmitter.mm; sourceTree = "<group>"; };
|
||||
B4F0A4A52FA1BC0000AAAA05 /* NativeEventEmitterSpec.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = NativeEventEmitterSpec.h; sourceTree = "<group>"; };
|
||||
B4F0A4A62FA1BC0000AAAA06 /* NativeMenuElementsEmitterSpec.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = NativeMenuElementsEmitterSpec.h; sourceTree = "<group>"; };
|
||||
B642AFB13483418CAB6FF25E /* libRCTQRCodeLocalImage.a */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = archive.ar; path = libRCTQRCodeLocalImage.a; sourceTree = "<group>"; };
|
||||
B9D9B3A7B2CB4255876B67AF /* libz.tbd */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libz.tbd; path = usr/lib/libz.tbd; sourceTree = SDKROOT; };
|
||||
BBA99996E6FA4B49ACE0BEFA /* libRNRate.a */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = archive.ar; path = libRNRate.a; sourceTree = "<group>"; };
|
||||
@ -593,6 +600,9 @@
|
||||
83CBB9F61A601CBA00E9B192 = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
B49D99932EFE2F3500A718AC /* NativeWidgetHelperSpec.h */,
|
||||
B4F0A4A52FA1BC0000AAAA05 /* NativeEventEmitterSpec.h */,
|
||||
B4F0A4A62FA1BC0000AAAA06 /* NativeMenuElementsEmitterSpec.h */,
|
||||
B45010A12C1504E900619044 /* Components */,
|
||||
B44033C82BCC34AC00162242 /* Shared */,
|
||||
B41C2E552BB3DCB8000FE097 /* PrivacyInfo.xcprivacy */,
|
||||
@ -742,9 +752,11 @@
|
||||
B4D899932DCAE67700B959AA /* CustomSegmentedControl.m */,
|
||||
B4AA75232DAA339E00CF5CBE /* MenuElementsEmitter.m */,
|
||||
B4B3EC232D69FF8700327F3D /* EventEmitter.swift */,
|
||||
B4F0A4A32FA1BC0000AAAA02 /* EventEmitter.mm */,
|
||||
B409AB052D71E07500BA06F8 /* MenuElementsEmitter.swift */,
|
||||
B4B3EC202D69FF6C00327F3D /* CustomSegmentedControl.swift */,
|
||||
B4B1A4612BFA73110072E3BB /* WidgetHelper.swift */,
|
||||
B4F0A4A12FA1BC0000AAAA00 /* WidgetHelper.mm */,
|
||||
);
|
||||
path = Components;
|
||||
sourceTree = "<group>";
|
||||
@ -1140,6 +1152,7 @@
|
||||
B48630E52CCEE8B800A8425C /* PriceView.swift in Sources */,
|
||||
B48630E72CCEE91900A8425C /* PriceWidgetProvider.swift in Sources */,
|
||||
B4B3EC252D69FF8700327F3D /* EventEmitter.swift in Sources */,
|
||||
B4F0A4A42FA1BC0000AAAA03 /* EventEmitter.mm in Sources */,
|
||||
B48630ED2CCEEEB000A8425C /* WalletAppShortcuts.swift in Sources */,
|
||||
B409AB062D71E07500BA06F8 /* MenuElementsEmitter.swift in Sources */,
|
||||
B44033CE2BCC352900162242 /* UserDefaultsGroup.swift in Sources */,
|
||||
@ -1157,6 +1170,7 @@
|
||||
B4793DBB2CEDACBD00C92C2E /* Chain.swift in Sources */,
|
||||
B4B3EC222D69FF6C00327F3D /* CustomSegmentedControl.swift in Sources */,
|
||||
B4B1A4622BFA73110072E3BB /* WidgetHelper.swift in Sources */,
|
||||
B4F0A4A22FA1BC0000AAAA01 /* WidgetHelper.mm in Sources */,
|
||||
B48630E12CCEE7C800A8425C /* PriceWidgetEntryView.swift in Sources */,
|
||||
B44033DA2BCC369A00162242 /* Colors.swift in Sources */,
|
||||
B44033D32BCC368800162242 /* UserDefaultsGroupKey.swift in Sources */,
|
||||
@ -1965,8 +1979,8 @@
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/bugsnag/bugsnag-cocoa";
|
||||
requirement = {
|
||||
kind = exactVersion;
|
||||
version = 6.28.1;
|
||||
kind = upToNextMajorVersion;
|
||||
minimumVersion = 6.28.1;
|
||||
};
|
||||
};
|
||||
/* End XCRemoteSwiftPackageReference section */
|
||||
|
||||
@ -12,6 +12,14 @@ class AppDelegate: RCTAppDelegate, UNUserNotificationCenterDelegate {
|
||||
private var userDefaultsGroup: UserDefaults?
|
||||
|
||||
override func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
|
||||
#if RCT_NEW_ARCH_ENABLED
|
||||
let turboModuleEnabled = true
|
||||
#else
|
||||
let turboModuleEnabled = false
|
||||
#endif
|
||||
|
||||
RCTAppSetupPrepareApp(application, turboModuleEnabled)
|
||||
|
||||
clearFilesIfNeeded()
|
||||
|
||||
// Fix app group UserDefaults initialization
|
||||
@ -50,8 +58,8 @@ class AppDelegate: RCTAppDelegate, UNUserNotificationCenterDelegate {
|
||||
|
||||
RCTI18nUtil.sharedInstance().allowRTL(true)
|
||||
|
||||
let center = UNUserNotificationCenter.current()
|
||||
center.delegate = self
|
||||
RNNotifications.startMonitorNotifications()
|
||||
RNNotifications.addNativeDelegate(self)
|
||||
|
||||
setupUserDefaultsListener()
|
||||
registerNotificationCategories()
|
||||
@ -60,7 +68,9 @@ class AppDelegate: RCTAppDelegate, UNUserNotificationCenterDelegate {
|
||||
_ = MenuElementsEmitter.sharedInstance()
|
||||
NSLog("[MenuElements] AppDelegate: Initialized emitter singleton")
|
||||
|
||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||
let result = super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
override func sourceURL(for bridge: RCTBridge) -> URL? {
|
||||
@ -286,7 +296,7 @@ class AppDelegate: RCTAppDelegate, UNUserNotificationCenterDelegate {
|
||||
]
|
||||
|
||||
if keys.contains(keyPath) {
|
||||
WidgetHelper.reloadAllWidgets()
|
||||
WidgetHelper().reloadAllWidgets()
|
||||
}
|
||||
}
|
||||
|
||||
@ -321,9 +331,26 @@ class AppDelegate: RCTAppDelegate, UNUserNotificationCenterDelegate {
|
||||
return RCTLinkingManager.application(app, open: url, options: options)
|
||||
}
|
||||
|
||||
override func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
|
||||
RNNotifications.didRegisterForRemoteNotifications(withDeviceToken: deviceToken)
|
||||
}
|
||||
|
||||
override func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
|
||||
RNNotifications.didFailToRegisterForRemoteNotificationsWithError(error)
|
||||
}
|
||||
|
||||
override func application(
|
||||
_ application: UIApplication,
|
||||
didReceiveRemoteNotification userInfo: [AnyHashable: Any],
|
||||
fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void
|
||||
) {
|
||||
RNNotifications.didReceiveBackgroundNotification(userInfo, withCompletionHandler: completionHandler)
|
||||
}
|
||||
|
||||
override func applicationWillTerminate(_ application: UIApplication) {
|
||||
userDefaultsGroup?.removeObject(forKey: "onUserActivityOpen")
|
||||
|
||||
|
||||
RNNotifications.removeNativeDelegate(self)
|
||||
UserDefaults.standard.removeObserver(self, forKeyPath: "deviceUID")
|
||||
}
|
||||
|
||||
@ -351,7 +378,6 @@ class AppDelegate: RCTAppDelegate, UNUserNotificationCenterDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
RNCPushNotificationIOS.didReceive(response)
|
||||
completionHandler()
|
||||
}
|
||||
|
||||
|
||||
@ -195,6 +195,8 @@
|
||||
<string>io.bluewallet.bluewallet.receiveonchain</string>
|
||||
<string>io.bluewallet.bluewallet.xpub</string>
|
||||
</array>
|
||||
<key>RCTNewArchEnabled</key>
|
||||
<true/>
|
||||
<key>UIAppFonts</key>
|
||||
<array>
|
||||
<string>Entypo.ttf</string>
|
||||
|
||||
@ -41,6 +41,8 @@
|
||||
</array>
|
||||
<key>LSApplicationCategoryType</key>
|
||||
<string>public.app-category.finance</string>
|
||||
<key>RCTNewArchEnabled</key>
|
||||
<true/>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
|
||||
@ -16,6 +16,6 @@
|
||||
|
||||
RCT_EXPORT_VIEW_PROPERTY(values, NSArray)
|
||||
RCT_EXPORT_VIEW_PROPERTY(selectedIndex, NSNumber)
|
||||
RCT_EXPORT_VIEW_PROPERTY(onChangeEvent, RCTDirectEventBlock)
|
||||
RCT_EXPORT_VIEW_PROPERTY(onChange, RCTBubblingEventBlock)
|
||||
|
||||
@end
|
||||
|
||||
9
ios/Components/EventEmitter.mm
Normal file
9
ios/Components/EventEmitter.mm
Normal file
@ -0,0 +1,9 @@
|
||||
#import <React/RCTEventEmitter.h>
|
||||
#import "NativeEventEmitterSpec.h"
|
||||
|
||||
@interface RCT_EXTERN_REMAP_MODULE(EventEmitter, EventEmitter, RCTEventEmitter<NativeEventEmitterSpec>)
|
||||
RCT_EXTERN_METHOD(addListener:(NSString *)eventName)
|
||||
RCT_EXTERN_METHOD(removeListeners:(double)count)
|
||||
RCT_EXTERN_METHOD(getMostRecentUserActivity:(RCTPromiseResolveBlock)resolve
|
||||
rejecter:(RCTPromiseRejectBlock)reject)
|
||||
@end
|
||||
@ -2,13 +2,9 @@ import Foundation
|
||||
import React
|
||||
|
||||
@objc(EventEmitter)
|
||||
class EventEmitter: RCTEventEmitter {
|
||||
class EventEmitter: RCTEventEmitter, NativeEventEmitterSpec {
|
||||
static let sharedInstance = EventEmitter()
|
||||
|
||||
override class func requiresMainQueueSetup() -> Bool {
|
||||
return true
|
||||
}
|
||||
|
||||
@objc static func shared() -> EventEmitter {
|
||||
return sharedInstance
|
||||
}
|
||||
@ -17,6 +13,14 @@ class EventEmitter: RCTEventEmitter {
|
||||
return ["onUserActivityOpen"]
|
||||
}
|
||||
|
||||
override func addListener(_ eventName: String!) {
|
||||
// Required for TurboModule event emitters; no-op handled by JS side
|
||||
}
|
||||
|
||||
override func removeListeners(_ count: Double) {
|
||||
// Required for TurboModule event emitters; no-op handled by JS side
|
||||
}
|
||||
|
||||
@objc func sendUserActivity(_ userInfo: [String: Any]) {
|
||||
sendEvent(withName: "onUserActivityOpen", body: userInfo)
|
||||
}
|
||||
|
||||
@ -1,20 +1,13 @@
|
||||
#import <React/RCTBridgeModule.h>
|
||||
#import <React/RCTEventEmitter.h>
|
||||
#import "NativeMenuElementsEmitterSpec.h"
|
||||
|
||||
// This macro exposes the Swift class to Objective-C
|
||||
@interface RCT_EXTERN_MODULE(MenuElementsEmitter, RCTEventEmitter)
|
||||
|
||||
// Expose the Swift method to JS
|
||||
RCT_EXTERN_METHOD(shared)
|
||||
@interface RCT_EXTERN_REMAP_MODULE(MenuElementsEmitter, MenuElementsEmitter, RCTEventEmitter<NativeMenuElementsEmitterSpec>)
|
||||
RCT_EXTERN_METHOD(addListener:(NSString *)eventName)
|
||||
RCT_EXTERN_METHOD(removeListeners:(double)count)
|
||||
RCT_EXTERN_METHOD(openSettings)
|
||||
RCT_EXTERN_METHOD(addWalletMenuAction)
|
||||
RCT_EXTERN_METHOD(importWalletMenuAction)
|
||||
RCT_EXTERN_METHOD(reloadTransactionsMenuAction)
|
||||
RCT_EXTERN_METHOD(checkListenerStatus)
|
||||
|
||||
// Make sure we share the same instance between native UI and JS
|
||||
+ (BOOL)requiresMainQueueSetup {
|
||||
return YES;
|
||||
}
|
||||
|
||||
RCT_EXTERN_METHOD(sharedInstance)
|
||||
@end
|
||||
|
||||
@ -3,7 +3,7 @@ import React
|
||||
|
||||
@objc(CustomSegmentedControl)
|
||||
class CustomSegmentedControl: UISegmentedControl {
|
||||
@objc var onChangeEvent: RCTDirectEventBlock?
|
||||
@objc var onChange: RCTBubblingEventBlock?
|
||||
|
||||
@objc var values: [String] = [] {
|
||||
didSet {
|
||||
@ -31,7 +31,7 @@ class CustomSegmentedControl: UISegmentedControl {
|
||||
}
|
||||
|
||||
@objc func onChange(_ sender: UISegmentedControl) {
|
||||
onChangeEvent?(["selectedIndex": sender.selectedSegmentIndex])
|
||||
onChange?(["selectedIndex": sender.selectedSegmentIndex])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
6
ios/Components/WidgetHelper.mm
Normal file
6
ios/Components/WidgetHelper.mm
Normal file
@ -0,0 +1,6 @@
|
||||
#import <React/RCTBridgeModule.h>
|
||||
#import "NativeWidgetHelperSpec.h"
|
||||
|
||||
@interface RCT_EXTERN_REMAP_MODULE(WidgetHelper, WidgetHelperModule, NSObject<NativeWidgetHelperSpec>)
|
||||
RCT_EXTERN_METHOD(reloadAllWidgets)
|
||||
@end
|
||||
@ -1,11 +1,40 @@
|
||||
import Foundation
|
||||
import WidgetKit
|
||||
#if canImport(React_Codegen)
|
||||
import React
|
||||
#endif
|
||||
|
||||
@objc class WidgetHelper: NSObject {
|
||||
@objc static func reloadAllWidgets() {
|
||||
if #available(iOS 14.0, *) {
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
} else {
|
||||
// Fallback on earlier versions
|
||||
}
|
||||
// Lightweight helper used by the app target to refresh widget timelines from native code.
|
||||
class WidgetHelper {
|
||||
func reloadAllWidgets() {
|
||||
if #available(iOS 14.0, *) {
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if canImport(React_Codegen)
|
||||
@objc(WidgetHelperModule)
|
||||
class WidgetHelperModule: NSObject, NativeWidgetHelperSpec {
|
||||
static func moduleName() -> String! { "WidgetHelper" }
|
||||
static func requiresMainQueueSetup() -> Bool { false }
|
||||
|
||||
@objc
|
||||
func reloadAllWidgets() {
|
||||
if #available(iOS 14.0, *) {
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
}
|
||||
}
|
||||
}
|
||||
#else
|
||||
// Fallback for targets (e.g., widget extension) that do not pull in React codegen modules.
|
||||
@objc(WidgetHelperModule)
|
||||
class WidgetHelperModule: NSObject {
|
||||
func reloadAllWidgets() {
|
||||
// WidgetsExtension does not link the app's WidgetHelper; invoke WidgetKit directly.
|
||||
if #available(iOS 14.0, *) {
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@ -1,12 +1 @@
|
||||
#import <React/RCTBridgeModule.h>
|
||||
#import <React/RCTEventEmitter.h>
|
||||
|
||||
@interface RCT_EXTERN_MODULE(MenuElementsEmitter, RCTEventEmitter)
|
||||
|
||||
RCT_EXTERN_METHOD(openSettings)
|
||||
RCT_EXTERN_METHOD(addWalletMenuAction)
|
||||
RCT_EXTERN_METHOD(importWalletMenuAction)
|
||||
RCT_EXTERN_METHOD(reloadTransactionsMenuAction)
|
||||
RCT_EXTERN_METHOD(sharedInstance)
|
||||
|
||||
@end
|
||||
// Intentionally left empty after TurboModule migration.
|
||||
|
||||
@ -2,8 +2,7 @@ import Foundation
|
||||
import React
|
||||
|
||||
@objc(MenuElementsEmitter)
|
||||
class MenuElementsEmitter: RCTEventEmitter {
|
||||
|
||||
class MenuElementsEmitter: RCTEventEmitter, NativeMenuElementsEmitterSpec {
|
||||
private static var instance: MenuElementsEmitter?
|
||||
private var hasListeners = false
|
||||
|
||||
@ -19,13 +18,23 @@ class MenuElementsEmitter: RCTEventEmitter {
|
||||
}
|
||||
return instance!
|
||||
}
|
||||
|
||||
// NativeMenuElementsEmitterSpec expects an instance method; bridge it to the singleton above.
|
||||
@objc
|
||||
func sharedInstance() {
|
||||
_ = MenuElementsEmitter.sharedInstance()
|
||||
}
|
||||
|
||||
override func supportedEvents() -> [String]! {
|
||||
return ["openSettings", "addWalletMenuAction", "importWalletMenuAction", "reloadTransactionsMenuAction"]
|
||||
}
|
||||
|
||||
override class func requiresMainQueueSetup() -> Bool {
|
||||
return true
|
||||
override func addListener(_ eventName: String!) {
|
||||
// Required for TurboModule event emitters; JS handles bookkeeping
|
||||
}
|
||||
|
||||
override func removeListeners(_ count: Double) {
|
||||
// Required for TurboModule event emitters; JS handles bookkeeping
|
||||
}
|
||||
|
||||
override func startObserving() {
|
||||
|
||||
9
ios/NativeEventEmitterSpec.h
Normal file
9
ios/NativeEventEmitterSpec.h
Normal file
@ -0,0 +1,9 @@
|
||||
#import <React/RCTBridgeModule.h>
|
||||
|
||||
// Keep this spec light for Swift bridging; avoid TurboModule includes to prevent C++ STL lookup issues during Swift dependency scanning.
|
||||
@protocol NativeEventEmitterSpec <RCTBridgeModule>
|
||||
- (void)addListener:(NSString *)eventName;
|
||||
- (void)removeListeners:(double)count;
|
||||
- (void)getMostRecentUserActivity:(RCTPromiseResolveBlock)resolve
|
||||
rejecter:(RCTPromiseRejectBlock)reject;
|
||||
@end
|
||||
11
ios/NativeMenuElementsEmitterSpec.h
Normal file
11
ios/NativeMenuElementsEmitterSpec.h
Normal file
@ -0,0 +1,11 @@
|
||||
#import <React/RCTBridgeModule.h>
|
||||
|
||||
@protocol NativeMenuElementsEmitterSpec <RCTBridgeModule>
|
||||
- (void)addListener:(NSString *)eventName;
|
||||
- (void)removeListeners:(double)count;
|
||||
- (void)openSettings;
|
||||
- (void)addWalletMenuAction;
|
||||
- (void)importWalletMenuAction;
|
||||
- (void)reloadTransactionsMenuAction;
|
||||
- (void)sharedInstance;
|
||||
@end
|
||||
5
ios/NativeWidgetHelperSpec.h
Normal file
5
ios/NativeWidgetHelperSpec.h
Normal file
@ -0,0 +1,5 @@
|
||||
#import <React/RCTBridgeModule.h>
|
||||
|
||||
@protocol NativeWidgetHelperSpec <RCTBridgeModule>
|
||||
- (void)reloadAllWidgets;
|
||||
@end
|
||||
20
ios/Podfile
20
ios/Podfile
@ -6,7 +6,7 @@ def node_require(script)
|
||||
{paths: [process.argv[1]]},
|
||||
)", __dir__]).strip
|
||||
end
|
||||
ENV['RCT_NEW_ARCH_ENABLED'] = '0'
|
||||
ENV['RCT_NEW_ARCH_ENABLED'] = '1'
|
||||
min_ios_version_supported = '15.1'
|
||||
node_require('react-native/scripts/react_native_pods.rb')
|
||||
node_require('react-native-permissions/scripts/setup.rb')
|
||||
@ -52,6 +52,24 @@ post_install do |installer|
|
||||
:mac_catalyst_enabled => true,
|
||||
# :ccache_enabled => true
|
||||
)
|
||||
# Fix RCTDeprecation module map error with Xcode 16+/26+ (required for react-native-notifications).
|
||||
# The bridging header imports RCTBridgeModule.h which does
|
||||
# #import <RCTDeprecation/RCTDeprecation.h>. With -fmodules, Clang treats this
|
||||
# as a module import and builds a PCM, but PCH verification then fails with
|
||||
# "module 'RCTDeprecation' is not defined in any loaded module map file".
|
||||
# Fix: remove RCTDeprecation -fmodule-map-file from the consumer xcconfigs
|
||||
# so the import resolves as a plain textual include via HEADER_SEARCH_PATHS.
|
||||
# No code uses @import RCTDeprecation, so this is safe.
|
||||
installer.generated_aggregate_targets.each do |aggregate_target|
|
||||
aggregate_target.xcconfigs.each do |config_name, config|
|
||||
xcconfig_path = aggregate_target.xcconfig_path(config_name)
|
||||
content = File.read(xcconfig_path)
|
||||
# Order matters: match -Xcc variant first to avoid leaving a dangling -Xcc
|
||||
content.gsub!('-Xcc -fmodule-map-file="${PODS_ROOT}/Headers/Public/RCTDeprecation/RCTDeprecation.modulemap" ', '')
|
||||
content.gsub!('-fmodule-map-file="${PODS_ROOT}/Headers/Public/RCTDeprecation/RCTDeprecation.modulemap" ', '')
|
||||
File.write(xcconfig_path, content)
|
||||
end
|
||||
end
|
||||
installer.pods_project.targets.each do |target|
|
||||
target.build_configurations.each do |config|
|
||||
config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '15.1'
|
||||
|
||||
3125
ios/Podfile.lock
3125
ios/Podfile.lock
File diff suppressed because it is too large
Load Diff
10
ios/Shared/SizeClassEmitter.m
Normal file
10
ios/Shared/SizeClassEmitter.m
Normal file
@ -0,0 +1,10 @@
|
||||
#import <React/RCTBridgeModule.h>
|
||||
#import <React/RCTEventEmitter.h>
|
||||
|
||||
@interface RCT_EXTERN_MODULE(SizeClassEmitter, RCTEventEmitter)
|
||||
|
||||
RCT_EXTERN_METHOD(getCurrentSizeClass:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject)
|
||||
RCT_EXTERN_METHOD(emitSizeClassChange:(UIWindow *)window)
|
||||
RCT_EXTERN_METHOD(sharedInstance)
|
||||
|
||||
@end
|
||||
161
ios/Shared/SizeClassEmitter.swift
Normal file
161
ios/Shared/SizeClassEmitter.swift
Normal file
@ -0,0 +1,161 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
import React
|
||||
|
||||
@objc(SizeClassEmitter)
|
||||
class SizeClassEmitter: RCTEventEmitter {
|
||||
private enum SizeClassValue: Int {
|
||||
case compact = 0
|
||||
case regular = 1
|
||||
case large = 2
|
||||
}
|
||||
|
||||
private static var sharedEmitter = SizeClassEmitter()
|
||||
private var hasListeners = false
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
SizeClassEmitter.sharedEmitter = self
|
||||
}
|
||||
|
||||
override class func requiresMainQueueSetup() -> Bool {
|
||||
true
|
||||
}
|
||||
|
||||
override func supportedEvents() -> [String]! {
|
||||
["sizeClassDidChange"]
|
||||
}
|
||||
|
||||
// MARK: - Singleton access used by ObjC bridge
|
||||
|
||||
@objc func sharedInstance() -> SizeClassEmitter {
|
||||
SizeClassEmitter.sharedEmitter
|
||||
}
|
||||
|
||||
// MARK: - Public API exposed to JS
|
||||
|
||||
@objc func getCurrentSizeClass(_ resolve: @escaping RCTPromiseResolveBlock, rejecter reject: @escaping RCTPromiseRejectBlock) {
|
||||
DispatchQueue.main.async {
|
||||
guard let payload = self.buildPayload(window: nil) else {
|
||||
reject("size_class_unavailable", "Unable to read current size class", nil)
|
||||
return
|
||||
}
|
||||
resolve(payload)
|
||||
}
|
||||
}
|
||||
|
||||
@objc func emitSizeClassChange(_ window: UIWindow?) {
|
||||
DispatchQueue.main.async {
|
||||
self.sendUpdate(window: window, reason: "manual")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Listener lifecycle
|
||||
|
||||
override func startObserving() {
|
||||
hasListeners = true
|
||||
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(handlePotentialTraitChange),
|
||||
name: UIDevice.orientationDidChangeNotification,
|
||||
object: nil
|
||||
)
|
||||
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(handlePotentialTraitChange),
|
||||
name: UIApplication.didBecomeActiveNotification,
|
||||
object: nil
|
||||
)
|
||||
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(handlePotentialTraitChange),
|
||||
name: UIWindow.didBecomeKeyNotification,
|
||||
object: nil
|
||||
)
|
||||
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(handlePotentialTraitChange),
|
||||
name: UIWindow.didResignKeyNotification,
|
||||
object: nil
|
||||
)
|
||||
}
|
||||
|
||||
override func stopObserving() {
|
||||
hasListeners = false
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
}
|
||||
|
||||
// MARK: - Notification handling
|
||||
|
||||
@objc private func handlePotentialTraitChange() {
|
||||
sendUpdate(window: nil, reason: "notification")
|
||||
}
|
||||
|
||||
private func sendUpdate(window: UIWindow?, reason: String) {
|
||||
guard hasListeners else { return }
|
||||
guard let payload = buildPayload(window: window) else { return }
|
||||
|
||||
#if DEBUG
|
||||
NSLog("[SizeClassEmitter] Emitting update (%@): %@", reason, payload.description)
|
||||
#endif
|
||||
|
||||
sendEvent(withName: "sizeClassDidChange", body: payload)
|
||||
}
|
||||
|
||||
// MARK: - Payload construction
|
||||
|
||||
private func buildPayload(window: UIWindow?) -> [String: Any]? {
|
||||
guard let activeWindow = resolveWindow(window) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let traits = activeWindow.traitCollection
|
||||
let bounds = activeWindow.bounds
|
||||
|
||||
let horizontalClass = map(sizeClass: traits.horizontalSizeClass)
|
||||
let verticalClass = map(sizeClass: traits.verticalSizeClass)
|
||||
|
||||
// Preserve previous JS behavior: any non-Compact width is considered Large overall.
|
||||
let overallClass: SizeClassValue = horizontalClass == .compact ? .compact : .large
|
||||
|
||||
let orientation: String = bounds.width > bounds.height ? "landscape" : "portrait"
|
||||
let isLargeScreen = horizontalClass != .compact
|
||||
|
||||
return [
|
||||
"horizontal": horizontalClass.rawValue,
|
||||
"vertical": verticalClass.rawValue,
|
||||
"sizeClass": overallClass.rawValue,
|
||||
"orientation": orientation,
|
||||
"isLargeScreen": isLargeScreen,
|
||||
"width": Double(bounds.width),
|
||||
"height": Double(bounds.height),
|
||||
]
|
||||
}
|
||||
|
||||
private func map(sizeClass: UIUserInterfaceSizeClass) -> SizeClassValue {
|
||||
switch sizeClass {
|
||||
case .compact:
|
||||
return .compact
|
||||
case .regular:
|
||||
return .regular
|
||||
default:
|
||||
return .regular
|
||||
}
|
||||
}
|
||||
|
||||
private func resolveWindow(_ providedWindow: UIWindow?) -> UIWindow? {
|
||||
if let providedWindow {
|
||||
return providedWindow
|
||||
}
|
||||
|
||||
if let keyWindow = UIApplication.shared.windows.first(where: { $0.isKeyWindow }) {
|
||||
return keyWindow
|
||||
}
|
||||
|
||||
return UIApplication.shared.windows.first
|
||||
}
|
||||
}
|
||||
@ -27,5 +27,7 @@
|
||||
<key>NSExtensionPrincipalClass</key>
|
||||
<string>StickerBrowserViewController</string>
|
||||
</dict>
|
||||
<key>RCTNewArchEnabled</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@ -20,16 +20,6 @@
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
<string>com.apple.widgetkit-extension</string>
|
||||
</dict>
|
||||
<key>bugsnag</key>
|
||||
<dict>
|
||||
<key>apiKey</key>
|
||||
<string>17ba9059f676f1cc4f45d98182388b01</string>
|
||||
</dict>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
@ -64,5 +54,17 @@
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
<string>com.apple.widgetkit-extension</string>
|
||||
</dict>
|
||||
<key>RCTNewArchEnabled</key>
|
||||
<true/>
|
||||
<key>bugsnag</key>
|
||||
<dict>
|
||||
<key>apiKey</key>
|
||||
<string>17ba9059f676f1cc4f45d98182388b01</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user