Update
No files matched your search
@@ -0,0 +1,11 @@
|
|||||||
|
EXPO_PUBLIC_SUPABASE_URL=your_supabase_url
|
||||||
|
EXPO_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key
|
||||||
|
|
||||||
|
# Sentry error tracking (get DSN from https://sentry.io)
|
||||||
|
EXPO_PUBLIC_SENTRY_DSN=https://03a91f8a1b1ecdce2d5f38a4c23ca9d5@o4510613894070272.ingest.de.sentry.io/4510613896560720
|
||||||
|
|
||||||
|
# RevenueCat (for Apple subscriptions)
|
||||||
|
# Get your API key from https://app.revenuecat.com
|
||||||
|
EXPO_PUBLIC_REVENUECAT_IOS_API_KEY=your_revenuecat_ios_api_key
|
||||||
|
# Entitlement ID from RevenueCat dashboard (defaults to "pro" if not set)
|
||||||
|
EXPO_PUBLIC_REVENUECAT_ENTITLEMENT_ID=Sauci Pro
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# OSX
|
||||||
|
#
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Android/IntelliJ
|
||||||
|
#
|
||||||
|
build/
|
||||||
|
.idea
|
||||||
|
.gradle
|
||||||
|
local.properties
|
||||||
|
*.iml
|
||||||
|
*.hprof
|
||||||
|
.cxx/
|
||||||
|
|
||||||
|
# Bundle artifacts
|
||||||
|
*.jsbundle
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
apply plugin: "com.android.application"
|
||||||
|
apply plugin: "org.jetbrains.kotlin.android"
|
||||||
|
apply plugin: "com.facebook.react"
|
||||||
|
|
||||||
|
def projectRoot = rootDir.getAbsoluteFile().getParentFile().getAbsolutePath()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This is the configuration block to customize your React Native Android app.
|
||||||
|
* By default you don't need to apply any configuration, just uncomment the lines you need.
|
||||||
|
*/
|
||||||
|
react {
|
||||||
|
entryFile = file(["node", "-e", "require('expo/scripts/resolveAppEntry')", projectRoot, "android", "absolute"].execute(null, rootDir).text.trim())
|
||||||
|
reactNativeDir = new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim()).getParentFile().getAbsoluteFile()
|
||||||
|
hermesCommand = new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim()).getParentFile().getAbsolutePath() + "/sdks/hermesc/%OS-BIN%/hermesc"
|
||||||
|
codegenDir = new File(["node", "--print", "require.resolve('@react-native/codegen/package.json', { paths: [require.resolve('react-native/package.json')] })"].execute(null, rootDir).text.trim()).getParentFile().getAbsoluteFile()
|
||||||
|
|
||||||
|
// Use Expo CLI to bundle the app, this ensures the Metro config
|
||||||
|
// works correctly with Expo projects.
|
||||||
|
cliFile = new File(["node", "--print", "require.resolve('@expo/cli', { paths: [require.resolve('expo/package.json')] })"].execute(null, rootDir).text.trim())
|
||||||
|
bundleCommand = "export:embed"
|
||||||
|
|
||||||
|
/* Folders */
|
||||||
|
// The root of your project, i.e. where "package.json" lives. Default is '../..'
|
||||||
|
// root = file("../../")
|
||||||
|
// The folder where the react-native NPM package is. Default is ../../node_modules/react-native
|
||||||
|
// reactNativeDir = file("../../node_modules/react-native")
|
||||||
|
// The folder where the react-native Codegen package is. Default is ../../node_modules/@react-native/codegen
|
||||||
|
// codegenDir = file("../../node_modules/@react-native/codegen")
|
||||||
|
|
||||||
|
/* Variants */
|
||||||
|
// The list of variants to that are debuggable. For those we're going to
|
||||||
|
// skip the bundling of the JS bundle and the assets. By default is just 'debug'.
|
||||||
|
// If you add flavors like lite, prod, etc. you'll have to list your debuggableVariants.
|
||||||
|
// debuggableVariants = ["liteDebug", "prodDebug"]
|
||||||
|
|
||||||
|
/* Bundling */
|
||||||
|
// A list containing the node command and its flags. Default is just 'node'.
|
||||||
|
// nodeExecutableAndArgs = ["node"]
|
||||||
|
|
||||||
|
//
|
||||||
|
// The path to the CLI configuration file. Default is empty.
|
||||||
|
// bundleConfig = file(../rn-cli.config.js)
|
||||||
|
//
|
||||||
|
// The name of the generated asset file containing your JS bundle
|
||||||
|
// bundleAssetName = "MyApplication.android.bundle"
|
||||||
|
//
|
||||||
|
// The entry file for bundle generation. Default is 'index.android.js' or 'index.js'
|
||||||
|
// entryFile = file("../js/MyApplication.android.js")
|
||||||
|
//
|
||||||
|
// A list of extra flags to pass to the 'bundle' commands.
|
||||||
|
// See https://github.com/react-native-community/cli/blob/main/docs/commands.md#bundle
|
||||||
|
// extraPackagerArgs = []
|
||||||
|
|
||||||
|
/* Hermes Commands */
|
||||||
|
// The hermes compiler command to run. By default it is 'hermesc'
|
||||||
|
// hermesCommand = "$rootDir/my-custom-hermesc/bin/hermesc"
|
||||||
|
//
|
||||||
|
// The list of flags to pass to the Hermes compiler. By default is "-O", "-output-source-map"
|
||||||
|
// hermesFlags = ["-O", "-output-source-map"]
|
||||||
|
|
||||||
|
/* Autolinking */
|
||||||
|
autolinkLibrariesWithApp()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set this to true to Run Proguard on Release builds to minify the Java bytecode.
|
||||||
|
*/
|
||||||
|
def enableProguardInReleaseBuilds = (findProperty('android.enableProguardInReleaseBuilds') ?: false).toBoolean()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The preferred build flavor of JavaScriptCore (JSC)
|
||||||
|
*
|
||||||
|
* For example, to use the international variant, you can use:
|
||||||
|
* `def jscFlavor = 'org.webkit:android-jsc-intl:+'`
|
||||||
|
*
|
||||||
|
* The international variant includes ICU i18n library and necessary data
|
||||||
|
* allowing to use e.g. `Date.toLocaleString` and `String.localeCompare` that
|
||||||
|
* give correct results when using with locales other than en-US. Note that
|
||||||
|
* this variant is about 6MiB larger per architecture than default.
|
||||||
|
*/
|
||||||
|
def jscFlavor = 'org.webkit:android-jsc:+'
|
||||||
|
|
||||||
|
apply from: new File(["node", "--print", "require('path').dirname(require.resolve('@sentry/react-native/package.json'))"].execute().text.trim(), "sentry.gradle")
|
||||||
|
|
||||||
|
android {
|
||||||
|
ndkVersion rootProject.ext.ndkVersion
|
||||||
|
|
||||||
|
buildToolsVersion rootProject.ext.buildToolsVersion
|
||||||
|
compileSdk rootProject.ext.compileSdkVersion
|
||||||
|
|
||||||
|
namespace 'com.sauci.app'
|
||||||
|
defaultConfig {
|
||||||
|
applicationId 'com.sauci.app'
|
||||||
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
|
versionCode 1
|
||||||
|
versionName "1.0.0"
|
||||||
|
}
|
||||||
|
signingConfigs {
|
||||||
|
debug {
|
||||||
|
storeFile file('debug.keystore')
|
||||||
|
storePassword 'android'
|
||||||
|
keyAlias 'androiddebugkey'
|
||||||
|
keyPassword 'android'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
buildTypes {
|
||||||
|
debug {
|
||||||
|
signingConfig signingConfigs.debug
|
||||||
|
}
|
||||||
|
release {
|
||||||
|
// Caution! In production, you need to generate your own keystore file.
|
||||||
|
// see https://reactnative.dev/docs/signed-apk-android.
|
||||||
|
signingConfig signingConfigs.debug
|
||||||
|
shrinkResources (findProperty('android.enableShrinkResourcesInReleaseBuilds')?.toBoolean() ?: false)
|
||||||
|
minifyEnabled enableProguardInReleaseBuilds
|
||||||
|
proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
|
||||||
|
crunchPngs (findProperty('android.enablePngCrunchInReleaseBuilds')?.toBoolean() ?: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
packagingOptions {
|
||||||
|
jniLibs {
|
||||||
|
useLegacyPackaging (findProperty('expo.useLegacyPackaging')?.toBoolean() ?: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
androidResources {
|
||||||
|
ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:!CVS:!thumbs.db:!picasa.ini:!*~'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply static values from `gradle.properties` to the `android.packagingOptions`
|
||||||
|
// Accepts values in comma delimited lists, example:
|
||||||
|
// android.packagingOptions.pickFirsts=/LICENSE,**/picasa.ini
|
||||||
|
["pickFirsts", "excludes", "merges", "doNotStrip"].each { prop ->
|
||||||
|
// Split option: 'foo,bar' -> ['foo', 'bar']
|
||||||
|
def options = (findProperty("android.packagingOptions.$prop") ?: "").split(",");
|
||||||
|
// Trim all elements in place.
|
||||||
|
for (i in 0..<options.size()) options[i] = options[i].trim();
|
||||||
|
// `[] - ""` is essentially `[""].filter(Boolean)` removing all empty strings.
|
||||||
|
options -= ""
|
||||||
|
|
||||||
|
if (options.length > 0) {
|
||||||
|
println "android.packagingOptions.$prop += $options ($options.length)"
|
||||||
|
// Ex: android.packagingOptions.pickFirsts += '**/SCCS/**'
|
||||||
|
options.each {
|
||||||
|
android.packagingOptions[prop] += it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
// The version of react-native is set by the React Native Gradle Plugin
|
||||||
|
implementation("com.facebook.react:react-android")
|
||||||
|
|
||||||
|
def isGifEnabled = (findProperty('expo.gif.enabled') ?: "") == "true";
|
||||||
|
def isWebpEnabled = (findProperty('expo.webp.enabled') ?: "") == "true";
|
||||||
|
def isWebpAnimatedEnabled = (findProperty('expo.webp.animated') ?: "") == "true";
|
||||||
|
|
||||||
|
if (isGifEnabled) {
|
||||||
|
// For animated gif support
|
||||||
|
implementation("com.facebook.fresco:animated-gif:${reactAndroidLibs.versions.fresco.get()}")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isWebpEnabled) {
|
||||||
|
// For webp support
|
||||||
|
implementation("com.facebook.fresco:webpsupport:${reactAndroidLibs.versions.fresco.get()}")
|
||||||
|
if (isWebpAnimatedEnabled) {
|
||||||
|
// Animated webp support
|
||||||
|
implementation("com.facebook.fresco:animated-webp:${reactAndroidLibs.versions.fresco.get()}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hermesEnabled.toBoolean()) {
|
||||||
|
implementation("com.facebook.react:hermes-android")
|
||||||
|
} else {
|
||||||
|
implementation jscFlavor
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# Add project specific ProGuard rules here.
|
||||||
|
# By default, the flags in this file are appended to flags specified
|
||||||
|
# in /usr/local/Cellar/android-sdk/24.3.3/tools/proguard/proguard-android.txt
|
||||||
|
# You can edit the include path and order by changing the proguardFiles
|
||||||
|
# directive in build.gradle.
|
||||||
|
#
|
||||||
|
# For more details, see
|
||||||
|
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||||
|
|
||||||
|
# react-native-reanimated
|
||||||
|
-keep class com.swmansion.reanimated.** { *; }
|
||||||
|
-keep class com.facebook.react.turbomodule.** { *; }
|
||||||
|
|
||||||
|
# Add any project specific keep options here:
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
|
||||||
|
|
||||||
|
<application android:usesCleartextTraffic="true" tools:targetApi="28" tools:ignore="GoogleAppIndexingWarning" tools:replace="android:usesCleartextTraffic" />
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
|
||||||
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
|
||||||
|
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
|
||||||
|
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
|
||||||
|
<uses-permission android:name="android.permission.VIBRATE"/>
|
||||||
|
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
|
||||||
|
<queries>
|
||||||
|
<intent>
|
||||||
|
<action android:name="android.intent.action.VIEW"/>
|
||||||
|
<category android:name="android.intent.category.BROWSABLE"/>
|
||||||
|
<data android:scheme="https"/>
|
||||||
|
</intent>
|
||||||
|
</queries>
|
||||||
|
<application android:name=".MainApplication" android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round" android:allowBackup="true" android:theme="@style/AppTheme" android:supportsRtl="true" android:fullBackupContent="@xml/secure_store_backup_rules" android:dataExtractionRules="@xml/secure_store_data_extraction_rules">
|
||||||
|
<meta-data android:name="com.google.firebase.messaging.default_notification_color" android:resource="@color/notification_icon_color"/>
|
||||||
|
<meta-data android:name="com.google.firebase.messaging.default_notification_icon" android:resource="@drawable/notification_icon"/>
|
||||||
|
<meta-data android:name="expo.modules.notifications.default_notification_color" android:resource="@color/notification_icon_color"/>
|
||||||
|
<meta-data android:name="expo.modules.notifications.default_notification_icon" android:resource="@drawable/notification_icon"/>
|
||||||
|
<meta-data android:name="expo.modules.updates.ENABLED" android:value="false"/>
|
||||||
|
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_CHECK_ON_LAUNCH" android:value="ALWAYS"/>
|
||||||
|
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_LAUNCH_WAIT_MS" android:value="0"/>
|
||||||
|
<activity android:name=".MainActivity" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout|uiMode" android:launchMode="singleTask" android:windowSoftInputMode="adjustResize" android:theme="@style/Theme.App.SplashScreen" android:exported="true" android:screenOrientation="portrait">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN"/>
|
||||||
|
<category android:name="android.intent.category.LAUNCHER"/>
|
||||||
|
</intent-filter>
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW"/>
|
||||||
|
<category android:name="android.intent.category.DEFAULT"/>
|
||||||
|
<category android:name="android.intent.category.BROWSABLE"/>
|
||||||
|
<data android:scheme="sauci"/>
|
||||||
|
<data android:scheme="com.sauci.app"/>
|
||||||
|
<data android:scheme="exp+sauci"/>
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package com.sauci.app
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
|
|
||||||
|
import com.facebook.react.ReactActivity
|
||||||
|
import com.facebook.react.ReactActivityDelegate
|
||||||
|
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled
|
||||||
|
import com.facebook.react.defaults.DefaultReactActivityDelegate
|
||||||
|
|
||||||
|
import expo.modules.ReactActivityDelegateWrapper
|
||||||
|
|
||||||
|
class MainActivity : ReactActivity() {
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
// Set the theme to AppTheme BEFORE onCreate to support
|
||||||
|
// coloring the background, status bar, and navigation bar.
|
||||||
|
// This is required for expo-splash-screen.
|
||||||
|
setTheme(R.style.AppTheme);
|
||||||
|
super.onCreate(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the name of the main component registered from JavaScript. This is used to schedule
|
||||||
|
* rendering of the component.
|
||||||
|
*/
|
||||||
|
override fun getMainComponentName(): String = "main"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the instance of the [ReactActivityDelegate]. We use [DefaultReactActivityDelegate]
|
||||||
|
* which allows you to enable New Architecture with a single boolean flags [fabricEnabled]
|
||||||
|
*/
|
||||||
|
override fun createReactActivityDelegate(): ReactActivityDelegate {
|
||||||
|
return ReactActivityDelegateWrapper(
|
||||||
|
this,
|
||||||
|
BuildConfig.IS_NEW_ARCHITECTURE_ENABLED,
|
||||||
|
object : DefaultReactActivityDelegate(
|
||||||
|
this,
|
||||||
|
mainComponentName,
|
||||||
|
fabricEnabled
|
||||||
|
){})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Align the back button behavior with Android S
|
||||||
|
* where moving root activities to background instead of finishing activities.
|
||||||
|
* @see <a href="https://developer.android.com/reference/android/app/Activity#onBackPressed()">onBackPressed</a>
|
||||||
|
*/
|
||||||
|
override fun invokeDefaultOnBackPressed() {
|
||||||
|
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.R) {
|
||||||
|
if (!moveTaskToBack(false)) {
|
||||||
|
// For non-root activities, use the default implementation to finish them.
|
||||||
|
super.invokeDefaultOnBackPressed()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the default back button implementation on Android S
|
||||||
|
// because it's doing more than [Activity.moveTaskToBack] in fact.
|
||||||
|
super.invokeDefaultOnBackPressed()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package com.sauci.app
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.content.res.Configuration
|
||||||
|
|
||||||
|
import com.facebook.react.PackageList
|
||||||
|
import com.facebook.react.ReactApplication
|
||||||
|
import com.facebook.react.ReactNativeHost
|
||||||
|
import com.facebook.react.ReactPackage
|
||||||
|
import com.facebook.react.ReactHost
|
||||||
|
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.load
|
||||||
|
import com.facebook.react.defaults.DefaultReactNativeHost
|
||||||
|
import com.facebook.react.soloader.OpenSourceMergedSoMapping
|
||||||
|
import com.facebook.soloader.SoLoader
|
||||||
|
|
||||||
|
import expo.modules.ApplicationLifecycleDispatcher
|
||||||
|
import expo.modules.ReactNativeHostWrapper
|
||||||
|
|
||||||
|
class MainApplication : Application(), ReactApplication {
|
||||||
|
|
||||||
|
override val reactNativeHost: ReactNativeHost = ReactNativeHostWrapper(
|
||||||
|
this,
|
||||||
|
object : DefaultReactNativeHost(this) {
|
||||||
|
override fun getPackages(): List<ReactPackage> {
|
||||||
|
val packages = PackageList(this).packages
|
||||||
|
// Packages that cannot be autolinked yet can be added manually here, for example:
|
||||||
|
// packages.add(new MyReactNativePackage());
|
||||||
|
return packages
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getJSMainModuleName(): String = ".expo/.virtual-metro-entry"
|
||||||
|
|
||||||
|
override fun getUseDeveloperSupport(): Boolean = BuildConfig.DEBUG
|
||||||
|
|
||||||
|
override val isNewArchEnabled: Boolean = BuildConfig.IS_NEW_ARCHITECTURE_ENABLED
|
||||||
|
override val isHermesEnabled: Boolean = BuildConfig.IS_HERMES_ENABLED
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
override val reactHost: ReactHost
|
||||||
|
get() = ReactNativeHostWrapper.createReactHost(applicationContext, reactNativeHost)
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
ApplicationLifecycleDispatcher.onApplicationCreate(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onConfigurationChanged(newConfig: Configuration) {
|
||||||
|
super.onConfigurationChanged(newConfig)
|
||||||
|
ApplicationLifecycleDispatcher.onConfigurationChanged(this, newConfig)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 504 B |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 321 B |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 732 B |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 42 KiB |
@@ -0,0 +1,6 @@
|
|||||||
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<item android:drawable="@color/splashscreen_background"/>
|
||||||
|
<item>
|
||||||
|
<bitmap android:gravity="center" android:src="@drawable/splashscreen_logo"/>
|
||||||
|
</item>
|
||||||
|
</layer-list>
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Copyright (C) 2014 The Android Open Source Project
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
-->
|
||||||
|
<inset xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:insetLeft="@dimen/abc_edit_text_inset_horizontal_material"
|
||||||
|
android:insetRight="@dimen/abc_edit_text_inset_horizontal_material"
|
||||||
|
android:insetTop="@dimen/abc_edit_text_inset_top_material"
|
||||||
|
android:insetBottom="@dimen/abc_edit_text_inset_bottom_material"
|
||||||
|
>
|
||||||
|
|
||||||
|
<selector>
|
||||||
|
<!--
|
||||||
|
This file is a copy of abc_edit_text_material (https://bit.ly/3k8fX7I).
|
||||||
|
The item below with state_pressed="false" and state_focused="false" causes a NullPointerException.
|
||||||
|
NullPointerException:tempt to invoke virtual method 'android.graphics.drawable.Drawable android.graphics.drawable.Drawable$ConstantState.newDrawable(android.content.res.Resources)'
|
||||||
|
|
||||||
|
<item android:state_pressed="false" android:state_focused="false" android:drawable="@drawable/abc_textfield_default_mtrl_alpha"/>
|
||||||
|
|
||||||
|
For more info, see https://bit.ly/3CdLStv (react-native/pull/29452) and https://bit.ly/3nxOMoR.
|
||||||
|
-->
|
||||||
|
<item android:state_enabled="false" android:drawable="@drawable/abc_textfield_default_mtrl_alpha"/>
|
||||||
|
<item android:drawable="@drawable/abc_textfield_activated_mtrl_alpha"/>
|
||||||
|
</selector>
|
||||||
|
|
||||||
|
</inset>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/iconBackground"/>
|
||||||
|
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||||
|
</adaptive-icon>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/iconBackground"/>
|
||||||
|
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||||
|
</adaptive-icon>
|
||||||
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 865 B |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 463 B |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 928 B |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 8.5 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 8.5 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 8.5 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 8.1 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<resources/>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<resources>
|
||||||
|
<color name="splashscreen_background">#1a1a2e</color>
|
||||||
|
<color name="iconBackground">#1a1a2e</color>
|
||||||
|
<color name="colorPrimary">#023c69</color>
|
||||||
|
<color name="colorPrimaryDark">#1a1a2e</color>
|
||||||
|
<color name="notification_icon_color">#e94560</color>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<resources>
|
||||||
|
<string name="app_name">Sauci</string>
|
||||||
|
<string name="expo_splash_screen_resize_mode" translatable="false">contain</string>
|
||||||
|
<string name="expo_splash_screen_status_bar_translucent" translatable="false">false</string>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
<style name="AppTheme" parent="Theme.AppCompat.Light.NoActionBar">
|
||||||
|
<item name="android:textColor">@android:color/black</item>
|
||||||
|
<item name="android:editTextStyle">@style/ResetEditText</item>
|
||||||
|
<item name="android:editTextBackground">@drawable/rn_edit_text_material</item>
|
||||||
|
<item name="colorPrimary">@color/colorPrimary</item>
|
||||||
|
<item name="android:statusBarColor">#1a1a2e</item>
|
||||||
|
</style>
|
||||||
|
<style name="ResetEditText" parent="@android:style/Widget.EditText">
|
||||||
|
<item name="android:padding">0dp</item>
|
||||||
|
<item name="android:textColorHint">#c8c8c8</item>
|
||||||
|
<item name="android:textColor">@android:color/black</item>
|
||||||
|
</style>
|
||||||
|
<style name="Theme.App.SplashScreen" parent="AppTheme">
|
||||||
|
<item name="android:windowBackground">@drawable/ic_launcher_background</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||||
|
|
||||||
|
buildscript {
|
||||||
|
ext {
|
||||||
|
buildToolsVersion = findProperty('android.buildToolsVersion') ?: '35.0.0'
|
||||||
|
minSdkVersion = Integer.parseInt(findProperty('android.minSdkVersion') ?: '24')
|
||||||
|
compileSdkVersion = Integer.parseInt(findProperty('android.compileSdkVersion') ?: '35')
|
||||||
|
targetSdkVersion = Integer.parseInt(findProperty('android.targetSdkVersion') ?: '34')
|
||||||
|
kotlinVersion = findProperty('android.kotlinVersion') ?: '1.9.25'
|
||||||
|
|
||||||
|
ndkVersion = "26.1.10909125"
|
||||||
|
}
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
dependencies {
|
||||||
|
classpath('com.android.tools.build:gradle')
|
||||||
|
classpath('com.facebook.react:react-native-gradle-plugin')
|
||||||
|
classpath('org.jetbrains.kotlin:kotlin-gradle-plugin')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
apply plugin: "com.facebook.react.rootproject"
|
||||||
|
|
||||||
|
allprojects {
|
||||||
|
repositories {
|
||||||
|
maven {
|
||||||
|
// All of React Native (JS, Obj-C sources, Android binaries) is installed from npm
|
||||||
|
url(new File(['node', '--print', "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim(), '../android'))
|
||||||
|
}
|
||||||
|
maven {
|
||||||
|
// Android JSC is installed from npm
|
||||||
|
url(new File(['node', '--print', "require.resolve('jsc-android/package.json', { paths: [require.resolve('react-native/package.json')] })"].execute(null, rootDir).text.trim(), '../dist'))
|
||||||
|
}
|
||||||
|
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
maven { url 'https://www.jitpack.io' }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# Project-wide Gradle settings.
|
||||||
|
|
||||||
|
# IDE (e.g. Android Studio) users:
|
||||||
|
# Gradle settings configured through the IDE *will override*
|
||||||
|
# any settings specified in this file.
|
||||||
|
|
||||||
|
# For more details on how to configure your build environment visit
|
||||||
|
# http://www.gradle.org/docs/current/userguide/build_environment.html
|
||||||
|
|
||||||
|
# Specifies the JVM arguments used for the daemon process.
|
||||||
|
# The setting is particularly useful for tweaking memory settings.
|
||||||
|
# Default value: -Xmx512m -XX:MaxMetaspaceSize=256m
|
||||||
|
org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m
|
||||||
|
|
||||||
|
# When configured, Gradle will run in incubating parallel mode.
|
||||||
|
# This option should only be used with decoupled projects. More details, visit
|
||||||
|
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
|
||||||
|
# org.gradle.parallel=true
|
||||||
|
|
||||||
|
# AndroidX package structure to make it clearer which packages are bundled with the
|
||||||
|
# Android operating system, and which are packaged with your app's APK
|
||||||
|
# https://developer.android.com/topic/libraries/support-library/androidx-rn
|
||||||
|
android.useAndroidX=true
|
||||||
|
|
||||||
|
# Enable AAPT2 PNG crunching
|
||||||
|
android.enablePngCrunchInReleaseBuilds=true
|
||||||
|
|
||||||
|
# Use this property to specify which architecture you want to build.
|
||||||
|
# You can also override it from the CLI using
|
||||||
|
# ./gradlew <task> -PreactNativeArchitectures=x86_64
|
||||||
|
reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64
|
||||||
|
|
||||||
|
# Use this property to enable support to the new architecture.
|
||||||
|
# This will allow you to use TurboModules and the Fabric render in
|
||||||
|
# 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=true
|
||||||
|
|
||||||
|
# Use this property to enable or disable the Hermes JS engine.
|
||||||
|
# If set to false, you will be using JSC instead.
|
||||||
|
hermesEnabled=true
|
||||||
|
|
||||||
|
# Enable GIF support in React Native images (~200 B increase)
|
||||||
|
expo.gif.enabled=true
|
||||||
|
# Enable webp support in React Native images (~85 KB increase)
|
||||||
|
expo.webp.enabled=true
|
||||||
|
# Enable animated webp support (~3.4 MB increase)
|
||||||
|
# Disabled by default because iOS doesn't support animated webp
|
||||||
|
expo.webp.animated=false
|
||||||
|
|
||||||
|
# Enable network inspector
|
||||||
|
EX_DEV_CLIENT_NETWORK_INSPECTOR=true
|
||||||
|
|
||||||
|
# Use legacy packaging to compress native libraries in the resulting APK.
|
||||||
|
expo.useLegacyPackaging=false
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-all.zip
|
||||||
|
networkTimeout=10000
|
||||||
|
validateDistributionUrl=true
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
#
|
||||||
|
# Copyright © 2015-2021 the original authors.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: Apache-2.0
|
||||||
|
#
|
||||||
|
|
||||||
|
##############################################################################
|
||||||
|
#
|
||||||
|
# Gradle start up script for POSIX generated by Gradle.
|
||||||
|
#
|
||||||
|
# Important for running:
|
||||||
|
#
|
||||||
|
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||||
|
# noncompliant, but you have some other compliant shell such as ksh or
|
||||||
|
# bash, then to run this script, type that shell name before the whole
|
||||||
|
# command line, like:
|
||||||
|
#
|
||||||
|
# ksh Gradle
|
||||||
|
#
|
||||||
|
# Busybox and similar reduced shells will NOT work, because this script
|
||||||
|
# requires all of these POSIX shell features:
|
||||||
|
# * functions;
|
||||||
|
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||||
|
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||||
|
# * compound commands having a testable exit status, especially «case»;
|
||||||
|
# * various built-in commands including «command», «set», and «ulimit».
|
||||||
|
#
|
||||||
|
# Important for patching:
|
||||||
|
#
|
||||||
|
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||||
|
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||||
|
#
|
||||||
|
# The "traditional" practice of packing multiple parameters into a
|
||||||
|
# space-separated string is a well documented source of bugs and security
|
||||||
|
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||||
|
# options in "$@", and eventually passing that to Java.
|
||||||
|
#
|
||||||
|
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||||
|
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||||
|
# see the in-line comments for details.
|
||||||
|
#
|
||||||
|
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||||
|
# Darwin, MinGW, and NonStop.
|
||||||
|
#
|
||||||
|
# (3) This script is generated from the Groovy template
|
||||||
|
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||||
|
# within the Gradle project.
|
||||||
|
#
|
||||||
|
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||||
|
#
|
||||||
|
##############################################################################
|
||||||
|
|
||||||
|
# Attempt to set APP_HOME
|
||||||
|
|
||||||
|
# Resolve links: $0 may be a link
|
||||||
|
app_path=$0
|
||||||
|
|
||||||
|
# Need this for daisy-chained symlinks.
|
||||||
|
while
|
||||||
|
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||||
|
[ -h "$app_path" ]
|
||||||
|
do
|
||||||
|
ls=$( ls -ld "$app_path" )
|
||||||
|
link=${ls#*' -> '}
|
||||||
|
case $link in #(
|
||||||
|
/*) app_path=$link ;; #(
|
||||||
|
*) app_path=$APP_HOME$link ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# This is normally unused
|
||||||
|
# shellcheck disable=SC2034
|
||||||
|
APP_BASE_NAME=${0##*/}
|
||||||
|
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||||
|
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
|
||||||
|
' "$PWD" ) || exit
|
||||||
|
|
||||||
|
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||||
|
MAX_FD=maximum
|
||||||
|
|
||||||
|
warn () {
|
||||||
|
echo "$*"
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
die () {
|
||||||
|
echo
|
||||||
|
echo "$*"
|
||||||
|
echo
|
||||||
|
exit 1
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
# OS specific support (must be 'true' or 'false').
|
||||||
|
cygwin=false
|
||||||
|
msys=false
|
||||||
|
darwin=false
|
||||||
|
nonstop=false
|
||||||
|
case "$( uname )" in #(
|
||||||
|
CYGWIN* ) cygwin=true ;; #(
|
||||||
|
Darwin* ) darwin=true ;; #(
|
||||||
|
MSYS* | MINGW* ) msys=true ;; #(
|
||||||
|
NONSTOP* ) nonstop=true ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||||
|
|
||||||
|
|
||||||
|
# Determine the Java command to use to start the JVM.
|
||||||
|
if [ -n "$JAVA_HOME" ] ; then
|
||||||
|
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||||
|
# IBM's JDK on AIX uses strange locations for the executables
|
||||||
|
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||||
|
else
|
||||||
|
JAVACMD=$JAVA_HOME/bin/java
|
||||||
|
fi
|
||||||
|
if [ ! -x "$JAVACMD" ] ; then
|
||||||
|
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
JAVACMD=java
|
||||||
|
if ! command -v java >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Increase the maximum file descriptors if we can.
|
||||||
|
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||||
|
case $MAX_FD in #(
|
||||||
|
max*)
|
||||||
|
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
MAX_FD=$( ulimit -H -n ) ||
|
||||||
|
warn "Could not query maximum file descriptor limit"
|
||||||
|
esac
|
||||||
|
case $MAX_FD in #(
|
||||||
|
'' | soft) :;; #(
|
||||||
|
*)
|
||||||
|
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
ulimit -n "$MAX_FD" ||
|
||||||
|
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Collect all arguments for the java command, stacking in reverse order:
|
||||||
|
# * args from the command line
|
||||||
|
# * the main class name
|
||||||
|
# * -classpath
|
||||||
|
# * -D...appname settings
|
||||||
|
# * --module-path (only if needed)
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||||
|
|
||||||
|
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||||
|
if "$cygwin" || "$msys" ; then
|
||||||
|
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||||
|
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||||
|
|
||||||
|
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||||
|
|
||||||
|
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||||
|
for arg do
|
||||||
|
if
|
||||||
|
case $arg in #(
|
||||||
|
-*) false ;; # don't mess with options #(
|
||||||
|
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||||
|
[ -e "$t" ] ;; #(
|
||||||
|
*) false ;;
|
||||||
|
esac
|
||||||
|
then
|
||||||
|
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||||
|
fi
|
||||||
|
# Roll the args list around exactly as many times as the number of
|
||||||
|
# args, so each arg winds up back in the position where it started, but
|
||||||
|
# possibly modified.
|
||||||
|
#
|
||||||
|
# NB: a `for` loop captures its iteration list before it begins, so
|
||||||
|
# changing the positional parameters here affects neither the number of
|
||||||
|
# iterations, nor the values presented in `arg`.
|
||||||
|
shift # remove old arg
|
||||||
|
set -- "$@" "$arg" # push replacement arg
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||||
|
|
||||||
|
# Collect all arguments for the java command:
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||||
|
# and any embedded shellness will be escaped.
|
||||||
|
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||||
|
# treated as '${Hostname}' itself on the command line.
|
||||||
|
|
||||||
|
set -- \
|
||||||
|
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||||
|
-classpath "$CLASSPATH" \
|
||||||
|
org.gradle.wrapper.GradleWrapperMain \
|
||||||
|
"$@"
|
||||||
|
|
||||||
|
# Stop when "xargs" is not available.
|
||||||
|
if ! command -v xargs >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "xargs is not available"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Use "xargs" to parse quoted args.
|
||||||
|
#
|
||||||
|
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||||
|
#
|
||||||
|
# In Bash we could simply go:
|
||||||
|
#
|
||||||
|
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||||
|
# set -- "${ARGS[@]}" "$@"
|
||||||
|
#
|
||||||
|
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||||
|
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||||
|
# character that might be a shell metacharacter, then use eval to reverse
|
||||||
|
# that process (while maintaining the separation between arguments), and wrap
|
||||||
|
# the whole thing up as a single "set" statement.
|
||||||
|
#
|
||||||
|
# This will of course break if any of these variables contains a newline or
|
||||||
|
# an unmatched quote.
|
||||||
|
#
|
||||||
|
|
||||||
|
eval "set -- $(
|
||||||
|
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||||
|
xargs -n1 |
|
||||||
|
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||||
|
tr '\n' ' '
|
||||||
|
)" '"$@"'
|
||||||
|
|
||||||
|
exec "$JAVACMD" "$@"
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
@rem
|
||||||
|
@rem Copyright 2015 the original author or authors.
|
||||||
|
@rem
|
||||||
|
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
@rem you may not use this file except in compliance with the License.
|
||||||
|
@rem You may obtain a copy of the License at
|
||||||
|
@rem
|
||||||
|
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
@rem
|
||||||
|
@rem Unless required by applicable law or agreed to in writing, software
|
||||||
|
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
@rem See the License for the specific language governing permissions and
|
||||||
|
@rem limitations under the License.
|
||||||
|
@rem
|
||||||
|
@rem SPDX-License-Identifier: Apache-2.0
|
||||||
|
@rem
|
||||||
|
|
||||||
|
@if "%DEBUG%"=="" @echo off
|
||||||
|
@rem ##########################################################################
|
||||||
|
@rem
|
||||||
|
@rem Gradle startup script for Windows
|
||||||
|
@rem
|
||||||
|
@rem ##########################################################################
|
||||||
|
|
||||||
|
@rem Set local scope for the variables with windows NT shell
|
||||||
|
if "%OS%"=="Windows_NT" setlocal
|
||||||
|
|
||||||
|
set DIRNAME=%~dp0
|
||||||
|
if "%DIRNAME%"=="" set DIRNAME=.
|
||||||
|
@rem This is normally unused
|
||||||
|
set APP_BASE_NAME=%~n0
|
||||||
|
set APP_HOME=%DIRNAME%
|
||||||
|
|
||||||
|
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||||
|
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||||
|
|
||||||
|
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||||
|
|
||||||
|
@rem Find java.exe
|
||||||
|
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||||
|
|
||||||
|
set JAVA_EXE=java.exe
|
||||||
|
%JAVA_EXE% -version >NUL 2>&1
|
||||||
|
if %ERRORLEVEL% equ 0 goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:findJavaFromJavaHome
|
||||||
|
set JAVA_HOME=%JAVA_HOME:"=%
|
||||||
|
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||||
|
|
||||||
|
if exist "%JAVA_EXE%" goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:execute
|
||||||
|
@rem Setup the command line
|
||||||
|
|
||||||
|
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||||
|
|
||||||
|
|
||||||
|
@rem Execute Gradle
|
||||||
|
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||||
|
|
||||||
|
:end
|
||||||
|
@rem End local scope for the variables with windows NT shell
|
||||||
|
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||||
|
|
||||||
|
:fail
|
||||||
|
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||||
|
rem the _cmd.exe /c_ return code!
|
||||||
|
set EXIT_CODE=%ERRORLEVEL%
|
||||||
|
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||||
|
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||||
|
exit /b %EXIT_CODE%
|
||||||
|
|
||||||
|
:mainEnd
|
||||||
|
if "%OS%"=="Windows_NT" endlocal
|
||||||
|
|
||||||
|
:omega
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
defaults.url=https://sentry.io/
|
||||||
|
defaults.org=molten-prints
|
||||||
|
defaults.project=sauci
|
||||||
|
# Using SENTRY_AUTH_TOKEN environment variable
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
pluginManagement {
|
||||||
|
includeBuild(new File(["node", "--print", "require.resolve('@react-native/gradle-plugin/package.json', { paths: [require.resolve('react-native/package.json')] })"].execute(null, rootDir).text.trim()).getParentFile().toString())
|
||||||
|
}
|
||||||
|
plugins { id("com.facebook.react.settings") }
|
||||||
|
|
||||||
|
extensions.configure(com.facebook.react.ReactSettingsExtension) { ex ->
|
||||||
|
if (System.getenv('EXPO_USE_COMMUNITY_AUTOLINKING') == '1') {
|
||||||
|
ex.autolinkLibrariesFromCommand()
|
||||||
|
} else {
|
||||||
|
def command = [
|
||||||
|
'node',
|
||||||
|
'--no-warnings',
|
||||||
|
'--eval',
|
||||||
|
'require(require.resolve(\'expo-modules-autolinking\', { paths: [require.resolve(\'expo/package.json\')] }))(process.argv.slice(1))',
|
||||||
|
'react-native-config',
|
||||||
|
'--json',
|
||||||
|
'--platform',
|
||||||
|
'android'
|
||||||
|
].toList()
|
||||||
|
ex.autolinkLibrariesFromCommand(command)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rootProject.name = 'Sauci'
|
||||||
|
|
||||||
|
dependencyResolutionManagement {
|
||||||
|
versionCatalogs {
|
||||||
|
reactAndroidLibs {
|
||||||
|
from(files(new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim(), "../gradle/libs.versions.toml")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
apply from: new File(["node", "--print", "require.resolve('expo/package.json')"].execute(null, rootDir).text.trim(), "../scripts/autolinking.gradle");
|
||||||
|
useExpoModules()
|
||||||
|
|
||||||
|
include ':app'
|
||||||
|
includeBuild(new File(["node", "--print", "require.resolve('@react-native/gradle-plugin/package.json', { paths: [require.resolve('react-native/package.json')] })"].execute(null, rootDir).text.trim()).getParentFile())
|
||||||
@@ -1,64 +1,74 @@
|
|||||||
{
|
{
|
||||||
"expo": {
|
"expo": {
|
||||||
"name": "Sauci",
|
"name": "Sauci",
|
||||||
"slug": "sauci",
|
"slug": "sauci",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"orientation": "portrait",
|
"orientation": "portrait",
|
||||||
"icon": "./assets/icon.png",
|
"icon": "./assets/icon.png",
|
||||||
"scheme": "sauci",
|
"scheme": "sauci",
|
||||||
"userInterfaceStyle": "automatic",
|
"userInterfaceStyle": "automatic",
|
||||||
"newArchEnabled": true,
|
"newArchEnabled": false,
|
||||||
"splash": {
|
"splash": {
|
||||||
"image": "./assets/splash-icon.png",
|
"image": "./assets/splash-icon.png",
|
||||||
"resizeMode": "contain",
|
"resizeMode": "contain",
|
||||||
"backgroundColor": "#1a1a2e"
|
"backgroundColor": "#1a1a2e"
|
||||||
},
|
},
|
||||||
"ios": {
|
"ios": {
|
||||||
"supportsTablet": false,
|
"supportsTablet": false,
|
||||||
"bundleIdentifier": "com.sauci.app",
|
"bundleIdentifier": "com.sauci.app",
|
||||||
"infoPlist": {
|
"usesAppleSignIn": true,
|
||||||
"UIBackgroundModes": [
|
"infoPlist": {
|
||||||
"remote-notification"
|
"UIBackgroundModes": [
|
||||||
]
|
"remote-notification"
|
||||||
}
|
]
|
||||||
},
|
}
|
||||||
"android": {
|
},
|
||||||
"adaptiveIcon": {
|
"android": {
|
||||||
"foregroundImage": "./assets/adaptive-icon.png",
|
"adaptiveIcon": {
|
||||||
"backgroundColor": "#1a1a2e"
|
"foregroundImage": "./assets/adaptive-icon.png",
|
||||||
},
|
"backgroundColor": "#1a1a2e"
|
||||||
"package": "com.sauci.app",
|
},
|
||||||
"permissions": [
|
"package": "com.sauci.app",
|
||||||
"RECEIVE_BOOT_COMPLETED",
|
"permissions": [
|
||||||
"VIBRATE"
|
"RECEIVE_BOOT_COMPLETED",
|
||||||
]
|
"VIBRATE"
|
||||||
},
|
]
|
||||||
"web": {
|
},
|
||||||
"bundler": "metro",
|
"web": {
|
||||||
"output": "static",
|
"bundler": "metro",
|
||||||
"favicon": "./assets/favicon.png"
|
"output": "static",
|
||||||
},
|
"favicon": "./assets/favicon.png"
|
||||||
"plugins": [
|
},
|
||||||
"expo-router",
|
"plugins": [
|
||||||
[
|
"expo-router",
|
||||||
"expo-notifications",
|
"expo-apple-authentication",
|
||||||
{
|
[
|
||||||
"icon": "./assets/notification-icon.png",
|
"expo-notifications",
|
||||||
"color": "#e94560"
|
{
|
||||||
}
|
"icon": "./assets/notification-icon.png",
|
||||||
],
|
"color": "#e94560"
|
||||||
"expo-secure-store"
|
}
|
||||||
],
|
],
|
||||||
"experiments": {
|
"expo-secure-store",
|
||||||
"typedRoutes": true
|
[
|
||||||
},
|
"@sentry/react-native/expo",
|
||||||
"extra": {
|
{
|
||||||
"router": {
|
"organization": "molten-prints",
|
||||||
"origin": false
|
"project": "sauci"
|
||||||
},
|
}
|
||||||
"eas": {
|
]
|
||||||
"projectId": "REPLACE_WITH_EAS_PROJECT_ID"
|
],
|
||||||
}
|
"experiments": {
|
||||||
}
|
"typedRoutes": true
|
||||||
}
|
},
|
||||||
}
|
"extra": {
|
||||||
|
"router": {
|
||||||
|
"origin": false
|
||||||
|
},
|
||||||
|
"eas": {
|
||||||
|
"projectId": "807ca0af-e9bd-40e8-a658-f3382072c724"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"owner": "jjbrunton-apps"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,15 @@
|
|||||||
import { Tabs, Redirect } from "expo-router";
|
import { Tabs, useRouter } from "expo-router";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { View, ActivityIndicator, StyleSheet, Platform } from "react-native";
|
import { View, ActivityIndicator, StyleSheet, Platform, Modal, Text, Pressable, Animated, AppState } from "react-native";
|
||||||
import { BlurView } from "expo-blur";
|
import { BlurView } from "expo-blur";
|
||||||
import { useAuthStore, useMatchStore } from "../../src/store";
|
import { useEffect, useState, useCallback, useRef } from "react";
|
||||||
import { colors, blur } from "../../src/theme";
|
import { useAuthStore, useMatchStore, useMessageStore, useSubscriptionStore } from "../../src/store";
|
||||||
|
import { colors, blur, radius, spacing, typography, shadows } from "../../src/theme";
|
||||||
|
import { supabase } from "../../src/lib/supabase";
|
||||||
|
import type { MatchWithQuestion } from "../../src/types";
|
||||||
|
import type { Database } from "../../src/types/supabase";
|
||||||
|
|
||||||
|
type Message = Database["public"]["Tables"]["messages"]["Row"];
|
||||||
|
|
||||||
function TabBarBackground() {
|
function TabBarBackground() {
|
||||||
if (Platform.OS === 'ios') {
|
if (Platform.OS === 'ios') {
|
||||||
@@ -19,8 +25,199 @@ function TabBarBackground() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function AppLayout() {
|
export default function AppLayout() {
|
||||||
const { isAuthenticated, isLoading } = useAuthStore();
|
const router = useRouter();
|
||||||
const { newMatchesCount } = useMatchStore();
|
const { isAuthenticated, isLoading, user, signOut } = useAuthStore();
|
||||||
|
const { newMatchesCount, addMatch } = useMatchStore();
|
||||||
|
const { unreadCount, lastMessage, fetchUnreadCount, addMessage, clearLastMessage } = useMessageStore();
|
||||||
|
const { initializeRevenueCat } = useSubscriptionStore();
|
||||||
|
const [matchNotification, setMatchNotification] = useState<MatchWithQuestion | null>(null);
|
||||||
|
const messageToastAnim = useRef(new Animated.Value(-100)).current;
|
||||||
|
const messageTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
|
// Redirect to login when not authenticated
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLoading && !isAuthenticated) {
|
||||||
|
router.replace("/(auth)/login");
|
||||||
|
}
|
||||||
|
}, [isLoading, isAuthenticated, router]);
|
||||||
|
|
||||||
|
// Check session validity on mount and when app comes to foreground
|
||||||
|
useEffect(() => {
|
||||||
|
const checkSession = async () => {
|
||||||
|
const { data: { session } } = await supabase.auth.getSession();
|
||||||
|
if (!session) {
|
||||||
|
// No valid session - sign out to clear local state
|
||||||
|
signOut();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check immediately
|
||||||
|
checkSession();
|
||||||
|
|
||||||
|
// Check when app comes to foreground
|
||||||
|
const subscription = AppState.addEventListener('change', (nextAppState) => {
|
||||||
|
if (nextAppState === 'active') {
|
||||||
|
checkSession();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
subscription.remove();
|
||||||
|
};
|
||||||
|
}, [signOut]);
|
||||||
|
|
||||||
|
// Fetch unread message count on mount
|
||||||
|
useEffect(() => {
|
||||||
|
if (user?.id) {
|
||||||
|
fetchUnreadCount();
|
||||||
|
}
|
||||||
|
}, [user?.id, fetchUnreadCount]);
|
||||||
|
|
||||||
|
// Initialize RevenueCat for subscription management
|
||||||
|
useEffect(() => {
|
||||||
|
if (user?.id && Platform.OS === "ios") {
|
||||||
|
initializeRevenueCat(user.id);
|
||||||
|
}
|
||||||
|
}, [user?.id, initializeRevenueCat]);
|
||||||
|
|
||||||
|
// Subscribe to realtime match notifications
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user?.couple_id) return;
|
||||||
|
|
||||||
|
const channel = supabase
|
||||||
|
.channel(`matches:${user.couple_id}`)
|
||||||
|
.on(
|
||||||
|
"postgres_changes",
|
||||||
|
{
|
||||||
|
event: "INSERT",
|
||||||
|
schema: "public",
|
||||||
|
table: "matches",
|
||||||
|
filter: `couple_id=eq.${user.couple_id}`,
|
||||||
|
},
|
||||||
|
async (payload) => {
|
||||||
|
// Fetch the full match with question details
|
||||||
|
const { data: matchWithQuestion } = await supabase
|
||||||
|
.from("matches")
|
||||||
|
.select(`
|
||||||
|
*,
|
||||||
|
question:questions(*)
|
||||||
|
`)
|
||||||
|
.eq("id", payload.new.id)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (matchWithQuestion) {
|
||||||
|
// Add to store and show notification
|
||||||
|
addMatch(matchWithQuestion);
|
||||||
|
setMatchNotification(matchWithQuestion);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.subscribe();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
supabase.removeChannel(channel);
|
||||||
|
};
|
||||||
|
}, [user?.couple_id, addMatch]);
|
||||||
|
|
||||||
|
// Subscribe to realtime message notifications
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user?.couple_id) return;
|
||||||
|
|
||||||
|
const channel = supabase
|
||||||
|
.channel(`messages:${user.couple_id}`)
|
||||||
|
.on(
|
||||||
|
"postgres_changes",
|
||||||
|
{
|
||||||
|
event: "INSERT",
|
||||||
|
schema: "public",
|
||||||
|
table: "messages",
|
||||||
|
},
|
||||||
|
async (payload) => {
|
||||||
|
const newMessage = payload.new as Message;
|
||||||
|
|
||||||
|
// Only process messages from partner
|
||||||
|
if (newMessage.user_id === user.id) return;
|
||||||
|
|
||||||
|
// Mark as delivered (message received by device)
|
||||||
|
if (!newMessage.delivered_at) {
|
||||||
|
await supabase
|
||||||
|
.from("messages")
|
||||||
|
.update({ delivered_at: new Date().toISOString() })
|
||||||
|
.eq("id", newMessage.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch the match details for context
|
||||||
|
const { data: match } = await supabase
|
||||||
|
.from("matches")
|
||||||
|
.select("id, question:questions(text)")
|
||||||
|
.eq("id", newMessage.match_id)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (match) {
|
||||||
|
addMessage({
|
||||||
|
...newMessage,
|
||||||
|
delivered_at: newMessage.delivered_at || new Date().toISOString(),
|
||||||
|
match: match as { id: string; question: { text: string } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.subscribe();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
supabase.removeChannel(channel);
|
||||||
|
};
|
||||||
|
}, [user?.couple_id, user?.id, addMessage]);
|
||||||
|
|
||||||
|
// Animate message toast when lastMessage changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (lastMessage) {
|
||||||
|
// Clear any existing timeout
|
||||||
|
if (messageTimeoutRef.current) {
|
||||||
|
clearTimeout(messageTimeoutRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slide in
|
||||||
|
Animated.spring(messageToastAnim, {
|
||||||
|
toValue: 0,
|
||||||
|
useNativeDriver: true,
|
||||||
|
tension: 80,
|
||||||
|
friction: 10,
|
||||||
|
}).start();
|
||||||
|
|
||||||
|
// Auto-dismiss after 4 seconds
|
||||||
|
messageTimeoutRef.current = setTimeout(() => {
|
||||||
|
dismissMessageToast();
|
||||||
|
}, 4000);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (messageTimeoutRef.current) {
|
||||||
|
clearTimeout(messageTimeoutRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [lastMessage]);
|
||||||
|
|
||||||
|
const dismissNotification = useCallback(() => {
|
||||||
|
setMatchNotification(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const dismissMessageToast = useCallback(() => {
|
||||||
|
Animated.timing(messageToastAnim, {
|
||||||
|
toValue: -100,
|
||||||
|
duration: 200,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}).start(() => {
|
||||||
|
clearLastMessage();
|
||||||
|
});
|
||||||
|
}, [messageToastAnim, clearLastMessage]);
|
||||||
|
|
||||||
|
const handleMessageToastPress = useCallback(() => {
|
||||||
|
if (lastMessage?.match_id) {
|
||||||
|
dismissMessageToast();
|
||||||
|
router.push(`/(app)/chat/${lastMessage.match_id}`);
|
||||||
|
}
|
||||||
|
}, [lastMessage, dismissMessageToast, router]);
|
||||||
|
|
||||||
// Show loading state while checking authentication
|
// Show loading state while checking authentication
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
@@ -31,98 +228,164 @@ export default function AppLayout() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Redirect to login if not authenticated
|
// Show loading while checking auth or redirecting
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
return <Redirect href="/(auth)/login" />;
|
return (
|
||||||
|
<View style={styles.loadingContainer}>
|
||||||
|
<ActivityIndicator size="large" color={colors.primary} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tabs
|
<>
|
||||||
screenOptions={{
|
<Tabs
|
||||||
headerShown: false,
|
screenOptions={{
|
||||||
tabBarBackground: () => <TabBarBackground />,
|
headerShown: false,
|
||||||
tabBarStyle: {
|
tabBarBackground: () => <TabBarBackground />,
|
||||||
position: 'absolute',
|
tabBarStyle: {
|
||||||
backgroundColor: 'transparent',
|
position: 'absolute',
|
||||||
borderTopColor: colors.glass.border,
|
backgroundColor: 'transparent',
|
||||||
borderTopWidth: 1,
|
borderTopColor: colors.glass.border,
|
||||||
paddingTop: 8,
|
borderTopWidth: 1,
|
||||||
paddingBottom: Platform.OS === 'ios' ? 28 : 8,
|
paddingTop: 8,
|
||||||
height: Platform.OS === 'ios' ? 88 : 64,
|
paddingBottom: Platform.OS === 'ios' ? 28 : 8,
|
||||||
elevation: 0,
|
height: Platform.OS === 'ios' ? 88 : 64,
|
||||||
},
|
elevation: 0,
|
||||||
tabBarActiveTintColor: colors.primary,
|
},
|
||||||
tabBarInactiveTintColor: colors.textTertiary,
|
tabBarActiveTintColor: colors.primary,
|
||||||
tabBarLabelStyle: {
|
tabBarInactiveTintColor: colors.textTertiary,
|
||||||
fontSize: 11,
|
tabBarLabelStyle: {
|
||||||
fontWeight: "600",
|
fontSize: 11,
|
||||||
},
|
fontWeight: "600",
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Tabs.Screen
|
|
||||||
name="index"
|
|
||||||
options={{
|
|
||||||
title: "Home",
|
|
||||||
tabBarIcon: ({ color, size }) => (
|
|
||||||
<Ionicons name="home" size={size} color={color} />
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Tabs.Screen
|
|
||||||
name="packs"
|
|
||||||
options={{
|
|
||||||
title: "Packs",
|
|
||||||
tabBarIcon: ({ color, size }) => (
|
|
||||||
<Ionicons name="layers" size={size} color={color} />
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Tabs.Screen
|
|
||||||
name="swipe"
|
|
||||||
options={{
|
|
||||||
title: "Play",
|
|
||||||
tabBarIcon: ({ color, size }) => (
|
|
||||||
<Ionicons name="flame" size={size} color={color} />
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Tabs.Screen
|
|
||||||
name="matches"
|
|
||||||
options={{
|
|
||||||
title: "Matches",
|
|
||||||
tabBarIcon: ({ color, size }) => (
|
|
||||||
<Ionicons name="heart" size={size} color={color} />
|
|
||||||
),
|
|
||||||
tabBarBadge: newMatchesCount > 0 ? newMatchesCount : undefined,
|
|
||||||
tabBarBadgeStyle: {
|
|
||||||
backgroundColor: colors.primary,
|
|
||||||
fontSize: 10,
|
|
||||||
fontWeight: '600',
|
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
<Tabs.Screen
|
<Tabs.Screen
|
||||||
name="profile"
|
name="index"
|
||||||
options={{
|
options={{
|
||||||
title: "Profile",
|
title: "Home",
|
||||||
tabBarIcon: ({ color, size }) => (
|
tabBarIcon: ({ color, size }) => (
|
||||||
<Ionicons name="person" size={size} color={color} />
|
<Ionicons name="home" size={size} color={color} />
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Tabs.Screen
|
<Tabs.Screen
|
||||||
name="pairing"
|
name="packs"
|
||||||
options={{
|
options={{
|
||||||
href: null, // Hide from tab bar
|
title: "Packs",
|
||||||
}}
|
tabBarIcon: ({ color, size }) => (
|
||||||
/>
|
<Ionicons name="layers" size={size} color={color} />
|
||||||
<Tabs.Screen
|
),
|
||||||
name="chat/[id]"
|
}}
|
||||||
options={{
|
/>
|
||||||
href: null, // Hide from tab bar
|
<Tabs.Screen
|
||||||
}}
|
name="swipe"
|
||||||
/>
|
options={{
|
||||||
</Tabs>
|
title: "Play",
|
||||||
|
tabBarIcon: ({ color, size }) => (
|
||||||
|
<Ionicons name="flame" size={size} color={color} />
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="matches"
|
||||||
|
options={{
|
||||||
|
title: "Matches",
|
||||||
|
tabBarIcon: ({ color, size }) => (
|
||||||
|
<Ionicons name="heart" size={size} color={color} />
|
||||||
|
),
|
||||||
|
tabBarBadge: (newMatchesCount + unreadCount) > 0 ? (newMatchesCount + unreadCount) : undefined,
|
||||||
|
tabBarBadgeStyle: {
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="profile"
|
||||||
|
options={{
|
||||||
|
title: "Profile",
|
||||||
|
tabBarIcon: ({ color, size }) => (
|
||||||
|
<Ionicons name="person" size={size} color={color} />
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="pairing"
|
||||||
|
options={{
|
||||||
|
href: null, // Hide from tab bar
|
||||||
|
tabBarStyle: { display: 'none' }, // Hide tab bar on pairing screen
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="chat/[id]"
|
||||||
|
options={{
|
||||||
|
href: null, // Hide from tab bar
|
||||||
|
tabBarStyle: { display: 'none' }, // Hide tab bar on chat screen
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
{/* Match Notification Modal */}
|
||||||
|
<Modal
|
||||||
|
visible={!!matchNotification}
|
||||||
|
transparent
|
||||||
|
animationType="fade"
|
||||||
|
onRequestClose={dismissNotification}
|
||||||
|
>
|
||||||
|
<Pressable style={styles.modalOverlay} onPress={dismissNotification}>
|
||||||
|
<View style={styles.modalContent}>
|
||||||
|
<View style={styles.modalHeader}>
|
||||||
|
<Ionicons name="heart" size={48} color={colors.primary} />
|
||||||
|
<Text style={styles.modalTitle}>It's a Match!</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.modalSubtitle}>You both agreed on:</Text>
|
||||||
|
<Text style={styles.modalQuestion}>
|
||||||
|
"{matchNotification?.question?.text}"
|
||||||
|
</Text>
|
||||||
|
<View style={styles.matchTypeBadge}>
|
||||||
|
<Text style={styles.matchTypeText}>
|
||||||
|
{matchNotification?.match_type === 'yes_yes' ? 'YES + YES' :
|
||||||
|
matchNotification?.match_type === 'yes_maybe' ? 'YES + MAYBE' :
|
||||||
|
'MAYBE + MAYBE'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Pressable style={styles.dismissButton} onPress={dismissNotification}>
|
||||||
|
<Text style={styles.dismissButtonText}>Nice!</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* Message Toast Notification */}
|
||||||
|
{lastMessage && (
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.messageToast,
|
||||||
|
{ transform: [{ translateY: messageToastAnim }] },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Pressable style={styles.messageToastContent} onPress={handleMessageToastPress}>
|
||||||
|
<View style={styles.messageToastIcon}>
|
||||||
|
<Ionicons name="chatbubble" size={20} color={colors.primary} />
|
||||||
|
</View>
|
||||||
|
<View style={styles.messageToastText}>
|
||||||
|
<Text style={styles.messageToastTitle} numberOfLines={1}>
|
||||||
|
New message
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.messageToastBody} numberOfLines={1}>
|
||||||
|
{lastMessage.content || "Sent an image"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Pressable onPress={dismissMessageToast} hitSlop={8}>
|
||||||
|
<Ionicons name="close" size={20} color={colors.textTertiary} />
|
||||||
|
</Pressable>
|
||||||
|
</Pressable>
|
||||||
|
</Animated.View>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,4 +396,106 @@ const styles = StyleSheet.create({
|
|||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
backgroundColor: colors.background,
|
backgroundColor: colors.background,
|
||||||
},
|
},
|
||||||
|
modalOverlay: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.7)',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: spacing.lg,
|
||||||
|
},
|
||||||
|
modalContent: {
|
||||||
|
backgroundColor: colors.backgroundLight,
|
||||||
|
borderRadius: radius.xl,
|
||||||
|
padding: spacing.xl,
|
||||||
|
alignItems: 'center',
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: 340,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.glass.border,
|
||||||
|
...shadows.lg,
|
||||||
|
},
|
||||||
|
modalHeader: {
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: spacing.lg,
|
||||||
|
},
|
||||||
|
modalTitle: {
|
||||||
|
...typography.title1,
|
||||||
|
color: colors.text,
|
||||||
|
marginTop: spacing.md,
|
||||||
|
},
|
||||||
|
modalSubtitle: {
|
||||||
|
...typography.body,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
marginBottom: spacing.sm,
|
||||||
|
},
|
||||||
|
modalQuestion: {
|
||||||
|
...typography.headline,
|
||||||
|
color: colors.text,
|
||||||
|
textAlign: 'center',
|
||||||
|
marginBottom: spacing.lg,
|
||||||
|
},
|
||||||
|
matchTypeBadge: {
|
||||||
|
backgroundColor: colors.primaryLight,
|
||||||
|
paddingHorizontal: spacing.md,
|
||||||
|
paddingVertical: spacing.xs,
|
||||||
|
borderRadius: radius.full,
|
||||||
|
marginBottom: spacing.lg,
|
||||||
|
},
|
||||||
|
matchTypeText: {
|
||||||
|
...typography.caption1,
|
||||||
|
color: colors.primary,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
dismissButton: {
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
paddingHorizontal: spacing.xl,
|
||||||
|
paddingVertical: spacing.md,
|
||||||
|
borderRadius: radius.lg,
|
||||||
|
minWidth: 120,
|
||||||
|
},
|
||||||
|
dismissButtonText: {
|
||||||
|
...typography.headline,
|
||||||
|
color: colors.text,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
messageToast: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: Platform.OS === 'ios' ? 50 : 20,
|
||||||
|
left: spacing.md,
|
||||||
|
right: spacing.md,
|
||||||
|
zIndex: 1000,
|
||||||
|
},
|
||||||
|
messageToastContent: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: colors.backgroundLight,
|
||||||
|
borderRadius: radius.lg,
|
||||||
|
padding: spacing.md,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.glass.border,
|
||||||
|
...shadows.lg,
|
||||||
|
},
|
||||||
|
messageToastIcon: {
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: 20,
|
||||||
|
backgroundColor: colors.primaryLight,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginRight: spacing.md,
|
||||||
|
},
|
||||||
|
messageToastText: {
|
||||||
|
flex: 1,
|
||||||
|
marginRight: spacing.sm,
|
||||||
|
},
|
||||||
|
messageToastTitle: {
|
||||||
|
...typography.subhead,
|
||||||
|
color: colors.text,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
messageToastBody: {
|
||||||
|
...typography.caption1,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
@@ -1,18 +1,19 @@
|
|||||||
import { useState, useEffect, useRef } from "react";
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
import { View, Text, StyleSheet, FlatList, TextInput, TouchableOpacity, KeyboardAvoidingView, Platform, Image, ActivityIndicator, Alert } from "react-native";
|
import { View, Text, StyleSheet, FlatList, TextInput, TouchableOpacity, KeyboardAvoidingView, Platform, Image, ActivityIndicator, Alert, Modal, Dimensions } from "react-native";
|
||||||
import { useLocalSearchParams, useRouter, Stack } from "expo-router";
|
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||||
|
import { useFocusEffect } from "@react-navigation/native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { BlurView } from "expo-blur";
|
|
||||||
import { LinearGradient } from "expo-linear-gradient";
|
import { LinearGradient } from "expo-linear-gradient";
|
||||||
import Animated, { FadeIn, FadeInUp } from "react-native-reanimated";
|
import Animated, { FadeIn, FadeInUp } from "react-native-reanimated";
|
||||||
import { supabase } from "../../../src/lib/supabase";
|
import { supabase } from "../../../src/lib/supabase";
|
||||||
import { useAuthStore } from "../../../src/store";
|
import { useAuthStore, useMessageStore } from "../../../src/store";
|
||||||
import * as ImagePicker from "expo-image-picker";
|
import * as ImagePicker from "expo-image-picker";
|
||||||
import * as FileSystem from 'expo-file-system';
|
import * as FileSystem from 'expo-file-system';
|
||||||
import { decode } from 'base64-arraybuffer';
|
import { decode } from 'base64-arraybuffer';
|
||||||
import { Database } from "../../../src/types/supabase";
|
import { Database } from "../../../src/types/supabase";
|
||||||
import { GradientBackground, GlassCard } from "../../../src/components/ui";
|
import { GradientBackground } from "../../../src/components/ui";
|
||||||
import { colors, gradients, spacing, radius, typography, blur, shadows } from "../../../src/theme";
|
import { colors, gradients, spacing, radius, typography } from "../../../src/theme";
|
||||||
|
|
||||||
type Message = Database["public"]["Tables"]["messages"]["Row"];
|
type Message = Database["public"]["Tables"]["messages"]["Row"];
|
||||||
|
|
||||||
@@ -20,14 +21,34 @@ export default function ChatScreen() {
|
|||||||
const { id } = useLocalSearchParams();
|
const { id } = useLocalSearchParams();
|
||||||
const matchId = id as string;
|
const matchId = id as string;
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
const { user } = useAuthStore();
|
const { user } = useAuthStore();
|
||||||
|
const { setActiveMatchId, markMatchMessagesAsRead } = useMessageStore();
|
||||||
const [messages, setMessages] = useState<Message[]>([]);
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
const [match, setMatch] = useState<any>(null);
|
const [match, setMatch] = useState<any>(null);
|
||||||
const [inputText, setInputText] = useState("");
|
const [inputText, setInputText] = useState("");
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
const [partnerTyping, setPartnerTyping] = useState(false);
|
const [partnerTyping, setPartnerTyping] = useState(false);
|
||||||
|
const [fullScreenImage, setFullScreenImage] = useState<string | null>(null);
|
||||||
const flatListRef = useRef<FlatList>(null);
|
const flatListRef = useRef<FlatList>(null);
|
||||||
const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const isFocusedRef = useRef(false);
|
||||||
|
|
||||||
|
// Track active chat to prevent notifications for current chat
|
||||||
|
// Use useFocusEffect because screens don't unmount on navigation
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
isFocusedRef.current = true;
|
||||||
|
if (matchId) {
|
||||||
|
setActiveMatchId(matchId);
|
||||||
|
markMatchMessagesAsRead(matchId);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
isFocusedRef.current = false;
|
||||||
|
setActiveMatchId(null);
|
||||||
|
};
|
||||||
|
}, [matchId, setActiveMatchId, markMatchMessagesAsRead])
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!matchId) return;
|
if (!matchId) return;
|
||||||
@@ -61,18 +82,32 @@ export default function ChatScreen() {
|
|||||||
if (data) {
|
if (data) {
|
||||||
setMessages(data);
|
setMessages(data);
|
||||||
|
|
||||||
const unreadIds = data
|
const now = new Date().toISOString();
|
||||||
.filter(m => m.user_id !== user?.id && !m.read_at)
|
|
||||||
.map(m => m.id);
|
|
||||||
|
|
||||||
|
// Get partner messages that need to be marked as delivered or read
|
||||||
|
const partnerMessages = data.filter(m => m.user_id !== user?.id);
|
||||||
|
const undeliveredIds = partnerMessages.filter(m => !m.delivered_at).map(m => m.id);
|
||||||
|
const unreadIds = partnerMessages.filter(m => !m.read_at).map(m => m.id);
|
||||||
|
|
||||||
|
// Mark as both delivered and read (since user is viewing the chat)
|
||||||
if (unreadIds.length > 0) {
|
if (unreadIds.length > 0) {
|
||||||
await supabase
|
await supabase
|
||||||
.from("messages")
|
.from("messages")
|
||||||
.update({ read_at: new Date().toISOString() })
|
.update({ delivered_at: now, read_at: now })
|
||||||
.in("id", unreadIds);
|
.in("id", unreadIds);
|
||||||
|
|
||||||
setMessages(prev => prev.map(m =>
|
setMessages(prev => prev.map(m =>
|
||||||
unreadIds.includes(m.id) ? { ...m, read_at: new Date().toISOString() } : m
|
unreadIds.includes(m.id) ? { ...m, delivered_at: now, read_at: now } : m
|
||||||
|
));
|
||||||
|
} else if (undeliveredIds.length > 0) {
|
||||||
|
// Only mark as delivered if already read but not delivered (edge case)
|
||||||
|
await supabase
|
||||||
|
.from("messages")
|
||||||
|
.update({ delivered_at: now })
|
||||||
|
.in("id", undeliveredIds);
|
||||||
|
|
||||||
|
setMessages(prev => prev.map(m =>
|
||||||
|
undeliveredIds.includes(m.id) ? { ...m, delivered_at: now } : m
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -92,13 +127,16 @@ export default function ChatScreen() {
|
|||||||
},
|
},
|
||||||
async (payload) => {
|
async (payload) => {
|
||||||
const newMessage = payload.new as Message;
|
const newMessage = payload.new as Message;
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
if (newMessage.user_id !== user?.id) {
|
if (newMessage.user_id !== user?.id && isFocusedRef.current) {
|
||||||
|
// Mark as both delivered and read (only if screen is focused)
|
||||||
await supabase
|
await supabase
|
||||||
.from("messages")
|
.from("messages")
|
||||||
.update({ read_at: new Date().toISOString() })
|
.update({ delivered_at: now, read_at: now })
|
||||||
.eq("id", newMessage.id);
|
.eq("id", newMessage.id);
|
||||||
newMessage.read_at = new Date().toISOString();
|
newMessage.delivered_at = now;
|
||||||
|
newMessage.read_at = now;
|
||||||
}
|
}
|
||||||
|
|
||||||
setMessages((prev) => [newMessage, ...prev]);
|
setMessages((prev) => [newMessage, ...prev]);
|
||||||
@@ -220,14 +258,11 @@ export default function ChatScreen() {
|
|||||||
|
|
||||||
if (uploadError) throw uploadError;
|
if (uploadError) throw uploadError;
|
||||||
|
|
||||||
const { data: { publicUrl } } = supabase.storage
|
// Store just the path, not the full URL (bucket is private)
|
||||||
.from("chat-media")
|
|
||||||
.getPublicUrl(fileName);
|
|
||||||
|
|
||||||
await supabase.from("messages").insert({
|
await supabase.from("messages").insert({
|
||||||
match_id: matchId,
|
match_id: matchId,
|
||||||
user_id: user.id,
|
user_id: user.id,
|
||||||
media_path: publicUrl,
|
media_path: fileName,
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -263,60 +298,88 @@ export default function ChatScreen() {
|
|||||||
start={{ x: 0, y: 0 }}
|
start={{ x: 0, y: 0 }}
|
||||||
end={{ x: 1, y: 1 }}
|
end={{ x: 1, y: 1 }}
|
||||||
>
|
>
|
||||||
<MessageContent item={item} isMe={isMe} revealMessage={revealMessage} />
|
<MessageContent item={item} isMe={isMe} revealMessage={revealMessage} onImagePress={setFullScreenImage} />
|
||||||
</LinearGradient>
|
</LinearGradient>
|
||||||
) : (
|
) : (
|
||||||
<View style={[styles.bubble, styles.theirBubble]}>
|
<View style={[styles.bubble, styles.theirBubble]}>
|
||||||
<MessageContent item={item} isMe={isMe} revealMessage={revealMessage} />
|
<MessageContent item={item} isMe={isMe} revealMessage={revealMessage} onImagePress={setFullScreenImage} />
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isYesYes = match?.match_type === "yes_yes";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GradientBackground>
|
<GradientBackground>
|
||||||
<Stack.Screen options={{
|
|
||||||
title: "Chat",
|
|
||||||
headerStyle: { backgroundColor: colors.backgroundLight },
|
|
||||||
headerTintColor: colors.text,
|
|
||||||
headerShadowVisible: false,
|
|
||||||
}} />
|
|
||||||
|
|
||||||
<KeyboardAvoidingView
|
<KeyboardAvoidingView
|
||||||
style={styles.keyboardAvoiding}
|
style={styles.keyboardAvoiding}
|
||||||
behavior={Platform.OS === "ios" ? "padding" : undefined}
|
behavior={Platform.OS === "ios" ? "padding" : undefined}
|
||||||
keyboardVerticalOffset={Platform.OS === "ios" ? 90 : 0}
|
keyboardVerticalOffset={0}
|
||||||
>
|
>
|
||||||
{/* Sticky top bar with question and answers */}
|
{/* Custom Header */}
|
||||||
|
<View style={[styles.header, { paddingTop: insets.top }]}>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => router.back()}
|
||||||
|
style={styles.backButton}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<Ionicons name="chevron-back" size={24} color={colors.text} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text style={styles.headerTitle}>Chat</Text>
|
||||||
|
<View style={styles.headerSpacer} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Question Header */}
|
||||||
{match?.question && (
|
{match?.question && (
|
||||||
<GlassCard style={styles.stickyTopBar} noPadding>
|
<View style={styles.questionHeader}>
|
||||||
<View style={styles.stickyContent}>
|
<LinearGradient
|
||||||
<Text style={styles.stickyQuestionText} numberOfLines={2}>
|
colors={gradients.primary as [string, string]}
|
||||||
|
style={styles.questionIconGradient}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 1 }}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name={isYesYes ? "heart" : "heart-half"}
|
||||||
|
size={16}
|
||||||
|
color={colors.text}
|
||||||
|
/>
|
||||||
|
</LinearGradient>
|
||||||
|
<View style={styles.questionContent}>
|
||||||
|
<Text style={styles.questionText} numberOfLines={2}>
|
||||||
{match.question.text}
|
{match.question.text}
|
||||||
{match.question.partner_text ? ` & ${match.question.partner_text}` : ""}
|
{match.question.partner_text ? ` / ${match.question.partner_text}` : ""}
|
||||||
</Text>
|
</Text>
|
||||||
{match.responses && match.responses.length > 0 && (
|
{match.responses && match.responses.length > 0 && (
|
||||||
<View style={styles.stickyAnswersContainer}>
|
<View style={styles.answersRow}>
|
||||||
{match.responses.map((response: any) => (
|
{match.responses.map((response: any) => (
|
||||||
<View key={response.user_id} style={styles.answerBadge}>
|
<View key={response.user_id} style={styles.answerChip}>
|
||||||
<Text style={styles.stickyAnswerText}>
|
<Text style={styles.answerName}>
|
||||||
{response.user_id === user?.id ? 'You' : (response.profiles?.name || 'Partner')}: {response.answer}
|
{response.user_id === user?.id ? 'You' : (response.profiles?.name?.split(' ')[0] || 'Partner')}
|
||||||
</Text>
|
</Text>
|
||||||
|
<View style={[
|
||||||
|
styles.answerDot,
|
||||||
|
response.answer === 'yes' && styles.answerDotYes,
|
||||||
|
response.answer === 'maybe' && styles.answerDotMaybe
|
||||||
|
]} />
|
||||||
|
<Text style={styles.answerText}>{response.answer}</Text>
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
</GlassCard>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Messages List */}
|
||||||
<FlatList
|
<FlatList
|
||||||
ref={flatListRef}
|
ref={flatListRef}
|
||||||
data={messages}
|
data={messages}
|
||||||
renderItem={renderMessage}
|
renderItem={renderMessage}
|
||||||
keyExtractor={(item) => item.id}
|
keyExtractor={(item) => item.id}
|
||||||
inverted
|
inverted
|
||||||
|
style={styles.messageList}
|
||||||
contentContainerStyle={styles.listContent}
|
contentContainerStyle={styles.listContent}
|
||||||
keyboardShouldPersistTaps="handled"
|
keyboardShouldPersistTaps="handled"
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
@@ -326,57 +389,150 @@ export default function ChatScreen() {
|
|||||||
entering={FadeIn.duration(200)}
|
entering={FadeIn.duration(200)}
|
||||||
style={styles.typingContainer}
|
style={styles.typingContainer}
|
||||||
>
|
>
|
||||||
<Text style={styles.typingText}>Partner is typing...</Text>
|
<View style={styles.typingBubble}>
|
||||||
|
<View style={styles.typingDots}>
|
||||||
|
<View style={[styles.typingDot, styles.typingDot1]} />
|
||||||
|
<View style={[styles.typingDot, styles.typingDot2]} />
|
||||||
|
<View style={[styles.typingDot, styles.typingDot3]} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
|
ListEmptyComponent={
|
||||||
|
<View style={styles.emptyChat}>
|
||||||
|
<LinearGradient
|
||||||
|
colors={gradients.primary as [string, string]}
|
||||||
|
style={styles.emptyChatIcon}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 1 }}
|
||||||
|
>
|
||||||
|
<Ionicons name="chatbubbles-outline" size={24} color={colors.text} />
|
||||||
|
</LinearGradient>
|
||||||
|
<Text style={styles.emptyChatText}>Start the conversation!</Text>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Input bar with glass effect */}
|
{/* Input Bar */}
|
||||||
{Platform.OS === 'ios' ? (
|
<View style={[styles.inputWrapper, { paddingBottom: insets.bottom || spacing.sm }]}>
|
||||||
<BlurView intensity={blur.medium} tint="dark" style={styles.inputWrapper}>
|
<InputBar
|
||||||
<InputBar
|
inputText={inputText}
|
||||||
inputText={inputText}
|
uploading={uploading}
|
||||||
uploading={uploading}
|
onChangeText={handleTyping}
|
||||||
onChangeText={handleTyping}
|
onSend={handleSend}
|
||||||
onSend={handleSend}
|
onPickImage={handlePickImage}
|
||||||
onPickImage={handlePickImage}
|
/>
|
||||||
/>
|
</View>
|
||||||
</BlurView>
|
|
||||||
) : (
|
|
||||||
<View style={[styles.inputWrapper, styles.inputWrapperAndroid]}>
|
|
||||||
<InputBar
|
|
||||||
inputText={inputText}
|
|
||||||
uploading={uploading}
|
|
||||||
onChangeText={handleTyping}
|
|
||||||
onSend={handleSend}
|
|
||||||
onPickImage={handlePickImage}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</KeyboardAvoidingView>
|
</KeyboardAvoidingView>
|
||||||
|
|
||||||
|
{/* Full Screen Image Modal */}
|
||||||
|
<Modal
|
||||||
|
visible={!!fullScreenImage}
|
||||||
|
transparent
|
||||||
|
animationType="fade"
|
||||||
|
onRequestClose={() => setFullScreenImage(null)}
|
||||||
|
>
|
||||||
|
<View style={styles.fullScreenOverlay}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.fullScreenCloseButton}
|
||||||
|
onPress={() => setFullScreenImage(null)}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
>
|
||||||
|
<Ionicons name="close" size={28} color={colors.text} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
{fullScreenImage && (
|
||||||
|
<Image
|
||||||
|
source={{ uri: fullScreenImage }}
|
||||||
|
style={styles.fullScreenImage}
|
||||||
|
resizeMode="contain"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
</GradientBackground>
|
</GradientBackground>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extract storage path from media_path (handles both old full URLs and new path-only format)
|
||||||
|
function getStoragePath(mediaPath: string): string {
|
||||||
|
if (mediaPath.startsWith('http')) {
|
||||||
|
// Old format: extract path from full URL
|
||||||
|
const match = mediaPath.match(/\/chat-media\/(.+)$/);
|
||||||
|
return match ? match[1] : mediaPath;
|
||||||
|
}
|
||||||
|
return mediaPath;
|
||||||
|
}
|
||||||
|
|
||||||
function MessageContent({
|
function MessageContent({
|
||||||
item,
|
item,
|
||||||
isMe,
|
isMe,
|
||||||
revealMessage,
|
revealMessage,
|
||||||
|
onImagePress,
|
||||||
}: {
|
}: {
|
||||||
item: Message;
|
item: Message;
|
||||||
isMe: boolean;
|
isMe: boolean;
|
||||||
revealMessage: (id: string) => void;
|
revealMessage: (id: string) => void;
|
||||||
|
onImagePress: (uri: string) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const [signedUrl, setSignedUrl] = useState<string | null>(null);
|
||||||
|
const [urlError, setUrlError] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!item.media_path) return;
|
||||||
|
|
||||||
|
const fetchSignedUrl = async () => {
|
||||||
|
const storagePath = getStoragePath(item.media_path!);
|
||||||
|
const { data, error } = await supabase.storage
|
||||||
|
.from('chat-media')
|
||||||
|
.createSignedUrl(storagePath, 3600); // 1 hour expiry
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error('Failed to get signed URL:', error);
|
||||||
|
setUrlError(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data?.signedUrl) {
|
||||||
|
setSignedUrl(data.signedUrl);
|
||||||
|
} else {
|
||||||
|
setUrlError(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchSignedUrl();
|
||||||
|
}, [item.media_path]);
|
||||||
|
|
||||||
if (item.media_path) {
|
if (item.media_path) {
|
||||||
|
const isViewed = !!item.media_viewed_at;
|
||||||
|
const canOpenFullScreen = isMe || isViewed;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View>
|
<View>
|
||||||
<Image
|
<TouchableOpacity
|
||||||
source={{ uri: item.media_path }}
|
activeOpacity={canOpenFullScreen ? 0.8 : 1}
|
||||||
style={styles.messageImage}
|
onPress={() => canOpenFullScreen && signedUrl && onImagePress(signedUrl)}
|
||||||
blurRadius={(!isMe && !item.media_viewed_at) ? 20 : 0}
|
disabled={!canOpenFullScreen || !signedUrl}
|
||||||
/>
|
>
|
||||||
{(!isMe && !item.media_viewed_at) && (
|
{signedUrl ? (
|
||||||
|
<Image
|
||||||
|
source={{ uri: signedUrl }}
|
||||||
|
style={styles.messageImage}
|
||||||
|
blurRadius={(!isMe && !isViewed) ? 20 : 0}
|
||||||
|
/>
|
||||||
|
) : urlError ? (
|
||||||
|
<View style={[styles.messageImage, { justifyContent: 'center', alignItems: 'center', backgroundColor: colors.surface }]}>
|
||||||
|
<Ionicons name="image-outline" size={32} color={colors.textSecondary} />
|
||||||
|
<Text style={{ color: colors.textSecondary, fontSize: 12, marginTop: 4 }}>Image unavailable</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View style={[styles.messageImage, { justifyContent: 'center', alignItems: 'center' }]}>
|
||||||
|
<ActivityIndicator color={colors.primary} />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
{/* Reveal overlay for recipient */}
|
||||||
|
{(!isMe && !isViewed) && (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.revealOverlay}
|
style={styles.revealOverlay}
|
||||||
activeOpacity={0.8}
|
activeOpacity={0.8}
|
||||||
@@ -388,6 +544,13 @@ function MessageContent({
|
|||||||
</View>
|
</View>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)}
|
)}
|
||||||
|
{/* Viewed indicator for sender */}
|
||||||
|
{isMe && isViewed && (
|
||||||
|
<View style={styles.viewedBadge}>
|
||||||
|
<Ionicons name="eye" size={12} color={colors.success} />
|
||||||
|
<Text style={styles.viewedText}>Viewed</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
<MessageMeta item={item} isMe={isMe} />
|
<MessageMeta item={item} isMe={isMe} />
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -402,18 +565,35 @@ function MessageContent({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function MessageMeta({ item, isMe }: { item: Message; isMe: boolean }) {
|
function MessageMeta({ item, isMe }: { item: Message; isMe: boolean }) {
|
||||||
|
// 3-state read receipts:
|
||||||
|
// - Single tick (grey) = sent to server
|
||||||
|
// - Double tick (grey) = delivered to partner's device
|
||||||
|
// - Double tick (blue) = read by partner
|
||||||
|
const isDelivered = !!item.delivered_at;
|
||||||
|
const isRead = !!item.read_at;
|
||||||
|
|
||||||
|
const getStatusIcon = () => {
|
||||||
|
if (isRead) {
|
||||||
|
// Double tick blue - read
|
||||||
|
return <Ionicons name="checkmark-done" size={16} color="#34B7F1" />;
|
||||||
|
} else if (isDelivered) {
|
||||||
|
// Double tick grey - delivered but not read
|
||||||
|
return <Ionicons name="checkmark-done" size={16} color="rgba(255,255,255,0.5)" />;
|
||||||
|
} else {
|
||||||
|
// Single tick grey - sent but not delivered
|
||||||
|
return <Ionicons name="checkmark" size={16} color="rgba(255,255,255,0.5)" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.metaContainer}>
|
<View style={styles.metaContainer}>
|
||||||
<Text style={styles.timestamp}>
|
<Text style={styles.timestamp}>
|
||||||
{new Date(item.created_at!).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
{new Date(item.created_at!).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||||
</Text>
|
</Text>
|
||||||
{isMe && (
|
{isMe && (
|
||||||
<Ionicons
|
<View style={styles.readStatus}>
|
||||||
name={item.read_at ? "checkmark-done" : "checkmark"}
|
{getStatusIcon()}
|
||||||
size={14}
|
</View>
|
||||||
color={item.read_at ? colors.success : "rgba(255,255,255,0.6)"}
|
|
||||||
style={styles.readIcon}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -438,7 +618,7 @@ function InputBar({
|
|||||||
{uploading ? (
|
{uploading ? (
|
||||||
<ActivityIndicator color={colors.primary} size="small" />
|
<ActivityIndicator color={colors.primary} size="small" />
|
||||||
) : (
|
) : (
|
||||||
<Ionicons name="image-outline" size={24} color={colors.primary} />
|
<Ionicons name="image-outline" size={22} color={colors.textSecondary} />
|
||||||
)}
|
)}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
@@ -456,13 +636,20 @@ function InputBar({
|
|||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={onSend}
|
onPress={onSend}
|
||||||
disabled={!inputText.trim()}
|
disabled={!inputText.trim()}
|
||||||
style={[styles.sendButton, inputText.trim() && styles.sendButtonActive]}
|
activeOpacity={0.8}
|
||||||
>
|
>
|
||||||
<Ionicons
|
<LinearGradient
|
||||||
name="send"
|
colors={inputText.trim() ? gradients.primary as [string, string] : [colors.glass.background, colors.glass.background]}
|
||||||
size={20}
|
style={styles.sendButton}
|
||||||
color={inputText.trim() ? colors.text : colors.textTertiary}
|
start={{ x: 0, y: 0 }}
|
||||||
/>
|
end={{ x: 1, y: 1 }}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name="send"
|
||||||
|
size={18}
|
||||||
|
color={inputText.trim() ? colors.text : colors.textTertiary}
|
||||||
|
/>
|
||||||
|
</LinearGradient>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -472,9 +659,94 @@ const styles = StyleSheet.create({
|
|||||||
keyboardAvoiding: {
|
keyboardAvoiding: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
|
header: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: spacing.sm,
|
||||||
|
paddingBottom: spacing.sm,
|
||||||
|
backgroundColor: colors.backgroundLight,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: colors.glass.border,
|
||||||
|
},
|
||||||
|
backButton: {
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: 20,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
headerTitle: {
|
||||||
|
...typography.headline,
|
||||||
|
color: colors.text,
|
||||||
|
flex: 1,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
headerSpacer: {
|
||||||
|
width: 40,
|
||||||
|
},
|
||||||
|
questionHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: spacing.md,
|
||||||
|
paddingVertical: spacing.sm,
|
||||||
|
backgroundColor: colors.glass.backgroundLight,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: colors.glass.border,
|
||||||
|
},
|
||||||
|
messageList: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
questionIconGradient: {
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: 16,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginRight: spacing.sm,
|
||||||
|
},
|
||||||
|
questionContent: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
questionText: {
|
||||||
|
...typography.subhead,
|
||||||
|
color: colors.text,
|
||||||
|
fontWeight: '500',
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
answersRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: spacing.md,
|
||||||
|
marginTop: spacing.xs,
|
||||||
|
},
|
||||||
|
answerChip: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: spacing.xs,
|
||||||
|
},
|
||||||
|
answerName: {
|
||||||
|
...typography.caption2,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
},
|
||||||
|
answerDot: {
|
||||||
|
width: 6,
|
||||||
|
height: 6,
|
||||||
|
borderRadius: 3,
|
||||||
|
backgroundColor: colors.textTertiary,
|
||||||
|
},
|
||||||
|
answerDotYes: {
|
||||||
|
backgroundColor: colors.success,
|
||||||
|
},
|
||||||
|
answerDotMaybe: {
|
||||||
|
backgroundColor: colors.warning,
|
||||||
|
},
|
||||||
|
answerText: {
|
||||||
|
...typography.caption2,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
textTransform: 'capitalize',
|
||||||
|
},
|
||||||
listContent: {
|
listContent: {
|
||||||
|
flexGrow: 1,
|
||||||
padding: spacing.md,
|
padding: spacing.md,
|
||||||
paddingBottom: spacing.lg,
|
|
||||||
},
|
},
|
||||||
messageRow: {
|
messageRow: {
|
||||||
marginBottom: spacing.sm,
|
marginBottom: spacing.sm,
|
||||||
@@ -489,8 +761,7 @@ const styles = StyleSheet.create({
|
|||||||
bubble: {
|
bubble: {
|
||||||
maxWidth: "78%",
|
maxWidth: "78%",
|
||||||
padding: spacing.md,
|
padding: spacing.md,
|
||||||
borderRadius: radius.xl,
|
borderRadius: radius.lg,
|
||||||
...shadows.sm,
|
|
||||||
},
|
},
|
||||||
myBubble: {
|
myBubble: {
|
||||||
borderBottomRightRadius: radius.xs,
|
borderBottomRightRadius: radius.xs,
|
||||||
@@ -504,6 +775,7 @@ const styles = StyleSheet.create({
|
|||||||
messageText: {
|
messageText: {
|
||||||
...typography.body,
|
...typography.body,
|
||||||
color: colors.text,
|
color: colors.text,
|
||||||
|
lineHeight: 22,
|
||||||
},
|
},
|
||||||
messageImage: {
|
messageImage: {
|
||||||
width: 200,
|
width: 200,
|
||||||
@@ -519,35 +791,70 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
timestamp: {
|
timestamp: {
|
||||||
...typography.caption2,
|
...typography.caption2,
|
||||||
color: "rgba(255, 255, 255, 0.6)",
|
color: "rgba(255, 255, 255, 0.5)",
|
||||||
},
|
},
|
||||||
readIcon: {
|
readStatus: {
|
||||||
marginLeft: spacing.xs,
|
marginLeft: spacing.xs,
|
||||||
},
|
},
|
||||||
|
viewedBadge: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 4,
|
||||||
|
position: 'absolute',
|
||||||
|
top: spacing.sm,
|
||||||
|
left: spacing.sm,
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.6)',
|
||||||
|
paddingHorizontal: spacing.sm,
|
||||||
|
paddingVertical: 3,
|
||||||
|
borderRadius: radius.sm,
|
||||||
|
},
|
||||||
|
viewedText: {
|
||||||
|
...typography.caption2,
|
||||||
|
color: colors.success,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
emptyChat: {
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingVertical: spacing.xxl,
|
||||||
|
},
|
||||||
|
emptyChatIcon: {
|
||||||
|
width: 56,
|
||||||
|
height: 56,
|
||||||
|
borderRadius: 28,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: spacing.md,
|
||||||
|
},
|
||||||
|
emptyChatText: {
|
||||||
|
...typography.subhead,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
},
|
||||||
inputWrapper: {
|
inputWrapper: {
|
||||||
|
backgroundColor: colors.backgroundLight,
|
||||||
borderTopWidth: 1,
|
borderTopWidth: 1,
|
||||||
borderTopColor: colors.glass.border,
|
borderTopColor: colors.glass.border,
|
||||||
},
|
},
|
||||||
inputWrapperAndroid: {
|
|
||||||
backgroundColor: colors.glass.backgroundLight,
|
|
||||||
},
|
|
||||||
inputContainer: {
|
inputContainer: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "flex-end",
|
alignItems: "flex-end",
|
||||||
padding: spacing.sm,
|
padding: spacing.sm,
|
||||||
paddingBottom: Platform.OS === "ios" ? spacing.xl : spacing.sm,
|
gap: spacing.sm,
|
||||||
},
|
},
|
||||||
attachButton: {
|
attachButton: {
|
||||||
padding: spacing.sm,
|
width: 40,
|
||||||
marginBottom: spacing.xs,
|
height: 40,
|
||||||
|
borderRadius: 20,
|
||||||
|
backgroundColor: colors.glass.background,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
},
|
},
|
||||||
inputFieldWrapper: {
|
inputFieldWrapper: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: colors.glass.background,
|
backgroundColor: colors.glass.background,
|
||||||
borderRadius: radius.xl,
|
borderRadius: radius.lg,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: colors.glass.border,
|
borderColor: colors.glass.border,
|
||||||
marginHorizontal: spacing.sm,
|
|
||||||
},
|
},
|
||||||
input: {
|
input: {
|
||||||
paddingHorizontal: spacing.md,
|
paddingHorizontal: spacing.md,
|
||||||
@@ -560,28 +867,47 @@ const styles = StyleSheet.create({
|
|||||||
width: 40,
|
width: 40,
|
||||||
height: 40,
|
height: 40,
|
||||||
borderRadius: 20,
|
borderRadius: 20,
|
||||||
backgroundColor: colors.glass.background,
|
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
marginBottom: spacing.xs,
|
|
||||||
},
|
|
||||||
sendButtonActive: {
|
|
||||||
backgroundColor: colors.primary,
|
|
||||||
},
|
},
|
||||||
typingContainer: {
|
typingContainer: {
|
||||||
|
paddingHorizontal: spacing.sm,
|
||||||
|
paddingVertical: spacing.xs,
|
||||||
|
},
|
||||||
|
typingBubble: {
|
||||||
|
backgroundColor: colors.glass.backgroundLight,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.glass.border,
|
||||||
|
borderRadius: radius.lg,
|
||||||
|
borderBottomLeftRadius: radius.xs,
|
||||||
paddingHorizontal: spacing.md,
|
paddingHorizontal: spacing.md,
|
||||||
paddingVertical: spacing.sm,
|
paddingVertical: spacing.sm,
|
||||||
|
alignSelf: 'flex-start',
|
||||||
},
|
},
|
||||||
typingText: {
|
typingDots: {
|
||||||
...typography.caption1,
|
flexDirection: 'row',
|
||||||
color: colors.textTertiary,
|
gap: 4,
|
||||||
fontStyle: "italic",
|
},
|
||||||
|
typingDot: {
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: 4,
|
||||||
|
backgroundColor: colors.textTertiary,
|
||||||
|
},
|
||||||
|
typingDot1: {
|
||||||
|
opacity: 0.4,
|
||||||
|
},
|
||||||
|
typingDot2: {
|
||||||
|
opacity: 0.6,
|
||||||
|
},
|
||||||
|
typingDot3: {
|
||||||
|
opacity: 0.8,
|
||||||
},
|
},
|
||||||
revealOverlay: {
|
revealOverlay: {
|
||||||
...StyleSheet.absoluteFillObject,
|
...StyleSheet.absoluteFillObject,
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
backgroundColor: "rgba(0,0,0,0.4)",
|
backgroundColor: "rgba(0,0,0,0.5)",
|
||||||
borderRadius: radius.md,
|
borderRadius: radius.md,
|
||||||
},
|
},
|
||||||
revealContent: {
|
revealContent: {
|
||||||
@@ -596,34 +922,26 @@ const styles = StyleSheet.create({
|
|||||||
fontWeight: "600",
|
fontWeight: "600",
|
||||||
marginTop: spacing.xs,
|
marginTop: spacing.xs,
|
||||||
},
|
},
|
||||||
stickyTopBar: {
|
fullScreenOverlay: {
|
||||||
marginHorizontal: spacing.md,
|
flex: 1,
|
||||||
marginTop: spacing.sm,
|
backgroundColor: "rgba(0, 0, 0, 0.95)",
|
||||||
marginBottom: spacing.xs,
|
|
||||||
},
|
|
||||||
stickyContent: {
|
|
||||||
padding: spacing.md,
|
|
||||||
},
|
|
||||||
stickyQuestionText: {
|
|
||||||
...typography.subhead,
|
|
||||||
color: colors.text,
|
|
||||||
fontWeight: "600",
|
|
||||||
textAlign: "center",
|
|
||||||
marginBottom: spacing.sm,
|
|
||||||
},
|
|
||||||
stickyAnswersContainer: {
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
gap: spacing.sm,
|
alignItems: "center",
|
||||||
},
|
},
|
||||||
answerBadge: {
|
fullScreenCloseButton: {
|
||||||
backgroundColor: colors.primaryLight,
|
position: "absolute",
|
||||||
paddingHorizontal: spacing.sm,
|
top: 50,
|
||||||
paddingVertical: spacing.xs,
|
right: 20,
|
||||||
borderRadius: radius.sm,
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
borderRadius: 22,
|
||||||
|
backgroundColor: "rgba(255, 255, 255, 0.15)",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
zIndex: 10,
|
||||||
},
|
},
|
||||||
stickyAnswerText: {
|
fullScreenImage: {
|
||||||
...typography.caption1,
|
width: Dimensions.get("window").width,
|
||||||
color: colors.text,
|
height: Dimensions.get("window").height * 0.8,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -1,17 +1,20 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { View, Text, StyleSheet, TouchableOpacity, ScrollView, Platform } from "react-native";
|
import { View, Text, StyleSheet, TouchableOpacity, ScrollView, Platform, useWindowDimensions } from "react-native";
|
||||||
import Animated, { FadeInDown, FadeInRight } from "react-native-reanimated";
|
import Animated, { FadeInDown, FadeInRight } from "react-native-reanimated";
|
||||||
import { LinearGradient } from "expo-linear-gradient";
|
import { LinearGradient } from "expo-linear-gradient";
|
||||||
import { useAuthStore, useMatchStore, usePacksStore } from "../../src/store";
|
import { useAuthStore, useMatchStore, usePacksStore } from "../../src/store";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { router } from "expo-router";
|
import { router } from "expo-router";
|
||||||
import { GradientBackground, GlassCard, GlassButton } from "../../src/components/ui";
|
import { GradientBackground, GlassCard } from "../../src/components/ui";
|
||||||
import { colors, gradients, spacing, radius, typography, shadows } from "../../src/theme";
|
import { colors, gradients, spacing, radius, typography, shadows } from "../../src/theme";
|
||||||
|
|
||||||
|
const MAX_CONTENT_WIDTH = 500;
|
||||||
|
|
||||||
export default function HomeScreen() {
|
export default function HomeScreen() {
|
||||||
const { user, partner } = useAuthStore();
|
const { user, partner } = useAuthStore();
|
||||||
const { matches, newMatchesCount, fetchMatches } = useMatchStore();
|
const { matches, newMatchesCount, fetchMatches } = useMatchStore();
|
||||||
const { packs, fetchPacks } = usePacksStore();
|
const { packs, fetchPacks } = usePacksStore();
|
||||||
|
const { width } = useWindowDimensions();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchMatches();
|
fetchMatches();
|
||||||
@@ -19,131 +22,266 @@ export default function HomeScreen() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const recentMatches = matches.slice(0, 3);
|
const recentMatches = matches.slice(0, 3);
|
||||||
|
const isWideScreen = width > MAX_CONTENT_WIDTH;
|
||||||
|
const enabledPacksCount = packs.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GradientBackground>
|
<GradientBackground>
|
||||||
<ScrollView
|
<ScrollView
|
||||||
style={styles.container}
|
style={styles.container}
|
||||||
contentContainerStyle={styles.contentContainer}
|
contentContainerStyle={[
|
||||||
|
styles.contentContainer,
|
||||||
|
isWideScreen && styles.contentContainerWide,
|
||||||
|
]}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
<View style={[styles.innerContainer, isWideScreen && styles.innerContainerWide]}>
|
||||||
<Animated.View
|
{/* Header */}
|
||||||
entering={FadeInDown.delay(100).duration(500)}
|
|
||||||
style={styles.header}
|
|
||||||
>
|
|
||||||
<View>
|
|
||||||
<Text style={styles.greeting}>
|
|
||||||
Hey, {user?.name || "there"}
|
|
||||||
</Text>
|
|
||||||
{partner && (
|
|
||||||
<Text style={styles.partnerText}>
|
|
||||||
Paired with {partner.name || partner.email || 'your partner'}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
<TouchableOpacity
|
|
||||||
style={styles.profileButton}
|
|
||||||
onPress={() => router.push("/(app)/profile")}
|
|
||||||
activeOpacity={0.7}
|
|
||||||
>
|
|
||||||
<Text style={styles.profileButtonText}>
|
|
||||||
{user?.name?.[0]?.toUpperCase() || "U"}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</Animated.View>
|
|
||||||
|
|
||||||
{/* Quick Stats */}
|
|
||||||
<Animated.View
|
|
||||||
entering={FadeInDown.delay(200).duration(500)}
|
|
||||||
style={styles.statsContainer}
|
|
||||||
>
|
|
||||||
<GlassCard style={styles.statCard}>
|
|
||||||
<View style={styles.statIconContainer}>
|
|
||||||
<Ionicons name="heart" size={20} color={colors.primary} />
|
|
||||||
</View>
|
|
||||||
<Text style={styles.statNumber}>{matches.length}</Text>
|
|
||||||
<Text style={styles.statLabel}>Matches</Text>
|
|
||||||
</GlassCard>
|
|
||||||
<GlassCard style={styles.statCard}>
|
|
||||||
<View style={styles.statIconContainer}>
|
|
||||||
<Ionicons name="layers" size={20} color={colors.primary} />
|
|
||||||
</View>
|
|
||||||
<Text style={styles.statNumber}>{packs.length}</Text>
|
|
||||||
<Text style={styles.statLabel}>Packs</Text>
|
|
||||||
</GlassCard>
|
|
||||||
<GlassCard style={styles.statCard}>
|
|
||||||
<View style={[styles.statIconContainer, newMatchesCount > 0 && styles.statIconHighlight]}>
|
|
||||||
<Ionicons name="sparkles" size={20} color={newMatchesCount > 0 ? colors.text : colors.primary} />
|
|
||||||
</View>
|
|
||||||
<Text style={styles.statNumber}>{newMatchesCount}</Text>
|
|
||||||
<Text style={styles.statLabel}>New</Text>
|
|
||||||
</GlassCard>
|
|
||||||
</Animated.View>
|
|
||||||
|
|
||||||
{/* Start Playing CTA */}
|
|
||||||
<Animated.View entering={FadeInDown.delay(300).duration(500)}>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => router.push("/(app)/swipe")}
|
|
||||||
activeOpacity={0.9}
|
|
||||||
>
|
|
||||||
<LinearGradient
|
|
||||||
colors={gradients.primary as [string, string]}
|
|
||||||
style={styles.ctaCard}
|
|
||||||
start={{ x: 0, y: 0 }}
|
|
||||||
end={{ x: 1, y: 1 }}
|
|
||||||
>
|
|
||||||
<View style={styles.ctaContent}>
|
|
||||||
<Text style={styles.ctaTitle}>Ready to explore?</Text>
|
|
||||||
<Text style={styles.ctaSubtitle}>
|
|
||||||
Swipe through questions and discover what you both enjoy
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<View style={styles.ctaIconContainer}>
|
|
||||||
<Ionicons name="arrow-forward" size={24} color={colors.text} />
|
|
||||||
</View>
|
|
||||||
</LinearGradient>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</Animated.View>
|
|
||||||
|
|
||||||
{/* Recent Matches */}
|
|
||||||
{recentMatches.length > 0 && (
|
|
||||||
<Animated.View
|
<Animated.View
|
||||||
entering={FadeInDown.delay(400).duration(500)}
|
entering={FadeInDown.delay(100).duration(500)}
|
||||||
style={styles.section}
|
style={styles.header}
|
||||||
>
|
>
|
||||||
<View style={styles.sectionHeader}>
|
<View style={styles.headerLeft}>
|
||||||
<Text style={styles.sectionTitle}>Recent Matches</Text>
|
<Text style={styles.greeting}>
|
||||||
<TouchableOpacity onPress={() => router.push("/(app)/matches")}>
|
Hey, {user?.name || "there"}
|
||||||
<Text style={styles.seeAll}>See all</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
{partner ? (
|
||||||
</View>
|
<View style={styles.partnerBadge}>
|
||||||
{recentMatches.map((match, index) => (
|
<Ionicons name="heart" size={12} color={colors.primary} />
|
||||||
<Animated.View
|
<Text style={styles.partnerText}>
|
||||||
key={match.id}
|
{partner.name || partner.email?.split('@')[0] || 'Partner'}
|
||||||
entering={FadeInRight.delay(450 + index * 100).duration(400)}
|
</Text>
|
||||||
>
|
</View>
|
||||||
|
) : (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => router.push(`/chat/${match.id}`)}
|
style={styles.pairBadge}
|
||||||
activeOpacity={0.7}
|
onPress={() => router.push("/(app)/pairing")}
|
||||||
>
|
>
|
||||||
<GlassCard style={styles.matchItem}>
|
<Ionicons name="add-circle-outline" size={12} color={colors.textTertiary} />
|
||||||
<View style={styles.matchIcon}>
|
<Text style={styles.pairText}>Pair with partner</Text>
|
||||||
<Ionicons name="heart" size={18} color={colors.primary} />
|
|
||||||
</View>
|
|
||||||
<Text style={styles.matchText} numberOfLines={1}>
|
|
||||||
{(match as any).question?.text || "A new match!"}
|
|
||||||
</Text>
|
|
||||||
{match.is_new && <View style={styles.newBadge} />}
|
|
||||||
</GlassCard>
|
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</Animated.View>
|
)}
|
||||||
))}
|
</View>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => router.push("/(app)/profile")}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
>
|
||||||
|
<LinearGradient
|
||||||
|
colors={gradients.primary as [string, string]}
|
||||||
|
style={styles.avatarGradient}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 1 }}
|
||||||
|
>
|
||||||
|
<View style={styles.avatarInner}>
|
||||||
|
<Text style={styles.avatarText}>
|
||||||
|
{user?.name?.[0]?.toUpperCase() || "U"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</LinearGradient>
|
||||||
|
</TouchableOpacity>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Bottom spacing for tab bar */}
|
{/* Stats Row */}
|
||||||
<View style={styles.bottomSpacer} />
|
<Animated.View
|
||||||
|
entering={FadeInDown.delay(200).duration(500)}
|
||||||
|
style={styles.statsContainer}
|
||||||
|
>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.statCardWrapper}
|
||||||
|
onPress={() => router.push("/(app)/matches")}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<GlassCard>
|
||||||
|
<View style={styles.statContent}>
|
||||||
|
<LinearGradient
|
||||||
|
colors={matches.length > 0 ? gradients.primary as [string, string] : [colors.glass.background, colors.glass.background]}
|
||||||
|
style={styles.statIcon}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 1 }}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name="heart"
|
||||||
|
size={18}
|
||||||
|
color={matches.length > 0 ? colors.text : colors.primary}
|
||||||
|
/>
|
||||||
|
</LinearGradient>
|
||||||
|
<Text style={styles.statNumber}>{matches.length}</Text>
|
||||||
|
<Text style={styles.statLabel}>Matches</Text>
|
||||||
|
</View>
|
||||||
|
</GlassCard>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.statCardWrapper}
|
||||||
|
onPress={() => router.push("/(app)/packs")}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<GlassCard>
|
||||||
|
<View style={styles.statContent}>
|
||||||
|
<LinearGradient
|
||||||
|
colors={enabledPacksCount > 0 ? gradients.primary as [string, string] : [colors.glass.background, colors.glass.background]}
|
||||||
|
style={styles.statIcon}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 1 }}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name="layers"
|
||||||
|
size={18}
|
||||||
|
color={enabledPacksCount > 0 ? colors.text : colors.primary}
|
||||||
|
/>
|
||||||
|
</LinearGradient>
|
||||||
|
<Text style={styles.statNumber}>{enabledPacksCount}</Text>
|
||||||
|
<Text style={styles.statLabel}>Packs</Text>
|
||||||
|
</View>
|
||||||
|
</GlassCard>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.statCardWrapper}
|
||||||
|
onPress={() => router.push("/(app)/matches")}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<GlassCard>
|
||||||
|
<View style={styles.statContent}>
|
||||||
|
<LinearGradient
|
||||||
|
colors={newMatchesCount > 0 ? gradients.primary as [string, string] : [colors.glass.background, colors.glass.background]}
|
||||||
|
style={styles.statIcon}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 1 }}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name="sparkles"
|
||||||
|
size={18}
|
||||||
|
color={newMatchesCount > 0 ? colors.text : colors.primary}
|
||||||
|
/>
|
||||||
|
</LinearGradient>
|
||||||
|
<Text style={styles.statNumber}>{newMatchesCount}</Text>
|
||||||
|
<Text style={styles.statLabel}>New</Text>
|
||||||
|
</View>
|
||||||
|
</GlassCard>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
{/* Play CTA */}
|
||||||
|
<Animated.View
|
||||||
|
entering={FadeInDown.delay(300).duration(500)}
|
||||||
|
style={styles.ctaSection}
|
||||||
|
>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => router.push("/(app)/swipe")}
|
||||||
|
activeOpacity={0.9}
|
||||||
|
>
|
||||||
|
<LinearGradient
|
||||||
|
colors={gradients.primary as [string, string]}
|
||||||
|
style={styles.ctaCard}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 1 }}
|
||||||
|
>
|
||||||
|
<View style={styles.ctaContent}>
|
||||||
|
<Text style={styles.ctaTitle}>Ready to explore?</Text>
|
||||||
|
<Text style={styles.ctaSubtitle}>
|
||||||
|
Swipe through questions and discover what you both enjoy
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.ctaIconContainer}>
|
||||||
|
<Ionicons name="play" size={24} color={colors.text} />
|
||||||
|
</View>
|
||||||
|
</LinearGradient>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
{/* Recent Matches */}
|
||||||
|
{recentMatches.length > 0 && (
|
||||||
|
<Animated.View
|
||||||
|
entering={FadeInDown.delay(400).duration(500)}
|
||||||
|
style={styles.section}
|
||||||
|
>
|
||||||
|
<View style={styles.sectionHeader}>
|
||||||
|
<Text style={styles.sectionTitle}>Recent Matches</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => router.push("/(app)/matches")}
|
||||||
|
style={styles.seeAllButton}
|
||||||
|
>
|
||||||
|
<Text style={styles.seeAllText}>See all</Text>
|
||||||
|
<Ionicons name="chevron-forward" size={14} color={colors.primary} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
{recentMatches.map((match, index) => (
|
||||||
|
<Animated.View
|
||||||
|
key={match.id}
|
||||||
|
entering={FadeInRight.delay(450 + index * 100).duration(400)}
|
||||||
|
>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => router.push(`/chat/${match.id}`)}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<GlassCard style={styles.matchCard}>
|
||||||
|
<View style={styles.matchRow}>
|
||||||
|
<LinearGradient
|
||||||
|
colors={gradients.primary as [string, string]}
|
||||||
|
style={styles.matchIcon}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 1 }}
|
||||||
|
>
|
||||||
|
<Ionicons name="heart" size={16} color={colors.text} />
|
||||||
|
</LinearGradient>
|
||||||
|
<Text style={styles.matchText} numberOfLines={1}>
|
||||||
|
{(match as any).question?.text || "A new match!"}
|
||||||
|
</Text>
|
||||||
|
{match.is_new && <View style={styles.newBadge} />}
|
||||||
|
</View>
|
||||||
|
</GlassCard>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</Animated.View>
|
||||||
|
))}
|
||||||
|
</Animated.View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tips when no matches */}
|
||||||
|
{recentMatches.length === 0 && (
|
||||||
|
<Animated.View
|
||||||
|
entering={FadeInDown.delay(400).duration(500)}
|
||||||
|
style={styles.section}
|
||||||
|
>
|
||||||
|
<Text style={styles.tipsTitle}>Getting Started</Text>
|
||||||
|
<GlassCard>
|
||||||
|
<View style={styles.tipRow}>
|
||||||
|
<View style={styles.tipIcon}>
|
||||||
|
<Ionicons name="layers-outline" size={18} color={colors.primary} />
|
||||||
|
</View>
|
||||||
|
<View style={styles.tipContent}>
|
||||||
|
<Text style={styles.tipText}>Enable question packs</Text>
|
||||||
|
<Text style={styles.tipSubtext}>Choose topics you want to explore</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</GlassCard>
|
||||||
|
<GlassCard style={styles.tipCard}>
|
||||||
|
<View style={styles.tipRow}>
|
||||||
|
<View style={styles.tipIcon}>
|
||||||
|
<Ionicons name="swap-horizontal-outline" size={18} color={colors.primary} />
|
||||||
|
</View>
|
||||||
|
<View style={styles.tipContent}>
|
||||||
|
<Text style={styles.tipText}>Swipe on questions</Text>
|
||||||
|
<Text style={styles.tipSubtext}>Answer yes, no, or maybe</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</GlassCard>
|
||||||
|
<GlassCard style={styles.tipCard}>
|
||||||
|
<View style={styles.tipRow}>
|
||||||
|
<View style={styles.tipIcon}>
|
||||||
|
<Ionicons name="heart-outline" size={18} color={colors.primary} />
|
||||||
|
</View>
|
||||||
|
<View style={styles.tipContent}>
|
||||||
|
<Text style={styles.tipText}>Discover matches</Text>
|
||||||
|
<Text style={styles.tipSubtext}>See where you both agree</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</GlassCard>
|
||||||
|
</Animated.View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View style={styles.bottomSpacer} />
|
||||||
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</GradientBackground>
|
</GradientBackground>
|
||||||
);
|
);
|
||||||
@@ -154,8 +292,19 @@ const styles = StyleSheet.create({
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
contentContainer: {
|
contentContainer: {
|
||||||
|
flexGrow: 1,
|
||||||
paddingBottom: Platform.OS === 'ios' ? 100 : 80,
|
paddingBottom: Platform.OS === 'ios' ? 100 : 80,
|
||||||
},
|
},
|
||||||
|
contentContainerWide: {
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
innerContainer: {
|
||||||
|
flex: 1,
|
||||||
|
width: '100%',
|
||||||
|
},
|
||||||
|
innerContainerWide: {
|
||||||
|
maxWidth: MAX_CONTENT_WIDTH,
|
||||||
|
},
|
||||||
header: {
|
header: {
|
||||||
paddingTop: 60,
|
paddingTop: 60,
|
||||||
paddingHorizontal: spacing.lg,
|
paddingHorizontal: spacing.lg,
|
||||||
@@ -164,25 +313,61 @@ const styles = StyleSheet.create({
|
|||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
},
|
},
|
||||||
|
headerLeft: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
greeting: {
|
greeting: {
|
||||||
...typography.title1,
|
...typography.title1,
|
||||||
color: colors.text,
|
color: colors.text,
|
||||||
},
|
},
|
||||||
partnerText: {
|
partnerBadge: {
|
||||||
...typography.subhead,
|
flexDirection: 'row',
|
||||||
color: colors.textSecondary,
|
alignItems: 'center',
|
||||||
marginTop: spacing.xs,
|
backgroundColor: colors.primaryLight,
|
||||||
|
paddingHorizontal: spacing.sm,
|
||||||
|
paddingVertical: spacing.xs,
|
||||||
|
borderRadius: radius.full,
|
||||||
|
alignSelf: 'flex-start',
|
||||||
|
marginTop: spacing.sm,
|
||||||
|
gap: spacing.xs,
|
||||||
},
|
},
|
||||||
profileButton: {
|
partnerText: {
|
||||||
width: 44,
|
...typography.caption1,
|
||||||
height: 44,
|
color: colors.text,
|
||||||
borderRadius: 22,
|
fontWeight: '500',
|
||||||
backgroundColor: colors.primary,
|
},
|
||||||
|
pairBadge: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: colors.glass.background,
|
||||||
|
paddingHorizontal: spacing.sm,
|
||||||
|
paddingVertical: spacing.xs,
|
||||||
|
borderRadius: radius.full,
|
||||||
|
alignSelf: 'flex-start',
|
||||||
|
marginTop: spacing.sm,
|
||||||
|
gap: spacing.xs,
|
||||||
|
},
|
||||||
|
pairText: {
|
||||||
|
...typography.caption1,
|
||||||
|
color: colors.textTertiary,
|
||||||
|
},
|
||||||
|
avatarGradient: {
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
borderRadius: 24,
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
...shadows.md,
|
...shadows.md,
|
||||||
},
|
},
|
||||||
profileButtonText: {
|
avatarInner: {
|
||||||
|
width: 42,
|
||||||
|
height: 42,
|
||||||
|
borderRadius: 21,
|
||||||
|
backgroundColor: colors.background,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
avatarText: {
|
||||||
...typography.headline,
|
...typography.headline,
|
||||||
color: colors.text,
|
color: colors.text,
|
||||||
},
|
},
|
||||||
@@ -191,34 +376,35 @@ const styles = StyleSheet.create({
|
|||||||
paddingHorizontal: spacing.lg,
|
paddingHorizontal: spacing.lg,
|
||||||
gap: spacing.sm,
|
gap: spacing.sm,
|
||||||
},
|
},
|
||||||
statCard: {
|
statCardWrapper: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
alignItems: "center",
|
|
||||||
paddingVertical: spacing.md,
|
|
||||||
},
|
},
|
||||||
statIconContainer: {
|
statContent: {
|
||||||
width: 40,
|
alignItems: "center",
|
||||||
height: 40,
|
paddingVertical: spacing.sm,
|
||||||
borderRadius: 20,
|
},
|
||||||
backgroundColor: colors.primaryLight,
|
statIcon: {
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
borderRadius: 18,
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
marginBottom: spacing.sm,
|
marginBottom: spacing.sm,
|
||||||
},
|
},
|
||||||
statIconHighlight: {
|
|
||||||
backgroundColor: colors.primary,
|
|
||||||
},
|
|
||||||
statNumber: {
|
statNumber: {
|
||||||
...typography.title2,
|
...typography.title2,
|
||||||
color: colors.text,
|
color: colors.text,
|
||||||
},
|
},
|
||||||
statLabel: {
|
statLabel: {
|
||||||
...typography.caption1,
|
...typography.caption2,
|
||||||
color: colors.textSecondary,
|
color: colors.textSecondary,
|
||||||
marginTop: spacing.xs,
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
ctaSection: {
|
||||||
|
paddingHorizontal: spacing.lg,
|
||||||
|
marginTop: spacing.lg,
|
||||||
},
|
},
|
||||||
ctaCard: {
|
ctaCard: {
|
||||||
margin: spacing.lg,
|
|
||||||
borderRadius: radius.xl,
|
borderRadius: radius.xl,
|
||||||
padding: spacing.lg,
|
padding: spacing.lg,
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
@@ -231,16 +417,17 @@ const styles = StyleSheet.create({
|
|||||||
ctaTitle: {
|
ctaTitle: {
|
||||||
...typography.title3,
|
...typography.title3,
|
||||||
color: colors.text,
|
color: colors.text,
|
||||||
|
marginBottom: spacing.xs,
|
||||||
},
|
},
|
||||||
ctaSubtitle: {
|
ctaSubtitle: {
|
||||||
...typography.subhead,
|
...typography.subhead,
|
||||||
color: "rgba(255,255,255,0.8)",
|
color: "rgba(255,255,255,0.8)",
|
||||||
marginTop: spacing.xs,
|
lineHeight: 20,
|
||||||
},
|
},
|
||||||
ctaIconContainer: {
|
ctaIconContainer: {
|
||||||
width: 44,
|
width: 48,
|
||||||
height: 44,
|
height: 48,
|
||||||
borderRadius: 22,
|
borderRadius: 24,
|
||||||
backgroundColor: "rgba(255,255,255,0.2)",
|
backgroundColor: "rgba(255,255,255,0.2)",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
@@ -248,7 +435,7 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
section: {
|
section: {
|
||||||
paddingHorizontal: spacing.lg,
|
paddingHorizontal: spacing.lg,
|
||||||
marginTop: spacing.sm,
|
marginTop: spacing.xl,
|
||||||
},
|
},
|
||||||
sectionHeader: {
|
sectionHeader: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
@@ -260,20 +447,26 @@ const styles = StyleSheet.create({
|
|||||||
...typography.headline,
|
...typography.headline,
|
||||||
color: colors.text,
|
color: colors.text,
|
||||||
},
|
},
|
||||||
seeAll: {
|
seeAllButton: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 2,
|
||||||
|
},
|
||||||
|
seeAllText: {
|
||||||
...typography.subhead,
|
...typography.subhead,
|
||||||
color: colors.primary,
|
color: colors.primary,
|
||||||
},
|
},
|
||||||
matchItem: {
|
matchCard: {
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
marginBottom: spacing.sm,
|
marginBottom: spacing.sm,
|
||||||
},
|
},
|
||||||
|
matchRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
matchIcon: {
|
matchIcon: {
|
||||||
width: 36,
|
width: 32,
|
||||||
height: 36,
|
height: 32,
|
||||||
borderRadius: 18,
|
borderRadius: 16,
|
||||||
backgroundColor: colors.primaryLight,
|
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
marginRight: spacing.md,
|
marginRight: spacing.md,
|
||||||
@@ -289,6 +482,43 @@ const styles = StyleSheet.create({
|
|||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
backgroundColor: colors.primary,
|
backgroundColor: colors.primary,
|
||||||
},
|
},
|
||||||
|
tipsTitle: {
|
||||||
|
...typography.caption1,
|
||||||
|
color: colors.textTertiary,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: 1.5,
|
||||||
|
marginBottom: spacing.sm,
|
||||||
|
marginLeft: spacing.xs,
|
||||||
|
},
|
||||||
|
tipCard: {
|
||||||
|
marginTop: spacing.sm,
|
||||||
|
},
|
||||||
|
tipRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
tipIcon: {
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
borderRadius: 18,
|
||||||
|
backgroundColor: colors.primaryLight,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginRight: spacing.md,
|
||||||
|
},
|
||||||
|
tipContent: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
tipText: {
|
||||||
|
...typography.body,
|
||||||
|
color: colors.text,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
tipSubtext: {
|
||||||
|
...typography.caption1,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
bottomSpacer: {
|
bottomSpacer: {
|
||||||
height: spacing.lg,
|
height: spacing.lg,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,20 +1,28 @@
|
|||||||
import { View, Text, StyleSheet, FlatList, RefreshControl, TouchableOpacity, ActivityIndicator, Platform } from "react-native";
|
import { View, Text, StyleSheet, FlatList, RefreshControl, TouchableOpacity, ActivityIndicator, Platform, useWindowDimensions } from "react-native";
|
||||||
import { useMatchStore, useAuthStore } from "../../src/store";
|
import { useMatchStore, useAuthStore } from "../../src/store";
|
||||||
import { useEffect } from "react";
|
import { useEffect, useCallback } from "react";
|
||||||
|
import { useFocusEffect } from "expo-router";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
|
import { LinearGradient } from "expo-linear-gradient";
|
||||||
import Animated, { FadeInDown, FadeInRight } from "react-native-reanimated";
|
import Animated, { FadeInDown, FadeInRight } from "react-native-reanimated";
|
||||||
import { GradientBackground, GlassCard } from "../../src/components/ui";
|
import { GradientBackground, GlassCard } from "../../src/components/ui";
|
||||||
import { colors, spacing, typography, radius } from "../../src/theme";
|
import { colors, gradients, spacing, typography, radius, shadows } from "../../src/theme";
|
||||||
|
|
||||||
|
const MAX_CONTENT_WIDTH = 500;
|
||||||
|
|
||||||
export default function MatchesScreen() {
|
export default function MatchesScreen() {
|
||||||
const { matches, fetchMatches, markAllAsSeen } = useMatchStore();
|
const { matches, fetchMatches, markAllAsSeen } = useMatchStore();
|
||||||
const { user } = useAuthStore();
|
const { user } = useAuthStore();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { width } = useWindowDimensions();
|
||||||
|
const isWideScreen = width > MAX_CONTENT_WIDTH;
|
||||||
|
|
||||||
useEffect(() => {
|
useFocusEffect(
|
||||||
fetchMatches();
|
useCallback(() => {
|
||||||
}, []);
|
fetchMatches();
|
||||||
|
}, [])
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (matches.length > 0) {
|
if (matches.length > 0) {
|
||||||
@@ -45,38 +53,52 @@ export default function MatchesScreen() {
|
|||||||
<Animated.View entering={FadeInRight.delay(index * 50).duration(300)}>
|
<Animated.View entering={FadeInRight.delay(index * 50).duration(300)}>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => router.push(`/chat/${item.id}`)}
|
onPress={() => router.push(`/chat/${item.id}`)}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.8}
|
||||||
>
|
>
|
||||||
<GlassCard style={styles.matchCard}>
|
<GlassCard style={styles.matchCard}>
|
||||||
<View style={styles.iconContainer}>
|
<View style={styles.matchRow}>
|
||||||
<Ionicons
|
<LinearGradient
|
||||||
name={isYesYes ? "heart" : "heart-half"}
|
colors={isYesYes ? gradients.primary as [string, string] : [colors.glass.background, colors.glass.background]}
|
||||||
size={24}
|
style={styles.iconContainer}
|
||||||
color={colors.primary}
|
start={{ x: 0, y: 0 }}
|
||||||
/>
|
end={{ x: 1, y: 1 }}
|
||||||
</View>
|
>
|
||||||
<View style={styles.content}>
|
<Ionicons
|
||||||
<Text style={styles.questionText} numberOfLines={2}>
|
name={isYesYes ? "heart" : "heart-half"}
|
||||||
{userText}
|
size={22}
|
||||||
</Text>
|
color={isYesYes ? colors.text : colors.primary}
|
||||||
{item.question.partner_text && (
|
/>
|
||||||
<Text style={styles.partnerText} numberOfLines={1}>
|
</LinearGradient>
|
||||||
Partner: {partnerText}
|
<View style={styles.content}>
|
||||||
|
<Text style={styles.questionText} numberOfLines={2}>
|
||||||
|
{userText}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
{item.question.partner_text && (
|
||||||
<View style={styles.metaContainer}>
|
<Text style={styles.partnerText} numberOfLines={1}>
|
||||||
<View style={[styles.tag, isYesYes && styles.tagHighlight]}>
|
Partner: {partnerText}
|
||||||
<Text style={[styles.tagText, isYesYes && styles.tagTextHighlight]}>
|
</Text>
|
||||||
{isYesYes ? "YES + YES" : "YES + MAYBE"}
|
)}
|
||||||
|
<View style={styles.metaRow}>
|
||||||
|
<View style={[styles.tag, isYesYes && styles.tagHighlight]}>
|
||||||
|
<Text style={[styles.tagText, isYesYes && styles.tagTextHighlight]}>
|
||||||
|
{isYesYes ? "YES + YES" : "YES + MAYBE"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.date}>
|
||||||
|
{new Date(item.created_at).toLocaleDateString()}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<Text style={styles.date}>
|
|
||||||
{new Date(item.created_at).toLocaleDateString()}
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
<View style={styles.rightSection}>
|
||||||
<View style={styles.chevronContainer}>
|
{item.unreadCount > 0 && (
|
||||||
<Ionicons name="chevron-forward" size={20} color={colors.textTertiary} />
|
<View style={styles.unreadBadge}>
|
||||||
|
<Ionicons name="chatbubble" size={12} color={colors.text} />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<View style={styles.chevronContainer}>
|
||||||
|
<Ionicons name="chevron-forward" size={18} color={colors.textTertiary} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</GlassCard>
|
</GlassCard>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -99,11 +121,17 @@ export default function MatchesScreen() {
|
|||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<Animated.View
|
<Animated.View
|
||||||
entering={FadeInDown.duration(400)}
|
entering={FadeInDown.duration(400)}
|
||||||
style={styles.header}
|
style={[styles.header, isWideScreen && styles.headerWide]}
|
||||||
>
|
>
|
||||||
<Text style={styles.title}>Your Matches</Text>
|
<View style={styles.headerTop}>
|
||||||
|
<Text style={styles.title}>Matches</Text>
|
||||||
|
<View style={styles.countBadge}>
|
||||||
|
<Ionicons name="heart" size={14} color={colors.primary} />
|
||||||
|
<Text style={styles.countText}>{matches.length}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
<Text style={styles.subtitle}>
|
<Text style={styles.subtitle}>
|
||||||
{matches.length} {matches.length === 1 ? 'match' : 'matches'} found
|
Discover what you both enjoy together
|
||||||
</Text>
|
</Text>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|
||||||
@@ -111,7 +139,7 @@ export default function MatchesScreen() {
|
|||||||
data={matches}
|
data={matches}
|
||||||
renderItem={renderItem}
|
renderItem={renderItem}
|
||||||
keyExtractor={(item) => item.id}
|
keyExtractor={(item) => item.id}
|
||||||
contentContainerStyle={styles.list}
|
contentContainerStyle={[styles.list, isWideScreen && styles.listWide]}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
refreshControl={
|
refreshControl={
|
||||||
<RefreshControl
|
<RefreshControl
|
||||||
@@ -126,13 +154,35 @@ export default function MatchesScreen() {
|
|||||||
entering={FadeInDown.delay(200).duration(400)}
|
entering={FadeInDown.delay(200).duration(400)}
|
||||||
style={styles.emptyContainer}
|
style={styles.emptyContainer}
|
||||||
>
|
>
|
||||||
<View style={styles.emptyIconContainer}>
|
<LinearGradient
|
||||||
<Ionicons name="heart-outline" size={48} color={colors.textTertiary} />
|
colors={gradients.primary as [string, string]}
|
||||||
</View>
|
style={styles.emptyIconGradient}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 1 }}
|
||||||
|
>
|
||||||
|
<View style={styles.emptyIconInner}>
|
||||||
|
<Ionicons name="heart-outline" size={40} color={colors.primary} />
|
||||||
|
</View>
|
||||||
|
</LinearGradient>
|
||||||
<Text style={styles.emptyTitle}>No matches yet</Text>
|
<Text style={styles.emptyTitle}>No matches yet</Text>
|
||||||
<Text style={styles.emptyText}>
|
<Text style={styles.emptyText}>
|
||||||
Keep swiping to find things you both enjoy!
|
Keep swiping to find things you both enjoy!
|
||||||
</Text>
|
</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.emptyButton}
|
||||||
|
onPress={() => router.push("/(app)/swipe")}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
>
|
||||||
|
<LinearGradient
|
||||||
|
colors={gradients.primary as [string, string]}
|
||||||
|
style={styles.emptyButtonGradient}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 1 }}
|
||||||
|
>
|
||||||
|
<Ionicons name="play" size={18} color={colors.text} />
|
||||||
|
<Text style={styles.emptyButtonText}>Start Swiping</Text>
|
||||||
|
</LinearGradient>
|
||||||
|
</TouchableOpacity>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -153,12 +203,36 @@ const styles = StyleSheet.create({
|
|||||||
header: {
|
header: {
|
||||||
paddingTop: 60,
|
paddingTop: 60,
|
||||||
paddingHorizontal: spacing.lg,
|
paddingHorizontal: spacing.lg,
|
||||||
paddingBottom: spacing.md,
|
paddingBottom: spacing.lg,
|
||||||
|
},
|
||||||
|
headerWide: {
|
||||||
|
alignSelf: 'center',
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: MAX_CONTENT_WIDTH,
|
||||||
|
},
|
||||||
|
headerTop: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
},
|
},
|
||||||
title: {
|
title: {
|
||||||
...typography.title1,
|
...typography.title1,
|
||||||
color: colors.text,
|
color: colors.text,
|
||||||
},
|
},
|
||||||
|
countBadge: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: colors.primaryLight,
|
||||||
|
paddingHorizontal: spacing.sm,
|
||||||
|
paddingVertical: spacing.xs,
|
||||||
|
borderRadius: radius.full,
|
||||||
|
gap: spacing.xs,
|
||||||
|
},
|
||||||
|
countText: {
|
||||||
|
...typography.subhead,
|
||||||
|
color: colors.text,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
subtitle: {
|
subtitle: {
|
||||||
...typography.subhead,
|
...typography.subhead,
|
||||||
color: colors.textSecondary,
|
color: colors.textSecondary,
|
||||||
@@ -166,18 +240,25 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
list: {
|
list: {
|
||||||
padding: spacing.lg,
|
padding: spacing.lg,
|
||||||
|
paddingTop: 0,
|
||||||
paddingBottom: Platform.OS === 'ios' ? 120 : 100,
|
paddingBottom: Platform.OS === 'ios' ? 120 : 100,
|
||||||
},
|
},
|
||||||
|
listWide: {
|
||||||
|
alignSelf: 'center',
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: MAX_CONTENT_WIDTH,
|
||||||
|
},
|
||||||
matchCard: {
|
matchCard: {
|
||||||
|
marginBottom: spacing.sm,
|
||||||
|
},
|
||||||
|
matchRow: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
marginBottom: spacing.sm,
|
|
||||||
},
|
},
|
||||||
iconContainer: {
|
iconContainer: {
|
||||||
width: 48,
|
width: 48,
|
||||||
height: 48,
|
height: 48,
|
||||||
borderRadius: 24,
|
borderRadius: 24,
|
||||||
backgroundColor: colors.primaryLight,
|
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
marginRight: spacing.md,
|
marginRight: spacing.md,
|
||||||
@@ -190,14 +271,15 @@ const styles = StyleSheet.create({
|
|||||||
color: colors.text,
|
color: colors.text,
|
||||||
fontWeight: "600",
|
fontWeight: "600",
|
||||||
marginBottom: spacing.xs,
|
marginBottom: spacing.xs,
|
||||||
|
lineHeight: 22,
|
||||||
},
|
},
|
||||||
partnerText: {
|
partnerText: {
|
||||||
...typography.subhead,
|
...typography.caption1,
|
||||||
color: colors.textSecondary,
|
color: colors.textSecondary,
|
||||||
fontStyle: "italic",
|
fontStyle: "italic",
|
||||||
marginBottom: spacing.sm,
|
marginBottom: spacing.sm,
|
||||||
},
|
},
|
||||||
metaContainer: {
|
metaRow: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
@@ -205,8 +287,8 @@ const styles = StyleSheet.create({
|
|||||||
tag: {
|
tag: {
|
||||||
backgroundColor: colors.glass.background,
|
backgroundColor: colors.glass.background,
|
||||||
paddingHorizontal: spacing.sm,
|
paddingHorizontal: spacing.sm,
|
||||||
paddingVertical: spacing.xs,
|
paddingVertical: 4,
|
||||||
borderRadius: radius.xs,
|
borderRadius: radius.sm,
|
||||||
},
|
},
|
||||||
tagHighlight: {
|
tagHighlight: {
|
||||||
backgroundColor: colors.primaryLight,
|
backgroundColor: colors.primaryLight,
|
||||||
@@ -214,39 +296,86 @@ const styles = StyleSheet.create({
|
|||||||
tagText: {
|
tagText: {
|
||||||
...typography.caption2,
|
...typography.caption2,
|
||||||
color: colors.textSecondary,
|
color: colors.textSecondary,
|
||||||
fontWeight: "600",
|
fontWeight: "700",
|
||||||
|
letterSpacing: 0.5,
|
||||||
},
|
},
|
||||||
tagTextHighlight: {
|
tagTextHighlight: {
|
||||||
color: colors.primary,
|
color: colors.primary,
|
||||||
},
|
},
|
||||||
date: {
|
date: {
|
||||||
...typography.caption1,
|
...typography.caption2,
|
||||||
color: colors.textTertiary,
|
color: colors.textTertiary,
|
||||||
},
|
},
|
||||||
chevronContainer: {
|
rightSection: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: spacing.sm,
|
||||||
marginLeft: spacing.sm,
|
marginLeft: spacing.sm,
|
||||||
},
|
},
|
||||||
|
unreadBadge: {
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
borderRadius: 12,
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
chevronContainer: {
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
borderRadius: 14,
|
||||||
|
backgroundColor: colors.glass.background,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
emptyContainer: {
|
emptyContainer: {
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
paddingVertical: spacing.xxl,
|
paddingVertical: spacing.xxl,
|
||||||
|
paddingHorizontal: spacing.lg,
|
||||||
},
|
},
|
||||||
emptyIconContainer: {
|
emptyIconGradient: {
|
||||||
width: 80,
|
width: 100,
|
||||||
height: 80,
|
height: 100,
|
||||||
borderRadius: 40,
|
borderRadius: 50,
|
||||||
backgroundColor: colors.glass.background,
|
justifyContent: 'center',
|
||||||
justifyContent: "center",
|
alignItems: 'center',
|
||||||
alignItems: "center",
|
marginBottom: spacing.lg,
|
||||||
marginBottom: spacing.md,
|
...shadows.lg,
|
||||||
|
},
|
||||||
|
emptyIconInner: {
|
||||||
|
width: 88,
|
||||||
|
height: 88,
|
||||||
|
borderRadius: 44,
|
||||||
|
backgroundColor: colors.background,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
},
|
},
|
||||||
emptyTitle: {
|
emptyTitle: {
|
||||||
...typography.title3,
|
...typography.title2,
|
||||||
color: colors.text,
|
color: colors.text,
|
||||||
marginBottom: spacing.xs,
|
marginBottom: spacing.sm,
|
||||||
},
|
},
|
||||||
emptyText: {
|
emptyText: {
|
||||||
...typography.body,
|
...typography.body,
|
||||||
color: colors.textSecondary,
|
color: colors.textSecondary,
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
|
marginBottom: spacing.xl,
|
||||||
|
},
|
||||||
|
emptyButton: {
|
||||||
|
borderRadius: radius.full,
|
||||||
|
overflow: 'hidden',
|
||||||
|
...shadows.md,
|
||||||
|
},
|
||||||
|
emptyButtonGradient: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: spacing.lg,
|
||||||
|
paddingVertical: spacing.md,
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
emptyButtonText: {
|
||||||
|
...typography.body,
|
||||||
|
color: colors.text,
|
||||||
|
fontWeight: '600',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -1,67 +1,184 @@
|
|||||||
import { View, Text, StyleSheet, FlatList, TouchableOpacity, Switch, RefreshControl, Platform } from "react-native";
|
import { View, Text, StyleSheet, FlatList, TouchableOpacity, Switch, RefreshControl, Platform } from "react-native";
|
||||||
import { usePacksStore } from "../../src/store";
|
import { usePacksStore, useAuthStore, useSubscriptionStore } from "../../src/store";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { router } from "expo-router";
|
import { router } from "expo-router";
|
||||||
import { useEffect } from "react";
|
import { useEffect, useMemo, useState, useCallback } from "react";
|
||||||
import Animated, { FadeInDown, FadeInRight } from "react-native-reanimated";
|
import Animated, { FadeInDown, FadeInRight, FadeIn, FadeOut, Layout } from "react-native-reanimated";
|
||||||
import { GradientBackground, GlassCard } from "../../src/components/ui";
|
import { GradientBackground, GlassCard } from "../../src/components/ui";
|
||||||
|
import { Paywall } from "../../src/components/Paywall";
|
||||||
import { colors, spacing, typography, radius } from "../../src/theme";
|
import { colors, spacing, typography, radius } from "../../src/theme";
|
||||||
|
import type { QuestionPack, Category } from "../../src/types";
|
||||||
|
|
||||||
|
interface CategoryWithPacks extends Category {
|
||||||
|
packs: QuestionPack[];
|
||||||
|
}
|
||||||
|
|
||||||
export default function PacksScreen() {
|
export default function PacksScreen() {
|
||||||
const { packs, enabledPackIds, togglePack, isLoading, fetchPacks } = usePacksStore();
|
const { packs, categories, enabledPackIds, togglePack, isLoading, fetchPacks } = usePacksStore();
|
||||||
|
const { user, partner } = useAuthStore();
|
||||||
|
const { subscription } = useSubscriptionStore();
|
||||||
|
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set());
|
||||||
|
const [showPaywall, setShowPaywall] = useState(false);
|
||||||
|
|
||||||
|
// Check if user or partner has premium access
|
||||||
|
const hasPremiumAccess = useMemo(() => {
|
||||||
|
return user?.is_premium || partner?.is_premium || subscription.isProUser;
|
||||||
|
}, [user?.is_premium, partner?.is_premium, subscription.isProUser]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchPacks();
|
fetchPacks();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const enabledCount = packs.filter(p => enabledPackIds.includes(p.id)).length;
|
// Handle toggling a pack - show paywall if premium pack without access
|
||||||
|
const handleTogglePack = useCallback((pack: QuestionPack) => {
|
||||||
|
if (pack.is_premium && !hasPremiumAccess) {
|
||||||
|
setShowPaywall(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
togglePack(pack.id);
|
||||||
|
}, [hasPremiumAccess, togglePack]);
|
||||||
|
|
||||||
const renderItem = ({ item, index }: { item: any; index: number }) => {
|
const enabledPacks = packs.filter(p => enabledPackIds.includes(p.id));
|
||||||
|
const enabledPackCount = enabledPacks.length;
|
||||||
|
const totalQuestions = enabledPacks.reduce((sum, p) => sum + (p.questions?.[0]?.count || 0), 0);
|
||||||
|
|
||||||
|
// Group packs by category
|
||||||
|
const categoriesWithPacks = useMemo((): CategoryWithPacks[] => {
|
||||||
|
return categories
|
||||||
|
.map(category => ({
|
||||||
|
...category,
|
||||||
|
packs: packs.filter(p => p.category_id === category.id)
|
||||||
|
}))
|
||||||
|
.filter(cat => cat.packs.length > 0);
|
||||||
|
}, [packs, categories]);
|
||||||
|
|
||||||
|
const toggleCategory = useCallback((categoryId: string) => {
|
||||||
|
setExpandedCategories(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(categoryId)) {
|
||||||
|
next.delete(categoryId);
|
||||||
|
} else {
|
||||||
|
next.add(categoryId);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const renderPack = (item: QuestionPack, index: number) => {
|
||||||
const isEnabled = enabledPackIds.includes(item.id);
|
const isEnabled = enabledPackIds.includes(item.id);
|
||||||
|
const isPremiumLocked = item.is_premium && !hasPremiumAccess;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Animated.View entering={FadeInRight.delay(index * 50).duration(300)}>
|
<Animated.View
|
||||||
<GlassCard style={styles.card}>
|
key={item.id}
|
||||||
|
entering={FadeIn.delay(index * 50).duration(200)}
|
||||||
|
exiting={FadeOut.duration(150)}
|
||||||
|
layout={Layout.springify()}
|
||||||
|
>
|
||||||
|
<View style={styles.packItem}>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.cardContent}
|
style={styles.packContent}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
onPress={() => router.push(`/pack/${item.id}`)}
|
onPress={() => {
|
||||||
|
if (isPremiumLocked) {
|
||||||
|
setShowPaywall(true);
|
||||||
|
} else {
|
||||||
|
router.push(`/pack/${item.id}`);
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<View style={[styles.iconContainer, isEnabled && styles.iconContainerActive]}>
|
<View style={[
|
||||||
<Text style={styles.emoji}>{item.icon || "📦"}</Text>
|
styles.packIcon,
|
||||||
|
isEnabled && styles.packIconActive,
|
||||||
|
isPremiumLocked && styles.packIconLocked
|
||||||
|
]}>
|
||||||
|
<Text style={styles.packEmoji}>{item.icon || "📦"}</Text>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.content}>
|
<View style={styles.packInfo}>
|
||||||
<View style={styles.headerRow}>
|
<View style={styles.packHeader}>
|
||||||
<Text style={styles.name}>{item.name}</Text>
|
<Text style={[
|
||||||
|
styles.packName,
|
||||||
|
isPremiumLocked && styles.packNameLocked
|
||||||
|
]}>
|
||||||
|
{item.name}
|
||||||
|
</Text>
|
||||||
{item.is_premium && (
|
{item.is_premium && (
|
||||||
<View style={styles.badge}>
|
<View style={[
|
||||||
<Ionicons name="star" size={10} color={colors.text} />
|
styles.badge,
|
||||||
|
isPremiumLocked && styles.badgeLocked
|
||||||
|
]}>
|
||||||
|
<Ionicons
|
||||||
|
name={isPremiumLocked ? "lock-closed" : "star"}
|
||||||
|
size={10}
|
||||||
|
color={colors.text}
|
||||||
|
/>
|
||||||
<Text style={styles.badgeText}>PRO</Text>
|
<Text style={styles.badgeText}>PRO</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
<Text style={styles.description} numberOfLines={2}>
|
<Text style={[
|
||||||
{item.description}
|
styles.packDescription,
|
||||||
|
isPremiumLocked && styles.packDescriptionLocked
|
||||||
|
]} numberOfLines={1}>
|
||||||
|
{isPremiumLocked ? "Upgrade to unlock" : item.description}
|
||||||
</Text>
|
</Text>
|
||||||
{item.question_count && (
|
|
||||||
<Text style={styles.questionCount}>
|
|
||||||
{item.question_count} questions
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<View style={styles.switchContainer}>
|
<Switch
|
||||||
<Switch
|
value={isEnabled}
|
||||||
value={isEnabled}
|
onValueChange={() => handleTogglePack(item)}
|
||||||
onValueChange={() => togglePack(item.id)}
|
disabled={isPremiumLocked}
|
||||||
trackColor={{
|
trackColor={{
|
||||||
false: colors.glass.background,
|
false: colors.glass.background,
|
||||||
true: colors.primary,
|
true: colors.primary,
|
||||||
}}
|
}}
|
||||||
thumbColor={colors.text}
|
thumbColor={colors.text}
|
||||||
ios_backgroundColor={colors.glass.background}
|
ios_backgroundColor={colors.glass.background}
|
||||||
|
style={isPremiumLocked ? { opacity: 0.4 } : undefined}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderCategory = ({ item: category, index }: { item: CategoryWithPacks; index: number }) => {
|
||||||
|
const isExpanded = expandedCategories.has(category.id);
|
||||||
|
const enabledInCategory = category.packs.filter(p => enabledPackIds.includes(p.id)).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View
|
||||||
|
entering={FadeInDown.delay(index * 100).duration(300)}
|
||||||
|
layout={Layout.springify()}
|
||||||
|
>
|
||||||
|
<GlassCard style={styles.categoryCard}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.categoryHeader}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
onPress={() => toggleCategory(category.id)}
|
||||||
|
>
|
||||||
|
<Text style={styles.categoryIcon}>{category.icon}</Text>
|
||||||
|
<View style={styles.categoryInfo}>
|
||||||
|
<Text style={styles.categoryName}>{category.name}</Text>
|
||||||
|
<Text style={styles.categoryMeta}>
|
||||||
|
{category.packs.length} packs · {enabledInCategory} enabled
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Ionicons
|
||||||
|
name={isExpanded ? "chevron-up" : "chevron-down"}
|
||||||
|
size={24}
|
||||||
|
color={colors.textSecondary}
|
||||||
/>
|
/>
|
||||||
</View>
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{isExpanded && (
|
||||||
|
<Animated.View
|
||||||
|
entering={FadeIn.duration(200)}
|
||||||
|
exiting={FadeOut.duration(150)}
|
||||||
|
style={styles.packsContainer}
|
||||||
|
>
|
||||||
|
{category.packs.map((pack, idx) => renderPack(pack, idx))}
|
||||||
|
</Animated.View>
|
||||||
|
)}
|
||||||
</GlassCard>
|
</GlassCard>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
);
|
);
|
||||||
@@ -76,13 +193,13 @@ export default function PacksScreen() {
|
|||||||
>
|
>
|
||||||
<Text style={styles.title}>Question Packs</Text>
|
<Text style={styles.title}>Question Packs</Text>
|
||||||
<Text style={styles.subtitle}>
|
<Text style={styles.subtitle}>
|
||||||
{enabledCount} of {packs.length} packs enabled
|
{totalQuestions} {totalQuestions === 1 ? 'question' : 'questions'} from {enabledPackCount} {enabledPackCount === 1 ? 'pack' : 'packs'} enabled
|
||||||
</Text>
|
</Text>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|
||||||
<FlatList
|
<FlatList
|
||||||
data={packs}
|
data={categoriesWithPacks}
|
||||||
renderItem={renderItem}
|
renderItem={renderCategory}
|
||||||
keyExtractor={(item) => item.id}
|
keyExtractor={(item) => item.id}
|
||||||
contentContainerStyle={styles.list}
|
contentContainerStyle={styles.list}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
@@ -109,6 +226,15 @@ export default function PacksScreen() {
|
|||||||
</Animated.View>
|
</Animated.View>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Paywall
|
||||||
|
visible={showPaywall}
|
||||||
|
onClose={() => setShowPaywall(false)}
|
||||||
|
onSuccess={() => {
|
||||||
|
// Refresh packs after successful purchase
|
||||||
|
fetchPacks();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
</GradientBackground>
|
</GradientBackground>
|
||||||
);
|
);
|
||||||
@@ -136,45 +262,88 @@ const styles = StyleSheet.create({
|
|||||||
padding: spacing.lg,
|
padding: spacing.lg,
|
||||||
paddingBottom: Platform.OS === 'ios' ? 120 : 100,
|
paddingBottom: Platform.OS === 'ios' ? 120 : 100,
|
||||||
},
|
},
|
||||||
card: {
|
categoryCard: {
|
||||||
|
marginBottom: spacing.md,
|
||||||
|
},
|
||||||
|
categoryHeader: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
marginBottom: spacing.sm,
|
|
||||||
},
|
},
|
||||||
cardContent: {
|
categoryIcon: {
|
||||||
|
fontSize: 32,
|
||||||
|
marginRight: spacing.md,
|
||||||
|
},
|
||||||
|
categoryInfo: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
categoryName: {
|
||||||
|
...typography.title3,
|
||||||
|
color: colors.text,
|
||||||
|
},
|
||||||
|
categoryMeta: {
|
||||||
|
...typography.caption1,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
packsContainer: {
|
||||||
|
marginTop: spacing.md,
|
||||||
|
paddingTop: spacing.md,
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: colors.glass.border,
|
||||||
|
},
|
||||||
|
packItem: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
paddingVertical: spacing.sm,
|
||||||
|
},
|
||||||
|
packContent: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
},
|
},
|
||||||
iconContainer: {
|
packIcon: {
|
||||||
width: 52,
|
width: 44,
|
||||||
height: 52,
|
height: 44,
|
||||||
borderRadius: 26,
|
borderRadius: 22,
|
||||||
backgroundColor: colors.glass.background,
|
backgroundColor: colors.glass.background,
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
marginRight: spacing.md,
|
marginRight: spacing.md,
|
||||||
},
|
},
|
||||||
iconContainerActive: {
|
packIconActive: {
|
||||||
backgroundColor: colors.primaryLight,
|
backgroundColor: colors.primaryLight,
|
||||||
},
|
},
|
||||||
emoji: {
|
packIconLocked: {
|
||||||
fontSize: 26,
|
opacity: 0.5,
|
||||||
},
|
},
|
||||||
content: {
|
packEmoji: {
|
||||||
|
fontSize: 22,
|
||||||
|
},
|
||||||
|
packInfo: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
marginRight: spacing.sm,
|
marginRight: spacing.sm,
|
||||||
},
|
},
|
||||||
headerRow: {
|
packHeader: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
marginBottom: spacing.xs,
|
marginBottom: 2,
|
||||||
},
|
},
|
||||||
name: {
|
packName: {
|
||||||
...typography.headline,
|
...typography.headline,
|
||||||
color: colors.text,
|
color: colors.text,
|
||||||
marginRight: spacing.sm,
|
marginRight: spacing.sm,
|
||||||
},
|
},
|
||||||
|
packNameLocked: {
|
||||||
|
color: colors.textSecondary,
|
||||||
|
},
|
||||||
|
packDescription: {
|
||||||
|
...typography.caption1,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
},
|
||||||
|
packDescriptionLocked: {
|
||||||
|
color: colors.textTertiary,
|
||||||
|
fontStyle: "italic",
|
||||||
|
},
|
||||||
badge: {
|
badge: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
@@ -184,24 +353,14 @@ const styles = StyleSheet.create({
|
|||||||
borderRadius: radius.xs,
|
borderRadius: radius.xs,
|
||||||
gap: 2,
|
gap: 2,
|
||||||
},
|
},
|
||||||
|
badgeLocked: {
|
||||||
|
backgroundColor: colors.glass.backgroundLight,
|
||||||
|
},
|
||||||
badgeText: {
|
badgeText: {
|
||||||
...typography.caption2,
|
...typography.caption2,
|
||||||
color: colors.text,
|
color: colors.text,
|
||||||
fontWeight: "700",
|
fontWeight: "700",
|
||||||
},
|
},
|
||||||
description: {
|
|
||||||
...typography.subhead,
|
|
||||||
color: colors.textSecondary,
|
|
||||||
lineHeight: 20,
|
|
||||||
},
|
|
||||||
questionCount: {
|
|
||||||
...typography.caption1,
|
|
||||||
color: colors.textTertiary,
|
|
||||||
marginTop: spacing.xs,
|
|
||||||
},
|
|
||||||
switchContainer: {
|
|
||||||
paddingLeft: spacing.sm,
|
|
||||||
},
|
|
||||||
emptyContainer: {
|
emptyContainer: {
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
paddingVertical: spacing.xxl,
|
paddingVertical: spacing.xxl,
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { supabase } from "../../src/lib/supabase";
|
|||||||
import { router } from "expo-router";
|
import { router } from "expo-router";
|
||||||
|
|
||||||
export default function PairingScreen() {
|
export default function PairingScreen() {
|
||||||
const { user, fetchCouple, fetchUser, couple, partner, isLoading: isAuthLoading } = useAuthStore();
|
const { user, fetchCouple, fetchUser, couple, partner, isLoading: isAuthLoading, signOut } = useAuthStore();
|
||||||
const [inviteCode, setInviteCode] = useState("");
|
const [inviteCode, setInviteCode] = useState("");
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
@@ -29,6 +29,41 @@ export default function PairingScreen() {
|
|||||||
}
|
}
|
||||||
}, [couple, partner]);
|
}, [couple, partner]);
|
||||||
|
|
||||||
|
// Subscribe to real-time updates for partner joining + polling fallback
|
||||||
|
useEffect(() => {
|
||||||
|
if (!couple || partner) return;
|
||||||
|
|
||||||
|
// Poll every 5 seconds as fallback
|
||||||
|
const pollInterval = setInterval(() => {
|
||||||
|
fetchCouple();
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
// Listen for new profiles joining this couple
|
||||||
|
const subscription = supabase
|
||||||
|
.channel(`couple-${couple.id}`)
|
||||||
|
.on(
|
||||||
|
"postgres_changes",
|
||||||
|
{
|
||||||
|
event: "UPDATE",
|
||||||
|
schema: "public",
|
||||||
|
table: "profiles",
|
||||||
|
filter: `couple_id=eq.${couple.id}`,
|
||||||
|
},
|
||||||
|
async (payload) => {
|
||||||
|
// Someone updated their profile to join this couple
|
||||||
|
if (payload.new.id !== user?.id) {
|
||||||
|
await fetchCouple();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.subscribe();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearInterval(pollInterval);
|
||||||
|
subscription.unsubscribe();
|
||||||
|
};
|
||||||
|
}, [couple, partner, user?.id, fetchCouple]);
|
||||||
|
|
||||||
const handleCreateCouple = async () => {
|
const handleCreateCouple = async () => {
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
try {
|
try {
|
||||||
@@ -103,6 +138,21 @@ export default function PairingScreen() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSignOut = () => {
|
||||||
|
Alert.alert(
|
||||||
|
"Sign Out",
|
||||||
|
"Are you sure you want to sign out?",
|
||||||
|
[
|
||||||
|
{ text: "Cancel", style: "cancel" },
|
||||||
|
{
|
||||||
|
text: "Sign Out",
|
||||||
|
style: "destructive",
|
||||||
|
onPress: signOut,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
if (isAuthLoading) {
|
if (isAuthLoading) {
|
||||||
return (
|
return (
|
||||||
<View style={[styles.container, styles.centerContent]}>
|
<View style={[styles.container, styles.centerContent]}>
|
||||||
@@ -138,6 +188,11 @@ export default function PairingScreen() {
|
|||||||
Waiting for your partner to join...
|
Waiting for your partner to join...
|
||||||
</Text>
|
</Text>
|
||||||
<ActivityIndicator size="small" color="#e94560" style={{ marginTop: 16 }} />
|
<ActivityIndicator size="small" color="#e94560" style={{ marginTop: 16 }} />
|
||||||
|
|
||||||
|
<TouchableOpacity style={styles.signOutButton} onPress={handleSignOut}>
|
||||||
|
<Ionicons name="log-out-outline" size={20} color="#888" />
|
||||||
|
<Text style={styles.signOutText}>Sign Out</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -192,6 +247,11 @@ export default function PairingScreen() {
|
|||||||
{isSubmitting ? "Creating..." : "Create New Code"}
|
{isSubmitting ? "Creating..." : "Create New Code"}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity style={styles.signOutButton} onPress={handleSignOut}>
|
||||||
|
<Ionicons name="log-out-outline" size={20} color="#888" />
|
||||||
|
<Text style={styles.signOutText}>Sign Out</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</KeyboardAvoidingView>
|
</KeyboardAvoidingView>
|
||||||
);
|
);
|
||||||
@@ -334,4 +394,15 @@ const styles = StyleSheet.create({
|
|||||||
color: "#666",
|
color: "#666",
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
},
|
},
|
||||||
|
signOutButton: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
marginTop: 48,
|
||||||
|
padding: 12,
|
||||||
|
},
|
||||||
|
signOutText: {
|
||||||
|
color: "#888",
|
||||||
|
fontSize: 16,
|
||||||
|
marginLeft: 8,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
@@ -1,15 +1,92 @@
|
|||||||
import { View, Text, StyleSheet, TouchableOpacity, Alert, ScrollView, Platform } from "react-native";
|
import { View, Text, StyleSheet, TouchableOpacity, Alert, ScrollView, Platform, useWindowDimensions, TextInput, Modal, ActivityIndicator, Linking } from "react-native";
|
||||||
import { useAuthStore } from "../../src/store";
|
import { useState, useMemo } from "react";
|
||||||
|
import { useAuthStore, useMatchStore, useMessageStore, usePacksStore, useSubscriptionStore } from "../../src/store";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { LinearGradient } from "expo-linear-gradient";
|
import { LinearGradient } from "expo-linear-gradient";
|
||||||
import Animated, { FadeInDown } from "react-native-reanimated";
|
import Animated, { FadeInDown } from "react-native-reanimated";
|
||||||
import { supabase } from "../../src/lib/supabase";
|
import { supabase } from "../../src/lib/supabase";
|
||||||
import { router } from "expo-router";
|
import { router } from "expo-router";
|
||||||
import { GradientBackground, GlassCard, GlassButton } from "../../src/components/ui";
|
import { GradientBackground, GlassCard } from "../../src/components/ui";
|
||||||
|
import { FeedbackModal } from "../../src/components/FeedbackModal";
|
||||||
|
import { Paywall } from "../../src/components/Paywall";
|
||||||
import { colors, gradients, spacing, radius, typography, shadows } from "../../src/theme";
|
import { colors, gradients, spacing, radius, typography, shadows } from "../../src/theme";
|
||||||
|
|
||||||
|
const MAX_CONTENT_WIDTH = 500;
|
||||||
|
|
||||||
export default function ProfileScreen() {
|
export default function ProfileScreen() {
|
||||||
const { user, partner, signOut, fetchCouple } = useAuthStore();
|
const { user, partner, couple, signOut, fetchCouple } = useAuthStore();
|
||||||
|
const { clearMatches } = useMatchStore();
|
||||||
|
const { clearMessages } = useMessageStore();
|
||||||
|
const { clearPacks } = usePacksStore();
|
||||||
|
const { subscription, restorePurchases, isPurchasing } = useSubscriptionStore();
|
||||||
|
const { width } = useWindowDimensions();
|
||||||
|
const isWideScreen = width > MAX_CONTENT_WIDTH;
|
||||||
|
|
||||||
|
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||||
|
const [deleteConfirmText, setDeleteConfirmText] = useState("");
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
const [showFeedbackModal, setShowFeedbackModal] = useState(false);
|
||||||
|
const [showPaywall, setShowPaywall] = useState(false);
|
||||||
|
|
||||||
|
// Check if user or partner has premium access
|
||||||
|
const hasPremiumAccess = useMemo(() => {
|
||||||
|
return user?.is_premium || partner?.is_premium || subscription.isProUser;
|
||||||
|
}, [user?.is_premium, partner?.is_premium, subscription.isProUser]);
|
||||||
|
|
||||||
|
// Check if it's the user's own subscription (vs partner's)
|
||||||
|
const isOwnSubscription = user?.is_premium && subscription.isProUser;
|
||||||
|
|
||||||
|
// Format expiration date
|
||||||
|
const formatExpirationDate = (date: Date | null) => {
|
||||||
|
if (!date) return "Never";
|
||||||
|
return date.toLocaleDateString("en-US", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRestorePurchases = async () => {
|
||||||
|
const restored = await restorePurchases();
|
||||||
|
if (restored) {
|
||||||
|
Alert.alert("Success", "Your purchases have been restored!");
|
||||||
|
} else {
|
||||||
|
Alert.alert("No Purchases Found", "No previous purchases found to restore.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleManageSubscription = () => {
|
||||||
|
Linking.openURL("https://apps.apple.com/account/subscriptions");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteRelationship = async () => {
|
||||||
|
if (deleteConfirmText !== "DELETE") return;
|
||||||
|
|
||||||
|
setIsDeleting(true);
|
||||||
|
try {
|
||||||
|
const { error } = await supabase.functions.invoke("delete-relationship", {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
// Clear local stores
|
||||||
|
clearMatches();
|
||||||
|
clearMessages();
|
||||||
|
clearPacks();
|
||||||
|
|
||||||
|
// Refresh user data (will also clear couple/partner since couple_id is now null)
|
||||||
|
await useAuthStore.getState().fetchUser();
|
||||||
|
|
||||||
|
setShowDeleteModal(false);
|
||||||
|
setDeleteConfirmText("");
|
||||||
|
Alert.alert("Success", "All relationship data has been deleted.");
|
||||||
|
} catch (error) {
|
||||||
|
Alert.alert("Error", "Failed to delete relationship data. Please try again.");
|
||||||
|
} finally {
|
||||||
|
setIsDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleUnpair = async () => {
|
const handleUnpair = async () => {
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
@@ -54,7 +131,10 @@ export default function ProfileScreen() {
|
|||||||
<GradientBackground>
|
<GradientBackground>
|
||||||
<ScrollView
|
<ScrollView
|
||||||
style={styles.container}
|
style={styles.container}
|
||||||
contentContainerStyle={styles.contentContainer}
|
contentContainerStyle={[
|
||||||
|
styles.contentContainer,
|
||||||
|
isWideScreen && styles.contentContainerWide,
|
||||||
|
]}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -66,103 +146,371 @@ export default function ProfileScreen() {
|
|||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|
||||||
{/* Profile Card */}
|
{/* Profile Card */}
|
||||||
<Animated.View entering={FadeInDown.delay(200).duration(500)}>
|
<Animated.View
|
||||||
<GlassCard style={styles.profileCard} variant="elevated">
|
entering={FadeInDown.delay(200).duration(500)}
|
||||||
<LinearGradient
|
style={styles.profileSection}
|
||||||
colors={gradients.primary as [string, string]}
|
>
|
||||||
style={styles.avatarContainer}
|
<GlassCard variant="elevated">
|
||||||
start={{ x: 0, y: 0 }}
|
<View style={styles.profileContent}>
|
||||||
end={{ x: 1, y: 1 }}
|
<LinearGradient
|
||||||
>
|
colors={gradients.primary as [string, string]}
|
||||||
<View style={styles.avatar}>
|
style={styles.avatarGradient}
|
||||||
<Text style={styles.avatarText}>
|
start={{ x: 0, y: 0 }}
|
||||||
{user?.name?.[0]?.toUpperCase() || "U"}
|
end={{ x: 1, y: 1 }}
|
||||||
</Text>
|
>
|
||||||
|
<View style={styles.avatarInner}>
|
||||||
|
<Text style={styles.avatarText}>
|
||||||
|
{user?.name?.[0]?.toUpperCase() || "U"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</LinearGradient>
|
||||||
|
<View style={styles.profileInfo}>
|
||||||
|
<Text style={styles.name}>{user?.name || "User"}</Text>
|
||||||
|
<Text style={styles.email}>{user?.email}</Text>
|
||||||
</View>
|
</View>
|
||||||
</LinearGradient>
|
</View>
|
||||||
<Text style={styles.name}>{user?.name || "User"}</Text>
|
|
||||||
<Text style={styles.email}>{user?.email}</Text>
|
|
||||||
</GlassCard>
|
</GlassCard>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|
||||||
{/* Relationship Section */}
|
{/* Partner Section */}
|
||||||
<Animated.View entering={FadeInDown.delay(300).duration(500)}>
|
<Animated.View
|
||||||
<Text style={styles.sectionTitle}>Relationship</Text>
|
entering={FadeInDown.delay(300).duration(500)}
|
||||||
|
style={styles.section}
|
||||||
|
>
|
||||||
|
<Text style={styles.sectionTitle}>Partner</Text>
|
||||||
<GlassCard>
|
<GlassCard>
|
||||||
{partner ? (
|
{partner ? (
|
||||||
<View style={styles.row}>
|
<View style={styles.rowContainer}>
|
||||||
<View style={styles.rowLeft}>
|
<View style={styles.rowLeft}>
|
||||||
<View style={styles.rowIconContainer}>
|
<LinearGradient
|
||||||
<Ionicons name="heart" size={20} color={colors.primary} />
|
colors={gradients.primary as [string, string]}
|
||||||
</View>
|
style={styles.partnerIconGradient}
|
||||||
<View>
|
start={{ x: 0, y: 0 }}
|
||||||
<Text style={styles.rowLabel}>Paired with</Text>
|
end={{ x: 1, y: 1 }}
|
||||||
|
>
|
||||||
|
<Ionicons name="heart" size={20} color={colors.text} />
|
||||||
|
</LinearGradient>
|
||||||
|
<View style={styles.rowTextContainer}>
|
||||||
<Text style={styles.rowValue}>
|
<Text style={styles.rowValue}>
|
||||||
{partner.name || partner.email || 'your partner'}
|
{partner.name || partner.email || 'Your partner'}
|
||||||
</Text>
|
</Text>
|
||||||
|
<Text style={styles.rowLabel}>Connected</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={handleUnpair}
|
onPress={handleUnpair}
|
||||||
style={styles.rowAction}
|
style={styles.unlinkButton}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
>
|
>
|
||||||
<Ionicons name="unlink-outline" size={20} color={colors.error} />
|
<Ionicons name="unlink-outline" size={18} color={colors.textTertiary} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.row}
|
style={styles.rowContainer}
|
||||||
onPress={() => router.push("/(app)/pairing")}
|
onPress={() => router.push("/(app)/pairing")}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
>
|
>
|
||||||
<View style={styles.rowLeft}>
|
<View style={styles.rowLeft}>
|
||||||
<View style={[styles.rowIconContainer, styles.rowIconInactive]}>
|
<View style={styles.emptyPartnerIcon}>
|
||||||
<Ionicons name="heart-outline" size={20} color={colors.textTertiary} />
|
<Ionicons name="heart-outline" size={20} color={colors.textTertiary} />
|
||||||
</View>
|
</View>
|
||||||
<View>
|
<View style={styles.rowTextContainer}>
|
||||||
<Text style={styles.rowLabel}>Not paired yet</Text>
|
<Text style={styles.rowValueMuted}>Not paired yet</Text>
|
||||||
<Text style={styles.rowValueMuted}>Tap to connect with your partner</Text>
|
<Text style={styles.rowLabel}>Tap to connect</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<Ionicons name="chevron-forward" size={20} color={colors.textTertiary} />
|
<View style={styles.chevronContainer}>
|
||||||
|
<Ionicons name="chevron-forward" size={18} color={colors.textTertiary} />
|
||||||
|
</View>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)}
|
)}
|
||||||
</GlassCard>
|
</GlassCard>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|
||||||
{/* Settings Section */}
|
{/* Subscription Section */}
|
||||||
<Animated.View entering={FadeInDown.delay(400).duration(500)}>
|
<Animated.View
|
||||||
<Text style={styles.sectionTitle}>Account</Text>
|
entering={FadeInDown.delay(350).duration(500)}
|
||||||
<GlassCard noPadding>
|
style={styles.section}
|
||||||
|
>
|
||||||
|
<Text style={styles.sectionTitle}>Subscription</Text>
|
||||||
|
<GlassCard>
|
||||||
|
{hasPremiumAccess ? (
|
||||||
|
<View style={styles.rowContainer}>
|
||||||
|
<View style={styles.rowLeft}>
|
||||||
|
<LinearGradient
|
||||||
|
colors={gradients.primary as [string, string]}
|
||||||
|
style={styles.partnerIconGradient}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 1 }}
|
||||||
|
>
|
||||||
|
<Ionicons name="star" size={20} color={colors.text} />
|
||||||
|
</LinearGradient>
|
||||||
|
<View style={styles.rowTextContainer}>
|
||||||
|
<Text style={styles.rowValue}>Pro Member</Text>
|
||||||
|
<Text style={styles.rowLabel}>
|
||||||
|
{isOwnSubscription
|
||||||
|
? `Renews ${formatExpirationDate(subscription.expirationDate)}`
|
||||||
|
: "Via partner's subscription"
|
||||||
|
}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
{isOwnSubscription && (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.manageButton}
|
||||||
|
onPress={handleManageSubscription}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<Text style={styles.manageButtonText}>Manage</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.rowContainer}
|
||||||
|
onPress={() => setShowPaywall(true)}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<View style={styles.rowLeft}>
|
||||||
|
<View style={styles.emptyPartnerIcon}>
|
||||||
|
<Ionicons name="star-outline" size={20} color={colors.textTertiary} />
|
||||||
|
</View>
|
||||||
|
<View style={styles.rowTextContainer}>
|
||||||
|
<Text style={styles.rowValueMuted}>Free Plan</Text>
|
||||||
|
<Text style={styles.rowLabel}>Upgrade to unlock all packs</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<LinearGradient
|
||||||
|
colors={gradients.primary as [string, string]}
|
||||||
|
style={styles.upgradeButton}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 0 }}
|
||||||
|
>
|
||||||
|
<Text style={styles.upgradeButtonText}>Upgrade</Text>
|
||||||
|
</LinearGradient>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</GlassCard>
|
||||||
|
|
||||||
|
{/* Restore Purchases Link */}
|
||||||
|
{!hasPremiumAccess && (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.menuRow, styles.menuRowDanger]}
|
style={styles.restoreLink}
|
||||||
|
onPress={handleRestorePurchases}
|
||||||
|
disabled={isPurchasing}
|
||||||
|
>
|
||||||
|
<Text style={styles.restoreLinkText}>
|
||||||
|
{isPurchasing ? "Restoring..." : "Restore Purchases"}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
{/* Account Section */}
|
||||||
|
<Animated.View
|
||||||
|
entering={FadeInDown.delay(400).duration(500)}
|
||||||
|
style={styles.section}
|
||||||
|
>
|
||||||
|
<Text style={styles.sectionTitle}>Account</Text>
|
||||||
|
<GlassCard>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.signOutRow}
|
||||||
onPress={handleSignOut}
|
onPress={handleSignOut}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
>
|
>
|
||||||
<View style={styles.rowLeft}>
|
<View style={styles.rowLeft}>
|
||||||
<View style={[styles.rowIconContainer, styles.rowIconDanger]}>
|
<View style={styles.signOutIcon}>
|
||||||
<Ionicons name="log-out-outline" size={20} color={colors.error} />
|
<Ionicons name="log-out-outline" size={20} color={colors.error} />
|
||||||
</View>
|
</View>
|
||||||
<Text style={styles.rowLabelDanger}>Sign Out</Text>
|
<Text style={styles.signOutText}>Sign Out</Text>
|
||||||
</View>
|
</View>
|
||||||
<Ionicons name="chevron-forward" size={20} color={colors.error} />
|
<Ionicons name="chevron-forward" size={18} color={colors.error} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</GlassCard>
|
</GlassCard>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|
||||||
{/* Version */}
|
{/* Support Section */}
|
||||||
<Animated.View
|
<Animated.View
|
||||||
entering={FadeInDown.delay(500).duration(500)}
|
entering={FadeInDown.delay(450).duration(500)}
|
||||||
style={styles.versionContainer}
|
style={styles.section}
|
||||||
>
|
>
|
||||||
<Text style={styles.version}>Sauci v1.0.0</Text>
|
<Text style={styles.sectionTitle}>Support</Text>
|
||||||
<Text style={styles.versionSub}>Made with love</Text>
|
<GlassCard>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.rowContainer}
|
||||||
|
onPress={() => setShowFeedbackModal(true)}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<View style={styles.rowLeft}>
|
||||||
|
<LinearGradient
|
||||||
|
colors={gradients.primary as [string, string]}
|
||||||
|
style={styles.partnerIconGradient}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 1 }}
|
||||||
|
>
|
||||||
|
<Ionicons name="chatbubble-ellipses-outline" size={20} color={colors.text} />
|
||||||
|
</LinearGradient>
|
||||||
|
<View style={styles.rowTextContainer}>
|
||||||
|
<Text style={styles.rowValue}>Send Feedback</Text>
|
||||||
|
<Text style={styles.rowLabel}>Report bugs or request features</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View style={styles.chevronContainer}>
|
||||||
|
<Ionicons name="chevron-forward" size={18} color={colors.textTertiary} />
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</GlassCard>
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
{/* Danger Zone - Only show if in a couple */}
|
||||||
|
{couple && (
|
||||||
|
<Animated.View
|
||||||
|
entering={FadeInDown.delay(550).duration(500)}
|
||||||
|
style={styles.section}
|
||||||
|
>
|
||||||
|
<Text style={styles.dangerSectionTitle}>Danger Zone</Text>
|
||||||
|
<GlassCard style={styles.dangerCard}>
|
||||||
|
<View style={styles.dangerContent}>
|
||||||
|
<View style={styles.dangerInfo}>
|
||||||
|
<View style={styles.dangerIconContainer}>
|
||||||
|
<Ionicons name="warning" size={20} color={colors.error} />
|
||||||
|
</View>
|
||||||
|
<View style={styles.dangerTextContainer}>
|
||||||
|
<Text style={styles.dangerTitle}>Delete All Data</Text>
|
||||||
|
<Text style={styles.dangerDescription}>
|
||||||
|
Permanently delete your relationship including all matches, chats, and shared photos.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.dangerButton}
|
||||||
|
onPress={() => setShowDeleteModal(true)}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<Ionicons name="trash-outline" size={16} color={colors.error} />
|
||||||
|
<Text style={styles.dangerButtonText}>Delete</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</GlassCard>
|
||||||
|
</Animated.View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Version */}
|
||||||
|
<Animated.View
|
||||||
|
entering={FadeInDown.delay(couple ? 650 : 550).duration(500)}
|
||||||
|
style={styles.versionContainer}
|
||||||
|
>
|
||||||
|
<View style={styles.versionBadge}>
|
||||||
|
<Ionicons name="heart" size={12} color={colors.primary} />
|
||||||
|
<Text style={styles.version}>Sauci v1.0.0</Text>
|
||||||
|
</View>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|
||||||
{/* Bottom spacing for tab bar */}
|
|
||||||
<View style={styles.bottomSpacer} />
|
<View style={styles.bottomSpacer} />
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
||||||
|
{/* Feedback Modal */}
|
||||||
|
<FeedbackModal
|
||||||
|
visible={showFeedbackModal}
|
||||||
|
onClose={() => setShowFeedbackModal(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Paywall Modal */}
|
||||||
|
<Paywall
|
||||||
|
visible={showPaywall}
|
||||||
|
onClose={() => setShowPaywall(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Delete Confirmation Modal */}
|
||||||
|
<Modal
|
||||||
|
visible={showDeleteModal}
|
||||||
|
transparent
|
||||||
|
animationType="fade"
|
||||||
|
onRequestClose={() => !isDeleting && setShowDeleteModal(false)}
|
||||||
|
>
|
||||||
|
<View style={styles.modalOverlay}>
|
||||||
|
<View style={styles.modalContent}>
|
||||||
|
<View style={styles.modalHeader}>
|
||||||
|
<View style={styles.modalIconContainer}>
|
||||||
|
<Ionicons name="warning" size={32} color={colors.error} />
|
||||||
|
</View>
|
||||||
|
<Text style={styles.modalTitle}>Delete All Data?</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={styles.modalDescription}>
|
||||||
|
This action cannot be undone. This will permanently delete:
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View style={styles.deleteList}>
|
||||||
|
<View style={styles.deleteListItem}>
|
||||||
|
<Ionicons name="heart-dislike" size={16} color={colors.error} />
|
||||||
|
<Text style={styles.deleteListText}>Your couple connection</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.deleteListItem}>
|
||||||
|
<Ionicons name="checkmark-circle" size={16} color={colors.error} />
|
||||||
|
<Text style={styles.deleteListText}>All matches and responses</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.deleteListItem}>
|
||||||
|
<Ionicons name="chatbubbles" size={16} color={colors.error} />
|
||||||
|
<Text style={styles.deleteListText}>All chat messages</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.deleteListItem}>
|
||||||
|
<Ionicons name="images" size={16} color={colors.error} />
|
||||||
|
<Text style={styles.deleteListText}>All shared photos</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={styles.confirmLabel}>
|
||||||
|
Type <Text style={styles.confirmKeyword}>DELETE</Text> to confirm:
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
style={styles.confirmInput}
|
||||||
|
value={deleteConfirmText}
|
||||||
|
onChangeText={setDeleteConfirmText}
|
||||||
|
placeholder="Type DELETE"
|
||||||
|
placeholderTextColor={colors.textTertiary}
|
||||||
|
autoCapitalize="characters"
|
||||||
|
autoCorrect={false}
|
||||||
|
editable={!isDeleting}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View style={styles.modalButtons}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.cancelButton}
|
||||||
|
onPress={() => {
|
||||||
|
setShowDeleteModal(false);
|
||||||
|
setDeleteConfirmText("");
|
||||||
|
}}
|
||||||
|
disabled={isDeleting}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<Text style={styles.cancelButtonText}>Cancel</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[
|
||||||
|
styles.confirmDeleteButton,
|
||||||
|
deleteConfirmText !== "DELETE" && styles.confirmDeleteButtonDisabled,
|
||||||
|
]}
|
||||||
|
onPress={handleDeleteRelationship}
|
||||||
|
disabled={deleteConfirmText !== "DELETE" || isDeleting}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
{isDeleting ? (
|
||||||
|
<ActivityIndicator size="small" color={colors.text} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Ionicons name="trash" size={16} color={colors.text} />
|
||||||
|
<Text style={styles.confirmDeleteButtonText}>Delete</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
</GradientBackground>
|
</GradientBackground>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -174,43 +522,53 @@ const styles = StyleSheet.create({
|
|||||||
contentContainer: {
|
contentContainer: {
|
||||||
paddingBottom: Platform.OS === 'ios' ? 100 : 80,
|
paddingBottom: Platform.OS === 'ios' ? 100 : 80,
|
||||||
},
|
},
|
||||||
|
contentContainerWide: {
|
||||||
|
alignSelf: 'center',
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: MAX_CONTENT_WIDTH,
|
||||||
|
},
|
||||||
header: {
|
header: {
|
||||||
paddingTop: 60,
|
paddingTop: 60,
|
||||||
paddingHorizontal: spacing.lg,
|
paddingHorizontal: spacing.lg,
|
||||||
paddingBottom: spacing.md,
|
paddingBottom: spacing.lg,
|
||||||
},
|
},
|
||||||
title: {
|
title: {
|
||||||
...typography.title1,
|
...typography.title1,
|
||||||
color: colors.text,
|
color: colors.text,
|
||||||
},
|
},
|
||||||
profileCard: {
|
profileSection: {
|
||||||
marginHorizontal: spacing.lg,
|
paddingHorizontal: spacing.lg,
|
||||||
alignItems: "center",
|
marginBottom: spacing.xl,
|
||||||
paddingVertical: spacing.xl,
|
|
||||||
marginBottom: spacing.lg,
|
|
||||||
},
|
},
|
||||||
avatarContainer: {
|
profileContent: {
|
||||||
width: 88,
|
alignItems: "center",
|
||||||
height: 88,
|
paddingVertical: spacing.lg,
|
||||||
borderRadius: 44,
|
},
|
||||||
|
avatarGradient: {
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
borderRadius: 50,
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
marginBottom: spacing.md,
|
|
||||||
...shadows.lg,
|
...shadows.lg,
|
||||||
},
|
},
|
||||||
avatar: {
|
avatarInner: {
|
||||||
width: 80,
|
width: 90,
|
||||||
height: 80,
|
height: 90,
|
||||||
borderRadius: 40,
|
borderRadius: 45,
|
||||||
backgroundColor: colors.background,
|
backgroundColor: colors.background,
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
},
|
},
|
||||||
avatarText: {
|
avatarText: {
|
||||||
fontSize: 32,
|
fontSize: 36,
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
color: colors.text,
|
color: colors.text,
|
||||||
},
|
},
|
||||||
|
profileInfo: {
|
||||||
|
alignItems: "center",
|
||||||
|
marginTop: spacing.lg,
|
||||||
|
},
|
||||||
name: {
|
name: {
|
||||||
...typography.title2,
|
...typography.title2,
|
||||||
color: colors.text,
|
color: colors.text,
|
||||||
@@ -220,43 +578,46 @@ const styles = StyleSheet.create({
|
|||||||
...typography.subhead,
|
...typography.subhead,
|
||||||
color: colors.textSecondary,
|
color: colors.textSecondary,
|
||||||
},
|
},
|
||||||
|
section: {
|
||||||
|
paddingHorizontal: spacing.lg,
|
||||||
|
marginBottom: spacing.lg,
|
||||||
|
},
|
||||||
sectionTitle: {
|
sectionTitle: {
|
||||||
...typography.caption1,
|
...typography.caption1,
|
||||||
color: colors.textTertiary,
|
color: colors.textTertiary,
|
||||||
textTransform: "uppercase",
|
textTransform: "uppercase",
|
||||||
letterSpacing: 1,
|
letterSpacing: 1.5,
|
||||||
marginBottom: spacing.sm,
|
marginBottom: spacing.sm,
|
||||||
marginLeft: spacing.lg + spacing.xs,
|
marginLeft: spacing.xs,
|
||||||
},
|
},
|
||||||
row: {
|
rowContainer: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
padding: spacing.md,
|
|
||||||
},
|
},
|
||||||
rowLeft: {
|
rowLeft: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
rowIconContainer: {
|
partnerIconGradient: {
|
||||||
width: 40,
|
width: 44,
|
||||||
height: 40,
|
height: 44,
|
||||||
borderRadius: 20,
|
borderRadius: 22,
|
||||||
backgroundColor: colors.primaryLight,
|
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
marginRight: spacing.md,
|
|
||||||
},
|
},
|
||||||
rowIconInactive: {
|
emptyPartnerIcon: {
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
borderRadius: 22,
|
||||||
backgroundColor: colors.glass.background,
|
backgroundColor: colors.glass.background,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
},
|
},
|
||||||
rowIconDanger: {
|
rowTextContainer: {
|
||||||
backgroundColor: colors.errorLight,
|
marginLeft: spacing.md,
|
||||||
},
|
flex: 1,
|
||||||
rowLabel: {
|
|
||||||
...typography.subhead,
|
|
||||||
color: colors.textSecondary,
|
|
||||||
},
|
},
|
||||||
rowValue: {
|
rowValue: {
|
||||||
...typography.body,
|
...typography.body,
|
||||||
@@ -264,40 +625,273 @@ const styles = StyleSheet.create({
|
|||||||
fontWeight: "600",
|
fontWeight: "600",
|
||||||
},
|
},
|
||||||
rowValueMuted: {
|
rowValueMuted: {
|
||||||
|
...typography.body,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
},
|
||||||
|
rowLabel: {
|
||||||
...typography.caption1,
|
...typography.caption1,
|
||||||
color: colors.textTertiary,
|
color: colors.textTertiary,
|
||||||
|
marginTop: 2,
|
||||||
},
|
},
|
||||||
rowLabelDanger: {
|
unlinkButton: {
|
||||||
...typography.body,
|
width: 36,
|
||||||
color: colors.error,
|
height: 36,
|
||||||
fontWeight: "500",
|
borderRadius: 18,
|
||||||
|
backgroundColor: colors.glass.background,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
},
|
},
|
||||||
rowAction: {
|
chevronContainer: {
|
||||||
padding: spacing.sm,
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
borderRadius: 14,
|
||||||
|
backgroundColor: colors.glass.background,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
},
|
},
|
||||||
menuRow: {
|
signOutRow: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
padding: spacing.md,
|
|
||||||
},
|
},
|
||||||
menuRowDanger: {
|
signOutIcon: {
|
||||||
borderRadius: radius.lg,
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
borderRadius: 22,
|
||||||
|
backgroundColor: colors.errorLight,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
signOutText: {
|
||||||
|
...typography.body,
|
||||||
|
color: colors.error,
|
||||||
|
fontWeight: "600",
|
||||||
|
marginLeft: spacing.md,
|
||||||
|
},
|
||||||
|
// Subscription styles
|
||||||
|
manageButton: {
|
||||||
|
paddingHorizontal: spacing.md,
|
||||||
|
paddingVertical: spacing.sm,
|
||||||
|
borderRadius: radius.md,
|
||||||
|
backgroundColor: colors.glass.background,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.glass.border,
|
||||||
|
},
|
||||||
|
manageButtonText: {
|
||||||
|
...typography.subhead,
|
||||||
|
color: colors.primary,
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
upgradeButton: {
|
||||||
|
paddingHorizontal: spacing.md,
|
||||||
|
paddingVertical: spacing.sm,
|
||||||
|
borderRadius: radius.md,
|
||||||
|
},
|
||||||
|
upgradeButtonText: {
|
||||||
|
...typography.subhead,
|
||||||
|
color: colors.text,
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
restoreLink: {
|
||||||
|
alignItems: "center",
|
||||||
|
marginTop: spacing.sm,
|
||||||
|
padding: spacing.sm,
|
||||||
|
},
|
||||||
|
restoreLinkText: {
|
||||||
|
...typography.caption1,
|
||||||
|
color: colors.textTertiary,
|
||||||
},
|
},
|
||||||
versionContainer: {
|
versionContainer: {
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
marginTop: spacing.xl,
|
marginTop: spacing.lg,
|
||||||
|
},
|
||||||
|
versionBadge: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
backgroundColor: colors.glass.background,
|
||||||
|
paddingHorizontal: spacing.md,
|
||||||
|
paddingVertical: spacing.sm,
|
||||||
|
borderRadius: radius.full,
|
||||||
|
gap: spacing.xs,
|
||||||
},
|
},
|
||||||
version: {
|
version: {
|
||||||
...typography.caption1,
|
...typography.caption1,
|
||||||
color: colors.textTertiary,
|
color: colors.textTertiary,
|
||||||
},
|
},
|
||||||
versionSub: {
|
|
||||||
...typography.caption2,
|
|
||||||
color: colors.textTertiary,
|
|
||||||
marginTop: spacing.xs,
|
|
||||||
},
|
|
||||||
bottomSpacer: {
|
bottomSpacer: {
|
||||||
height: spacing.lg,
|
height: spacing.lg,
|
||||||
},
|
},
|
||||||
|
// Danger Zone styles
|
||||||
|
dangerSectionTitle: {
|
||||||
|
...typography.caption1,
|
||||||
|
color: colors.error,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: 1.5,
|
||||||
|
marginBottom: spacing.sm,
|
||||||
|
marginLeft: spacing.xs,
|
||||||
|
},
|
||||||
|
dangerCard: {
|
||||||
|
borderColor: 'rgba(231, 76, 60, 0.3)',
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
dangerContent: {
|
||||||
|
gap: spacing.md,
|
||||||
|
},
|
||||||
|
dangerInfo: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "flex-start",
|
||||||
|
},
|
||||||
|
dangerIconContainer: {
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
borderRadius: 22,
|
||||||
|
backgroundColor: colors.errorLight,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
dangerTextContainer: {
|
||||||
|
flex: 1,
|
||||||
|
marginLeft: spacing.md,
|
||||||
|
},
|
||||||
|
dangerTitle: {
|
||||||
|
...typography.headline,
|
||||||
|
color: colors.text,
|
||||||
|
marginBottom: spacing.xs,
|
||||||
|
},
|
||||||
|
dangerDescription: {
|
||||||
|
...typography.subhead,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
dangerButton: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
backgroundColor: colors.errorLight,
|
||||||
|
paddingVertical: spacing.sm,
|
||||||
|
paddingHorizontal: spacing.md,
|
||||||
|
borderRadius: radius.md,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(231, 76, 60, 0.3)',
|
||||||
|
gap: spacing.xs,
|
||||||
|
alignSelf: "flex-end",
|
||||||
|
},
|
||||||
|
dangerButtonText: {
|
||||||
|
...typography.subhead,
|
||||||
|
color: colors.error,
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
// Modal styles
|
||||||
|
modalOverlay: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0.8)",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
padding: spacing.lg,
|
||||||
|
},
|
||||||
|
modalContent: {
|
||||||
|
backgroundColor: colors.backgroundLight,
|
||||||
|
borderRadius: radius.xl,
|
||||||
|
padding: spacing.lg,
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: 400,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.glass.border,
|
||||||
|
},
|
||||||
|
modalHeader: {
|
||||||
|
alignItems: "center",
|
||||||
|
marginBottom: spacing.lg,
|
||||||
|
},
|
||||||
|
modalIconContainer: {
|
||||||
|
width: 64,
|
||||||
|
height: 64,
|
||||||
|
borderRadius: 32,
|
||||||
|
backgroundColor: colors.errorLight,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
marginBottom: spacing.md,
|
||||||
|
},
|
||||||
|
modalTitle: {
|
||||||
|
...typography.title2,
|
||||||
|
color: colors.text,
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
modalDescription: {
|
||||||
|
...typography.body,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
textAlign: "center",
|
||||||
|
marginBottom: spacing.md,
|
||||||
|
},
|
||||||
|
deleteList: {
|
||||||
|
backgroundColor: colors.glass.background,
|
||||||
|
borderRadius: radius.md,
|
||||||
|
padding: spacing.md,
|
||||||
|
marginBottom: spacing.lg,
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
deleteListItem: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
deleteListText: {
|
||||||
|
...typography.subhead,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
},
|
||||||
|
confirmLabel: {
|
||||||
|
...typography.subhead,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
marginBottom: spacing.sm,
|
||||||
|
},
|
||||||
|
confirmKeyword: {
|
||||||
|
color: colors.error,
|
||||||
|
fontWeight: "bold",
|
||||||
|
},
|
||||||
|
confirmInput: {
|
||||||
|
backgroundColor: colors.glass.background,
|
||||||
|
borderRadius: radius.md,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.glass.border,
|
||||||
|
padding: spacing.md,
|
||||||
|
...typography.body,
|
||||||
|
color: colors.text,
|
||||||
|
marginBottom: spacing.lg,
|
||||||
|
},
|
||||||
|
modalButtons: {
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: spacing.md,
|
||||||
|
},
|
||||||
|
cancelButton: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: colors.glass.background,
|
||||||
|
paddingVertical: spacing.md,
|
||||||
|
borderRadius: radius.md,
|
||||||
|
alignItems: "center",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.glass.border,
|
||||||
|
},
|
||||||
|
cancelButtonText: {
|
||||||
|
...typography.body,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
confirmDeleteButton: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: colors.error,
|
||||||
|
paddingVertical: spacing.md,
|
||||||
|
borderRadius: radius.md,
|
||||||
|
alignItems: "center",
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: spacing.xs,
|
||||||
|
},
|
||||||
|
confirmDeleteButtonDisabled: {
|
||||||
|
backgroundColor: colors.glass.background,
|
||||||
|
opacity: 0.5,
|
||||||
|
},
|
||||||
|
confirmDeleteButtonText: {
|
||||||
|
...typography.body,
|
||||||
|
color: colors.text,
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { View, StyleSheet, Text, ActivityIndicator, Platform } from "react-native";
|
import { View, StyleSheet, Text, ActivityIndicator, Platform } from "react-native";
|
||||||
import { useLocalSearchParams } from "expo-router";
|
import { useLocalSearchParams, router } from "expo-router";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import Animated, { FadeIn, FadeInUp } from "react-native-reanimated";
|
import Animated, { FadeIn, FadeInUp } from "react-native-reanimated";
|
||||||
import { supabase } from "../../src/lib/supabase";
|
import { supabase } from "../../src/lib/supabase";
|
||||||
|
import { usePacksStore } from "../../src/store";
|
||||||
import SwipeCard from "../../src/components/SwipeCard";
|
import SwipeCard from "../../src/components/SwipeCard";
|
||||||
import { GradientBackground, GlassCard, GlassButton } from "../../src/components/ui";
|
import { GradientBackground, GlassCard, GlassButton } from "../../src/components/ui";
|
||||||
import { colors, spacing, typography, radius, shadows } from "../../src/theme";
|
import { colors, spacing, typography, radius, shadows } from "../../src/theme";
|
||||||
@@ -13,9 +14,10 @@ export default function SwipeScreen() {
|
|||||||
const [questions, setQuestions] = useState<any[]>([]);
|
const [questions, setQuestions] = useState<any[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [currentIndex, setCurrentIndex] = useState(0);
|
const [currentIndex, setCurrentIndex] = useState(0);
|
||||||
|
const { enabledPackIds, fetchPacks } = usePacksStore();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchQuestions();
|
fetchPacks().then(() => fetchQuestions());
|
||||||
}, [packId]);
|
}, [packId]);
|
||||||
|
|
||||||
const fetchQuestions = async () => {
|
const fetchQuestions = async () => {
|
||||||
@@ -83,6 +85,34 @@ export default function SwipeScreen() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Show "no packs enabled" message when not viewing a specific pack and no packs are enabled
|
||||||
|
if (!packId && enabledPackIds.length === 0) {
|
||||||
|
return (
|
||||||
|
<GradientBackground showAccent>
|
||||||
|
<View style={styles.centerContainer}>
|
||||||
|
<Animated.View
|
||||||
|
entering={FadeInUp.duration(600).springify()}
|
||||||
|
style={styles.emptyContent}
|
||||||
|
>
|
||||||
|
<View style={styles.noPacksIconContainer}>
|
||||||
|
<Ionicons name="layers-outline" size={64} color={colors.textTertiary} />
|
||||||
|
</View>
|
||||||
|
<Text style={styles.emptyTitle}>No packs enabled</Text>
|
||||||
|
<Text style={styles.emptySubtitle}>
|
||||||
|
Enable at least one question pack to start playing.
|
||||||
|
</Text>
|
||||||
|
<GlassButton
|
||||||
|
onPress={() => router.push("/packs")}
|
||||||
|
style={{ marginTop: spacing.lg }}
|
||||||
|
>
|
||||||
|
Choose Packs
|
||||||
|
</GlassButton>
|
||||||
|
</Animated.View>
|
||||||
|
</View>
|
||||||
|
</GradientBackground>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (currentIndex >= questions.length) {
|
if (currentIndex >= questions.length) {
|
||||||
return (
|
return (
|
||||||
<GradientBackground showAccent>
|
<GradientBackground showAccent>
|
||||||
@@ -236,6 +266,15 @@ const styles = StyleSheet.create({
|
|||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
marginBottom: spacing.lg,
|
marginBottom: spacing.lg,
|
||||||
},
|
},
|
||||||
|
noPacksIconContainer: {
|
||||||
|
width: 120,
|
||||||
|
height: 120,
|
||||||
|
borderRadius: 60,
|
||||||
|
backgroundColor: colors.glass.background,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
marginBottom: spacing.lg,
|
||||||
|
},
|
||||||
emptyTitle: {
|
emptyTitle: {
|
||||||
...typography.title2,
|
...typography.title2,
|
||||||
color: colors.text,
|
color: colors.text,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from "react";
|
import { useState, useEffect, useCallback } from "react";
|
||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
Text,
|
Text,
|
||||||
@@ -17,6 +17,16 @@ import Animated, {
|
|||||||
FadeInUp,
|
FadeInUp,
|
||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
// Safely import Apple Authentication - may not be available in Expo Go
|
||||||
|
let AppleAuthentication: typeof import("expo-apple-authentication") | null = null;
|
||||||
|
|
||||||
|
if (Platform.OS === "ios") {
|
||||||
|
try {
|
||||||
|
AppleAuthentication = require("expo-apple-authentication");
|
||||||
|
} catch {
|
||||||
|
AppleAuthentication = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
import { supabase } from "../../src/lib/supabase";
|
import { supabase } from "../../src/lib/supabase";
|
||||||
import { useAuthStore } from "../../src/store";
|
import { useAuthStore } from "../../src/store";
|
||||||
import { GradientBackground, GlassCard, GlassButton, GlassInput } from "../../src/components/ui";
|
import { GradientBackground, GlassCard, GlassButton, GlassInput } from "../../src/components/ui";
|
||||||
@@ -24,16 +34,71 @@ import { colors, spacing, radius, typography } from "../../src/theme";
|
|||||||
|
|
||||||
type AuthMode = "magic-link" | "password";
|
type AuthMode = "magic-link" | "password";
|
||||||
|
|
||||||
|
interface PendingVerification {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
export default function LoginScreen() {
|
export default function LoginScreen() {
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [isMagicLinkSent, setIsMagicLinkSent] = useState(false);
|
const [isMagicLinkSent, setIsMagicLinkSent] = useState(false);
|
||||||
|
const [pendingVerification, setPendingVerification] = useState<PendingVerification | null>(null);
|
||||||
|
const [isCheckingVerification, setIsCheckingVerification] = useState(false);
|
||||||
const [authMode, setAuthMode] = useState<AuthMode>("magic-link");
|
const [authMode, setAuthMode] = useState<AuthMode>("magic-link");
|
||||||
const [isSignUp, setIsSignUp] = useState(false);
|
const [isSignUp, setIsSignUp] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [isAppleAuthAvailable, setIsAppleAuthAvailable] = useState(false);
|
||||||
const { isAuthenticated } = useAuthStore();
|
const { isAuthenticated } = useAuthStore();
|
||||||
|
|
||||||
|
// Check Apple Auth availability asynchronously
|
||||||
|
useEffect(() => {
|
||||||
|
if (Platform.OS === "ios" && AppleAuthentication) {
|
||||||
|
AppleAuthentication.isAvailableAsync().then((available) => {
|
||||||
|
setIsAppleAuthAvailable(available);
|
||||||
|
}).catch(() => {
|
||||||
|
setIsAppleAuthAvailable(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Check if user has verified their email
|
||||||
|
const checkVerificationStatus = useCallback(async () => {
|
||||||
|
if (!pendingVerification) return;
|
||||||
|
|
||||||
|
setIsCheckingVerification(true);
|
||||||
|
const { error: signInError } = await supabase.auth.signInWithPassword({
|
||||||
|
email: pendingVerification.email,
|
||||||
|
password: pendingVerification.password,
|
||||||
|
});
|
||||||
|
setIsCheckingVerification(false);
|
||||||
|
|
||||||
|
if (signInError) {
|
||||||
|
if (signInError.message.includes("Email not confirmed")) {
|
||||||
|
// Still not verified - show a message
|
||||||
|
if (Platform.OS !== 'web') {
|
||||||
|
Alert.alert("Not yet verified", "Please check your email and click the verification link.");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Alert.alert("Error", signInError.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// If successful, the auth state change will handle the redirect
|
||||||
|
}, [pendingVerification]);
|
||||||
|
|
||||||
|
// Listen for auth state changes (e.g., when user verifies in another tab/browser)
|
||||||
|
useEffect(() => {
|
||||||
|
const { data: { subscription } } = supabase.auth.onAuthStateChange((event, session) => {
|
||||||
|
if (event === 'SIGNED_IN' && session) {
|
||||||
|
// User verified and signed in - clear pending state
|
||||||
|
setPendingVerification(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => subscription.unsubscribe();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const showError = (message: string) => {
|
const showError = (message: string) => {
|
||||||
setError(message);
|
setError(message);
|
||||||
// Also show native alert on mobile
|
// Also show native alert on mobile
|
||||||
@@ -100,20 +165,11 @@ export default function LoginScreen() {
|
|||||||
if (signUpError) {
|
if (signUpError) {
|
||||||
showError(signUpError.message);
|
showError(signUpError.message);
|
||||||
} else {
|
} else {
|
||||||
// Show success message
|
// Show the verification pending screen
|
||||||
if (Platform.OS === 'web') {
|
setPendingVerification({
|
||||||
setError(null);
|
email: email.trim(),
|
||||||
Alert.alert(
|
password,
|
||||||
"Check your email",
|
});
|
||||||
"We sent you a confirmation link to verify your email address."
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
Alert.alert(
|
|
||||||
"Check your email",
|
|
||||||
"We sent you a confirmation link to verify your email address.",
|
|
||||||
[{ text: "OK" }]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const { error: signInError } = await supabase.auth.signInWithPassword({
|
const { error: signInError } = await supabase.auth.signInWithPassword({
|
||||||
@@ -124,44 +180,78 @@ export default function LoginScreen() {
|
|||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
|
||||||
if (signInError) {
|
if (signInError) {
|
||||||
showError(signInError.message);
|
// Check if the error is due to unverified email
|
||||||
|
if (signInError.message.includes("Email not confirmed")) {
|
||||||
|
// Show the verification pending screen
|
||||||
|
setPendingVerification({
|
||||||
|
email: email.trim(),
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
showError(signInError.message);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleGoogleSignIn = async () => {
|
|
||||||
const { error } = await supabase.auth.signInWithOAuth({
|
|
||||||
provider: "google",
|
|
||||||
options: {
|
|
||||||
redirectTo: Linking.createURL("/(auth)/login"),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
Alert.alert("Error", error.message);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAppleSignIn = async () => {
|
const handleAppleSignIn = async () => {
|
||||||
const { error } = await supabase.auth.signInWithOAuth({
|
console.log("[Apple Sign In] Starting, isAppleAuthAvailable:", isAppleAuthAvailable, "AppleAuthentication:", !!AppleAuthentication);
|
||||||
provider: "apple",
|
|
||||||
options: {
|
|
||||||
redirectTo: Linking.createURL("/(auth)/login"),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (error) {
|
if (isAppleAuthAvailable && AppleAuthentication) {
|
||||||
Alert.alert("Error", error.message);
|
try {
|
||||||
|
console.log("[Apple Sign In] Calling signInAsync...");
|
||||||
|
const credential = await AppleAuthentication.signInAsync({
|
||||||
|
requestedScopes: [
|
||||||
|
AppleAuthentication.AppleAuthenticationScope.FULL_NAME,
|
||||||
|
AppleAuthentication.AppleAuthenticationScope.EMAIL,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
console.log("[Apple Sign In] Got credential:", !!credential, "identityToken:", !!credential?.identityToken);
|
||||||
|
|
||||||
|
if (credential.identityToken) {
|
||||||
|
const { error } = await supabase.auth.signInWithIdToken({
|
||||||
|
provider: "apple",
|
||||||
|
token: credential.identityToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.log("[Apple Sign In] Supabase error:", error.message);
|
||||||
|
Alert.alert("Error", error.message);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Alert.alert("Error", "No identity token received from Apple");
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
console.log("[Apple Sign In] Caught error:", e.code, e.message);
|
||||||
|
if (e.code === "ERR_REQUEST_CANCELED") {
|
||||||
|
// User canceled - do nothing
|
||||||
|
} else {
|
||||||
|
Alert.alert("Apple Sign In Error", `${e.code || 'Unknown'}: ${e.message || "Apple sign in failed"}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log("[Apple Sign In] Falling back to OAuth");
|
||||||
|
// Fallback to web OAuth when native module unavailable
|
||||||
|
const { error } = await supabase.auth.signInWithOAuth({
|
||||||
|
provider: "apple",
|
||||||
|
options: {
|
||||||
|
redirectTo: Linking.createURL("/(auth)/login"),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
Alert.alert("Error", error.message);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isMagicLinkSent) {
|
if (isMagicLinkSent) {
|
||||||
return (
|
return (
|
||||||
<GradientBackground showAccent>
|
<GradientBackground showAccent>
|
||||||
<View style={styles.container}>
|
<View style={styles.centeredContainer}>
|
||||||
<Animated.View
|
<Animated.View
|
||||||
entering={FadeInUp.duration(600).springify()}
|
entering={FadeInUp.duration(600).springify()}
|
||||||
style={styles.content}
|
style={styles.centeredContent}
|
||||||
>
|
>
|
||||||
<View style={styles.iconContainer}>
|
<View style={styles.iconContainer}>
|
||||||
<Ionicons name="mail" size={48} color={colors.primary} />
|
<Ionicons name="mail" size={48} color={colors.primary} />
|
||||||
@@ -184,6 +274,51 @@ export default function LoginScreen() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Email verification pending screen
|
||||||
|
if (pendingVerification) {
|
||||||
|
return (
|
||||||
|
<GradientBackground showAccent>
|
||||||
|
<View style={styles.centeredContainer}>
|
||||||
|
<Animated.View
|
||||||
|
entering={FadeInUp.duration(600).springify()}
|
||||||
|
style={styles.centeredContent}
|
||||||
|
>
|
||||||
|
<View style={styles.iconContainer}>
|
||||||
|
<Ionicons name="mail-unread" size={48} color={colors.primary} />
|
||||||
|
</View>
|
||||||
|
<Text style={styles.title}>Verify your email</Text>
|
||||||
|
<Text style={styles.subtitle}>
|
||||||
|
We sent a verification link to{'\n'}
|
||||||
|
<Text style={styles.emailHighlight}>{pendingVerification.email}</Text>
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.verificationHint}>
|
||||||
|
Click the link in your email to verify your account, then tap the button below.
|
||||||
|
</Text>
|
||||||
|
<GlassButton
|
||||||
|
onPress={checkVerificationStatus}
|
||||||
|
loading={isCheckingVerification}
|
||||||
|
disabled={isCheckingVerification}
|
||||||
|
style={{ marginTop: spacing.lg }}
|
||||||
|
>
|
||||||
|
I've verified my email
|
||||||
|
</GlassButton>
|
||||||
|
<GlassButton
|
||||||
|
variant="ghost"
|
||||||
|
onPress={() => {
|
||||||
|
setPendingVerification(null);
|
||||||
|
setEmail(pendingVerification.email);
|
||||||
|
setPassword("");
|
||||||
|
}}
|
||||||
|
style={{ marginTop: spacing.sm }}
|
||||||
|
>
|
||||||
|
Use a different email
|
||||||
|
</GlassButton>
|
||||||
|
</Animated.View>
|
||||||
|
</View>
|
||||||
|
</GradientBackground>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GradientBackground showAccent>
|
<GradientBackground showAccent>
|
||||||
<KeyboardAvoidingView
|
<KeyboardAvoidingView
|
||||||
@@ -335,23 +470,24 @@ export default function LoginScreen() {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.socialButtons}>
|
<View style={styles.socialButtons}>
|
||||||
<GlassButton
|
{isAppleAuthAvailable && AppleAuthentication ? (
|
||||||
variant="secondary"
|
<AppleAuthentication.AppleAuthenticationButton
|
||||||
onPress={handleGoogleSignIn}
|
buttonType={AppleAuthentication.AppleAuthenticationButtonType.SIGN_IN}
|
||||||
icon={<Ionicons name="logo-google" size={20} color={colors.text} />}
|
buttonStyle={AppleAuthentication.AppleAuthenticationButtonStyle.WHITE}
|
||||||
style={styles.socialButton}
|
cornerRadius={12}
|
||||||
>
|
style={styles.appleButton}
|
||||||
Google
|
onPress={handleAppleSignIn}
|
||||||
</GlassButton>
|
/>
|
||||||
|
) : (
|
||||||
<GlassButton
|
<GlassButton
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onPress={handleAppleSignIn}
|
onPress={handleAppleSignIn}
|
||||||
icon={<Ionicons name="logo-apple" size={20} color={colors.text} />}
|
icon={<Ionicons name="logo-apple" size={20} color={colors.text} />}
|
||||||
style={styles.socialButton}
|
style={styles.socialButton}
|
||||||
>
|
>
|
||||||
Apple
|
Apple
|
||||||
</GlassButton>
|
</GlassButton>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
</View>
|
</View>
|
||||||
@@ -365,6 +501,15 @@ const styles = StyleSheet.create({
|
|||||||
container: {
|
container: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
|
centeredContainer: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
centeredContent: {
|
||||||
|
alignItems: "center",
|
||||||
|
paddingHorizontal: spacing.lg,
|
||||||
|
},
|
||||||
scrollContent: {
|
scrollContent: {
|
||||||
flexGrow: 1,
|
flexGrow: 1,
|
||||||
},
|
},
|
||||||
@@ -401,6 +546,13 @@ const styles = StyleSheet.create({
|
|||||||
color: colors.primary,
|
color: colors.primary,
|
||||||
fontWeight: "600",
|
fontWeight: "600",
|
||||||
},
|
},
|
||||||
|
verificationHint: {
|
||||||
|
...typography.subhead,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
textAlign: "center",
|
||||||
|
marginTop: spacing.md,
|
||||||
|
paddingHorizontal: spacing.md,
|
||||||
|
},
|
||||||
iconContainer: {
|
iconContainer: {
|
||||||
width: 100,
|
width: 100,
|
||||||
height: 100,
|
height: 100,
|
||||||
@@ -487,4 +639,8 @@ const styles = StyleSheet.create({
|
|||||||
socialButton: {
|
socialButton: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
|
appleButton: {
|
||||||
|
flex: 1,
|
||||||
|
height: 48,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
@@ -1,6 +1,20 @@
|
|||||||
// Web polyfills must be imported FIRST before any other React Native imports
|
// Web polyfills must be imported FIRST before any other React Native imports
|
||||||
import "../src/polyfills/web";
|
import "../src/polyfills/web";
|
||||||
|
|
||||||
|
// Initialize Sentry early to capture all errors
|
||||||
|
import { initSentry, setUserContext, clearUserContext } from "../src/lib/sentry";
|
||||||
|
initSentry();
|
||||||
|
|
||||||
|
// Import push notification utilities
|
||||||
|
import {
|
||||||
|
registerForPushNotificationsAsync,
|
||||||
|
savePushToken,
|
||||||
|
clearPushToken,
|
||||||
|
setupNotificationResponseListener,
|
||||||
|
getInitialNotification,
|
||||||
|
handleNotificationResponse,
|
||||||
|
} from "../src/lib/notifications";
|
||||||
|
|
||||||
import React, { useEffect } from "react";
|
import React, { useEffect } from "react";
|
||||||
import { Platform, View } from "react-native";
|
import { Platform, View } from "react-native";
|
||||||
|
|
||||||
@@ -56,13 +70,41 @@ export default function RootLayout() {
|
|||||||
handleDeepLink(event.url);
|
handleDeepLink(event.url);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Set up push notification response listener (for taps while app is running)
|
||||||
|
const notificationSubscription = setupNotificationResponseListener();
|
||||||
|
|
||||||
|
// Check if app was opened from a notification tap (cold start)
|
||||||
|
getInitialNotification().then((response) => {
|
||||||
|
if (response) {
|
||||||
|
handleNotificationResponse(response);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Listen for auth changes
|
// Listen for auth changes
|
||||||
const { data: { subscription: authSubscription } } = supabase.auth.onAuthStateChange(
|
const { data: { subscription: authSubscription } } = supabase.auth.onAuthStateChange(
|
||||||
async (event, session) => {
|
async (event, session) => {
|
||||||
if (event === "SIGNED_IN" || event === "TOKEN_REFRESHED") {
|
if (event === "SIGNED_IN" || event === "TOKEN_REFRESHED") {
|
||||||
fetchUser();
|
fetchUser();
|
||||||
|
// Set Sentry user context for error tracking
|
||||||
|
if (session?.user) {
|
||||||
|
setUserContext(session.user.id, session.user.email);
|
||||||
|
|
||||||
|
// Register for push notifications and save token
|
||||||
|
const token = await registerForPushNotificationsAsync();
|
||||||
|
if (token) {
|
||||||
|
await savePushToken(session.user.id, token);
|
||||||
|
}
|
||||||
|
}
|
||||||
} else if (event === "SIGNED_OUT") {
|
} else if (event === "SIGNED_OUT") {
|
||||||
|
// Clear push token before clearing user state
|
||||||
|
const userId = useAuthStore.getState().user?.id;
|
||||||
|
if (userId) {
|
||||||
|
await clearPushToken(userId);
|
||||||
|
}
|
||||||
setUser(null);
|
setUser(null);
|
||||||
|
clearUserContext();
|
||||||
|
// Clear React Query cache
|
||||||
|
queryClient.clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -70,6 +112,7 @@ export default function RootLayout() {
|
|||||||
return () => {
|
return () => {
|
||||||
linkingSubscription.remove();
|
linkingSubscription.remove();
|
||||||
authSubscription.unsubscribe();
|
authSubscription.unsubscribe();
|
||||||
|
notificationSubscription.remove();
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 161 KiB |
|
Before Width: | Height: | Size: 372 B After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 161 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 161 KiB |
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"cli": {
|
||||||
|
"version": ">= 16.28.0",
|
||||||
|
"appVersionSource": "remote"
|
||||||
|
},
|
||||||
|
"build": {
|
||||||
|
"development": {
|
||||||
|
"developmentClient": true,
|
||||||
|
"distribution": "internal"
|
||||||
|
},
|
||||||
|
"preview": {
|
||||||
|
"distribution": "internal"
|
||||||
|
},
|
||||||
|
"production": {
|
||||||
|
"autoIncrement": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"submit": {
|
||||||
|
"production": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 8.8 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 7.7 KiB |
|
After Width: | Height: | Size: 7.7 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 28 KiB |