diff --git a/.gitignore b/.gitignore index f8c6c2e..309d66e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,43 +1,54 @@ -# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files +# Node modules +/node_modules/ -# dependencies -node_modules/ - -# Expo +# Expo / React Native build caches .expo/ -dist/ -web-build/ -expo-env.d.ts +.expo-shared/ +*.expo -# Native -.kotlin/ -*.orig.* -*.jks -*.p8 -*.p12 -*.key -*.mobileprovision - -# Metro -.metro-health-check* - -# debug -npm-debug.* -yarn-debug.* -yarn-error.* - -# macOS +# macOS files .DS_Store + +# Logs +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + +# OS files +Thumbs.db +desktop.ini + +# Expo builds +/dist/ +/web-build/ +/android/app/build/ +/ios/build/ +/ios/Pods/ +ios/Podfile.lock + +# Expo credentials and secrets +*.p12 +*.jks +*.keystore +*.mobileprovision +*.cer *.pem +*.p8 -# local env files -.env*.local +# Misc +*.swp +*.swo +*.env +.env.local +.env.*.local -# typescript -*.tsbuildinfo +# VSCode +.vscode/ -app-example +# JetBrains +.idea/ -# generated native folders -/ios -/android +# Temporary files +tmp/ +temp/ diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..521a9f7 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +legacy-peer-deps=true diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..8a6be07 --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,16 @@ +# OSX +# +.DS_Store + +# Android/IntelliJ +# +build/ +.idea +.gradle +local.properties +*.iml +*.hprof +.cxx/ + +# Bundle artifacts +*.jsbundle diff --git a/android/app/build.gradle b/android/app/build.gradle new file mode 100644 index 0000000..7a61962 --- /dev/null +++ b/android/app/build.gradle @@ -0,0 +1,182 @@ +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() + + enableBundleCompression = (findProperty('android.enableBundleCompression') ?: false).toBoolean() + // 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 in release builds to optimize the app using [R8](https://developer.android.com/topic/performance/app-optimization/enable-app-optimization). + */ +def enableMinifyInReleaseBuilds = (findProperty('android.enableMinifyInReleaseBuilds') ?: 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 = 'io.github.react-native-community:jsc-android:2026004.+' + +android { + ndkVersion rootProject.ext.ndkVersion + + buildToolsVersion rootProject.ext.buildToolsVersion + compileSdk rootProject.ext.compileSdkVersion + + namespace 'com.felix.infotarget' + defaultConfig { + applicationId 'com.felix.infotarget' + minSdkVersion rootProject.ext.minSdkVersion + targetSdkVersion rootProject.ext.targetSdkVersion + versionCode 1 + versionName "1.0.0" + + buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\"" + } + 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 + def enableShrinkResources = findProperty('android.enableShrinkResourcesInReleaseBuilds') ?: 'false' + shrinkResources enableShrinkResources.toBoolean() + minifyEnabled enableMinifyInReleaseBuilds + proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro" + def enablePngCrunchInRelease = findProperty('android.enablePngCrunchInReleaseBuilds') ?: 'true' + crunchPngs enablePngCrunchInRelease.toBoolean() + } + } + packagingOptions { + jniLibs { + def enableLegacyPackaging = findProperty('expo.useLegacyPackaging') ?: 'false' + useLegacyPackaging enableLegacyPackaging.toBoolean() + } + } + 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.. 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:${expoLibs.versions.fresco.get()}") + } + + if (isWebpEnabled) { + // For webp support + implementation("com.facebook.fresco:webpsupport:${expoLibs.versions.fresco.get()}") + if (isWebpAnimatedEnabled) { + // Animated webp support + implementation("com.facebook.fresco:animated-webp:${expoLibs.versions.fresco.get()}") + } + } + + if (hermesEnabled.toBoolean()) { + implementation("com.facebook.react:hermes-android") + } else { + implementation jscFlavor + } +} diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 0000000..551eb41 --- /dev/null +++ b/android/app/proguard-rules.pro @@ -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: diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..3ec2507 --- /dev/null +++ b/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/android/app/src/debugOptimized/AndroidManifest.xml b/android/app/src/debugOptimized/AndroidManifest.xml new file mode 100644 index 0000000..3ec2507 --- /dev/null +++ b/android/app/src/debugOptimized/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..693db22 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/java/com/felix/infotarget/MainActivity.kt b/android/app/src/main/java/com/felix/infotarget/MainActivity.kt new file mode 100644 index 0000000..e99d3d1 --- /dev/null +++ b/android/app/src/main/java/com/felix/infotarget/MainActivity.kt @@ -0,0 +1,65 @@ +package com.felix.infotarget +import expo.modules.splashscreen.SplashScreenManager + +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); + // @generated begin expo-splashscreen - expo prebuild (DO NOT MODIFY) sync-f3ff59a738c56c9a6119210cb55f0b613eb8b6af + SplashScreenManager.registerOnActivity(this) + // @generated end expo-splashscreen + 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 onBackPressed + */ + 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() + } +} diff --git a/android/app/src/main/java/com/felix/infotarget/MainApplication.kt b/android/app/src/main/java/com/felix/infotarget/MainApplication.kt new file mode 100644 index 0000000..9f59651 --- /dev/null +++ b/android/app/src/main/java/com/felix/infotarget/MainApplication.kt @@ -0,0 +1,56 @@ +package com.felix.infotarget + +import android.app.Application +import android.content.res.Configuration + +import com.facebook.react.PackageList +import com.facebook.react.ReactApplication +import com.facebook.react.ReactNativeApplicationEntryPoint.loadReactNative +import com.facebook.react.ReactNativeHost +import com.facebook.react.ReactPackage +import com.facebook.react.ReactHost +import com.facebook.react.common.ReleaseLevel +import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint +import com.facebook.react.defaults.DefaultReactNativeHost + +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 = + PackageList(this).packages.apply { + // Packages that cannot be autolinked yet can be added manually here, for example: + // add(MyReactNativePackage()) + } + + 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 reactHost: ReactHost + get() = ReactNativeHostWrapper.createReactHost(applicationContext, reactNativeHost) + + override fun onCreate() { + super.onCreate() + DefaultNewArchitectureEntryPoint.releaseLevel = try { + ReleaseLevel.valueOf(BuildConfig.REACT_NATIVE_RELEASE_LEVEL.uppercase()) + } catch (e: IllegalArgumentException) { + ReleaseLevel.STABLE + } + loadReactNative(this) + ApplicationLifecycleDispatcher.onApplicationCreate(this) + } + + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + ApplicationLifecycleDispatcher.onConfigurationChanged(this, newConfig) + } +} diff --git a/android/app/src/main/res/drawable-hdpi/splashscreen_logo.png b/android/app/src/main/res/drawable-hdpi/splashscreen_logo.png new file mode 100644 index 0000000..a0f27b3 Binary files /dev/null and b/android/app/src/main/res/drawable-hdpi/splashscreen_logo.png differ diff --git a/android/app/src/main/res/drawable-mdpi/splashscreen_logo.png b/android/app/src/main/res/drawable-mdpi/splashscreen_logo.png new file mode 100644 index 0000000..40fdf0b Binary files /dev/null and b/android/app/src/main/res/drawable-mdpi/splashscreen_logo.png differ diff --git a/android/app/src/main/res/drawable-xhdpi/splashscreen_logo.png b/android/app/src/main/res/drawable-xhdpi/splashscreen_logo.png new file mode 100644 index 0000000..a12fca8 Binary files /dev/null and b/android/app/src/main/res/drawable-xhdpi/splashscreen_logo.png differ diff --git a/android/app/src/main/res/drawable-xxhdpi/splashscreen_logo.png b/android/app/src/main/res/drawable-xxhdpi/splashscreen_logo.png new file mode 100644 index 0000000..1557d8d Binary files /dev/null and b/android/app/src/main/res/drawable-xxhdpi/splashscreen_logo.png differ diff --git a/android/app/src/main/res/drawable-xxxhdpi/splashscreen_logo.png b/android/app/src/main/res/drawable-xxxhdpi/splashscreen_logo.png new file mode 100644 index 0000000..2b0fdf1 Binary files /dev/null and b/android/app/src/main/res/drawable-xxxhdpi/splashscreen_logo.png differ diff --git a/android/app/src/main/res/drawable/ic_launcher_background.xml b/android/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..883b2a0 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/drawable/rn_edit_text_material.xml b/android/app/src/main/res/drawable/rn_edit_text_material.xml new file mode 100644 index 0000000..5c25e72 --- /dev/null +++ b/android/app/src/main/res/drawable/rn_edit_text_material.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..9764d2a --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..9764d2a --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..57b2180 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_background.webp b/android/app/src/main/res/mipmap-hdpi/ic_launcher_background.webp new file mode 100644 index 0000000..923e6b7 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_background.webp differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp b/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..923e6b7 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.webp b/android/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.webp new file mode 100644 index 0000000..923e6b7 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.webp differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..127f436 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000..d90f15f Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_background.webp b/android/app/src/main/res/mipmap-mdpi/ic_launcher_background.webp new file mode 100644 index 0000000..49c6abc Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_background.webp differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp b/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..49c6abc Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.webp b/android/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.webp new file mode 100644 index 0000000..49c6abc Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.webp differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..82d6926 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..a2efddf Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_background.webp b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_background.webp new file mode 100644 index 0000000..b56d885 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_background.webp differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..b56d885 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.webp b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.webp new file mode 100644 index 0000000..b56d885 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.webp differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..408233f Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..202cc52 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.webp b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.webp new file mode 100644 index 0000000..6a9351a Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.webp differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..6a9351a Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.webp b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.webp new file mode 100644 index 0000000..6a9351a Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.webp differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..4e73dd6 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..98a87ba Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.webp b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.webp new file mode 100644 index 0000000..26967f4 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.webp differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..26967f4 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.webp b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.webp new file mode 100644 index 0000000..26967f4 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.webp differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..8f9b8cd Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/android/app/src/main/res/values-night/colors.xml b/android/app/src/main/res/values-night/colors.xml new file mode 100644 index 0000000..5429410 --- /dev/null +++ b/android/app/src/main/res/values-night/colors.xml @@ -0,0 +1,3 @@ + + #000000 + \ No newline at end of file diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..edca213 --- /dev/null +++ b/android/app/src/main/res/values/colors.xml @@ -0,0 +1,7 @@ + + #ffffff + #E6F4FE + #023c69 + #ffffff + #0f172a + \ No newline at end of file diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..679dee9 --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,10 @@ + + info target + automatic + contain + false + -14735049 + visible + relative + inset-swipe + \ No newline at end of file diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..334a769 --- /dev/null +++ b/android/app/src/main/res/values/styles.xml @@ -0,0 +1,15 @@ + + + + \ No newline at end of file diff --git a/android/build.gradle b/android/build.gradle new file mode 100644 index 0000000..0554dd1 --- /dev/null +++ b/android/build.gradle @@ -0,0 +1,24 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. + +buildscript { + 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') + } +} + +allprojects { + repositories { + google() + mavenCentral() + maven { url 'https://www.jitpack.io' } + } +} + +apply plugin: "expo-root-project" +apply plugin: "com.facebook.react.rootproject" diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..8e39f82 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,65 @@ +# 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 -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 + +# Use this property to enable edge-to-edge display support. +# This allows your app to draw behind system bars for an immersive UI. +# Note: Only works with ReactActivity and should not be used with custom Activity. +edgeToEdgeEnabled=true + +# 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 + +# Specifies whether the app is configured to use edge-to-edge via the app config or plugin +# WARNING: This property has been deprecated and will be removed in Expo SDK 55. Use `edgeToEdgeEnabled` or `react.edgeToEdgeEnabled` to determine whether the project is using edge-to-edge. +expo.edgeToEdgeEnabled=true diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..1b33c55 Binary files /dev/null and b/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..d4081da --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/android/gradlew b/android/gradlew new file mode 100644 index 0000000..7f94d3d --- /dev/null +++ b/android/gradlew @@ -0,0 +1,251 @@ +#!/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\n' "$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="\\\"\\\"" + + +# 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" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# 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" "$@" diff --git a/android/gradlew.bat b/android/gradlew.bat new file mode 100644 index 0000000..db3a6ac --- /dev/null +++ b/android/gradlew.bat @@ -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= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +: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 diff --git a/android/settings.gradle b/android/settings.gradle new file mode 100644 index 0000000..d7bbf57 --- /dev/null +++ b/android/settings.gradle @@ -0,0 +1,39 @@ +pluginManagement { + def reactNativeGradlePlugin = new File( + providers.exec { + workingDir(rootDir) + commandLine("node", "--print", "require.resolve('@react-native/gradle-plugin/package.json', { paths: [require.resolve('react-native/package.json')] })") + }.standardOutput.asText.get().trim() + ).getParentFile().absolutePath + includeBuild(reactNativeGradlePlugin) + + def expoPluginsPath = new File( + providers.exec { + workingDir(rootDir) + commandLine("node", "--print", "require.resolve('expo-modules-autolinking/package.json', { paths: [require.resolve('expo/package.json')] })") + }.standardOutput.asText.get().trim(), + "../android/expo-gradle-plugin" + ).absolutePath + includeBuild(expoPluginsPath) +} + +plugins { + id("com.facebook.react.settings") + id("expo-autolinking-settings") +} + +extensions.configure(com.facebook.react.ReactSettingsExtension) { ex -> + if (System.getenv('EXPO_USE_COMMUNITY_AUTOLINKING') == '1') { + ex.autolinkLibrariesFromCommand() + } else { + ex.autolinkLibrariesFromCommand(expoAutolinking.rnConfigCommand) + } +} +expoAutolinking.useExpoModules() + +rootProject.name = 'info target' + +expoAutolinking.useExpoVersionCatalog() + +include ':app' +includeBuild(expoAutolinking.reactNativeGradlePlugin) diff --git a/api/URLs.ts b/api/URLs.ts new file mode 100644 index 0000000..b351da1 --- /dev/null +++ b/api/URLs.ts @@ -0,0 +1,33 @@ +export const BASE_URL = 'https://api.infotarget.uz/'; + +export const API_URLS = { + LOGIN: 'login/send_code/', + LoginConfirm: 'login/confirm/', + REFRESH: 'auth/token/refresh/', + DASHBOARD_ADS: '/api/dashboard-ads/', + DASHBOARD_ADS_DETAIL: (id: number) => `/api/dashboard-ads/${id}`, + ResendOTP: 'login/resend/', + Register: 'auth/register/', + Register_Confirm: 'auth/confirm/', + Register_Resend: 'auth/resend/', + Get_Products: 'api/product-service/', + Detail_Products: (id: number) => `api/product-service/${id}/`, + Delete_Products: (id: number) => `api/product-service/${id}/`, + Get_Company: 'api/product-service/product-service-legal-entity/', + Get_Countries: 'api/product-service/product-service-country/', + Get_States: 'api/countries/', + Get_Categories: 'api/category/', + Get_Categories_Child: (id: number) => `api/category/${id}/`, + Business_About: 'api/business-about/', + Business_About_Detail: (id: number) => `api/business-about/${id}/`, + Price_Calculation: `api/price-calculation/`, + Add_Ads: 'api/ad/', + Payment_Ads: (paymentType: 'payme' | 'referral', adId: number) => + `api/payments/${paymentType}/${adId}/`, + Get_Me: 'auth/me/', + User_Update: 'auth/user-update/', + Employee_List: 'api/employee/', + My_Ads: 'api/my-ads/', + My_Ads_Detail: (id: number) => `api/my-ads/${id}`, + My_Bonuses: 'api/cashback/', +}; diff --git a/api/httpClient.ts b/api/httpClient.ts new file mode 100644 index 0000000..1defde2 --- /dev/null +++ b/api/httpClient.ts @@ -0,0 +1,106 @@ +import axios from 'axios'; +import { API_URLS, BASE_URL } from './URLs'; + +import { getToken } from '@/hooks/storage.native'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { router } from 'expo-router'; + +const httpClient = axios.create({ + baseURL: BASE_URL, + timeout: 60000, +}); + +httpClient.interceptors.request.use(async (config) => { + const token = await getToken(); + const lang = await AsyncStorage.getItem('lang'); + + config.headers['Accept-Language'] = lang; + + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + + return config; +}); + +let isRefreshing = false; +let failedQueue: Array<{ resolve: (value: any) => void; reject: (reason?: any) => void }> = []; + +const processQueue = (error: any = null) => { + failedQueue.forEach((prom) => { + if (error) { + prom.reject(error); + } else { + prom.resolve(null); + } + }); + failedQueue = []; +}; + +httpClient.interceptors.response.use( + (response) => response, + + async (error) => { + const originalRequest = error.config; + + // Agar 401 bo'lsa va bu refresh so'rovi bo'lmasa + if (error.response?.status === 401 && !originalRequest._retry) { + if (isRefreshing) { + // Agar allaqachon refresh ketayotgan bo'lsa → kutamiz + return new Promise((resolve, reject) => { + failedQueue.push({ resolve, reject }); + }) + .then(() => httpClient(originalRequest)) + .catch((err) => Promise.reject(err)); + } + + originalRequest._retry = true; + isRefreshing = true; + + try { + const refresh = await AsyncStorage.getItem('refresh_token'); + + if (!refresh) { + throw new Error('Refresh token mavjud emas'); + } + + // Refresh token so'rovi + const { data } = await axios.post(`${BASE_URL}${API_URLS.REFRESH}`, { + refresh, // backend odatda { refresh } body kutadi + }); + + // Yangi tokenlarni saqlaymiz + await AsyncStorage.setItem('access_token', data.access); + await AsyncStorage.setItem('refresh_token', data.refresh || refresh); // agar yangi refresh kelmasa, eskisini saqlab qolamiz + + // Headerni yangilaymiz + originalRequest.headers.Authorization = `Bearer ${data.access}`; + + // Kutgan so'rovlarni qayta yuboramiz + processQueue(); + + // Original so'rovni qayta yuboramiz + return httpClient(originalRequest); + } catch (refreshError) { + console.error('Refresh token xatosi:', refreshError); + + // Refresh muvaffaqiyatsiz bo'lsa + processQueue(refreshError); + + // Tokenlarni o'chirib, logout qilamiz + await AsyncStorage.removeItem('access_token'); + await AsyncStorage.removeItem('refresh_token'); + + // Foydalanuvchini login sahifasiga yo'naltiramiz + router.replace('/'); + } finally { + isRefreshing = false; + } + } + + // Boshqa xatolar uchun oddiy reject + return Promise.reject(error); + } +); + +export default httpClient; diff --git a/app.json b/app.json index 4945108..4bba789 100644 --- a/app.json +++ b/app.json @@ -1,10 +1,10 @@ { "expo": { - "name": "info_target", - "slug": "info_target", + "name": "Info target", + "slug": "info-target", "version": "1.0.0", "orientation": "portrait", - "icon": "./assets/images/icon.png", + "icon": "./assets/images/logo.png", "scheme": "infotarget", "userInterfaceStyle": "automatic", "newArchEnabled": true, @@ -14,23 +14,36 @@ "android": { "adaptiveIcon": { "backgroundColor": "#E6F4FE", - "foregroundImage": "./assets/images/android-icon-foreground.png", - "backgroundImage": "./assets/images/android-icon-background.png", - "monochromeImage": "./assets/images/android-icon-monochrome.png" + "foregroundImage": "./assets/images/logo.png", + "backgroundImage": "./assets/images/logo.png", + "monochromeImage": "./assets/images/logo.png" }, "edgeToEdgeEnabled": true, - "predictiveBackGestureEnabled": false + "predictiveBackGestureEnabled": false, + "package": "com.felix.infotarget", + "versionCode": 1 }, "web": { "output": "static", - "favicon": "./assets/images/favicon.png" + "favicon": "./assets/images/logo.png" }, "plugins": [ "expo-router", + [ + "expo-navigation-bar", + { + "backgroundColor": "#0f172a", + "barStyle": "light", + "borderColor": "#1f2937", + "visibility": "visible", + "behavior": "inset-swipe", + "position": "relative" + } + ], [ "expo-splash-screen", { - "image": "./assets/images/splash-icon.png", + "image": "./assets/images/logo.png", "imageWidth": 200, "resizeMode": "contain", "backgroundColor": "#ffffff", @@ -43,6 +56,20 @@ "experiments": { "typedRoutes": true, "reactCompiler": true + }, + "extra": { + "router": {}, + "expo-navigation-bar": { + "backgroundColor": "#0f172a", + "barStyle": "light", + "borderColor": "#1f2937", + "visibility": "visible", + "behavior": "inset-swipe", + "position": "relative" + }, + "eas": { + "projectId": "9a281404-9d04-4493-b630-66c35af03ace" + } } } } diff --git a/app/(auth)/_layout.tsx b/app/(auth)/_layout.tsx new file mode 100644 index 0000000..2a52c0a --- /dev/null +++ b/app/(auth)/_layout.tsx @@ -0,0 +1,6 @@ +// app/(auth)/_layout.tsx +import { Slot } from 'expo-router'; + +export default function AuthLayout() { + return ; +} diff --git a/app/(auth)/confirm.tsx b/app/(auth)/confirm.tsx new file mode 100644 index 0000000..2aebe68 --- /dev/null +++ b/app/(auth)/confirm.tsx @@ -0,0 +1,18 @@ +import ConfirmScreen from '@/screens/auth/confirm/ConfirmScreen'; +import { ScrollView } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; + +export default function Confirm() { + return ( + + + + + + ); +} diff --git a/app/(auth)/index.tsx b/app/(auth)/index.tsx new file mode 100644 index 0000000..998c212 --- /dev/null +++ b/app/(auth)/index.tsx @@ -0,0 +1,39 @@ +import { useAuth } from '@/components/AuthProvider'; +import LoginScreen from '@/screens/auth/login/ui/LoginScreens'; +import { router } from 'expo-router'; +import { useEffect } from 'react'; +import { ActivityIndicator, ScrollView, View } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; + +export default function Index() { + const { isAuthenticated, isLoading } = useAuth(); + + // Loading spinner + if (isLoading) { + return ( + + + + ); + } + + // Token bo‘lsa dashboard-ga yo‘naltir + useEffect(() => { + if (isAuthenticated) { + router.replace('/(dashboard)'); + } + }, [isAuthenticated]); + + // Token yo‘q → login screen + if (!isAuthenticated) { + return ( + + + + + + ); + } + + return null; +} diff --git a/app/(auth)/register-confirm.tsx b/app/(auth)/register-confirm.tsx new file mode 100644 index 0000000..f75f7c9 --- /dev/null +++ b/app/(auth)/register-confirm.tsx @@ -0,0 +1,18 @@ +import RegisterConfirmScreen from '@/screens/auth/register-confirm/ConfirmScreen'; +import { ScrollView } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; + +export default function RegisterConfirm() { + return ( + + + + + + ); +} diff --git a/app/(auth)/register.tsx b/app/(auth)/register.tsx new file mode 100644 index 0000000..7592042 --- /dev/null +++ b/app/(auth)/register.tsx @@ -0,0 +1,22 @@ +import RegisterScreen from '@/screens/auth/register/RegisterScreen'; +import React from 'react'; +import { ScrollView, StyleSheet } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; + +export default function Index() { + return ( + + + + + + ); +} + +const styles = StyleSheet.create({ + safeArea: { flex: 1, backgroundColor: '#0f172a' }, +}); diff --git a/app/(auth)/select-category.tsx b/app/(auth)/select-category.tsx new file mode 100644 index 0000000..de5671a --- /dev/null +++ b/app/(auth)/select-category.tsx @@ -0,0 +1,263 @@ +import AuthHeader from '@/components/ui/AuthHeader'; +import { auth_api } from '@/screens/auth/login/lib/api'; +import { products_api } from '@/screens/home/lib/api'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { useMutation, useQuery } from '@tanstack/react-query'; +import { AxiosError } from 'axios'; +import { LinearGradient } from 'expo-linear-gradient'; +import { Stack, useLocalSearchParams, useRouter } from 'expo-router'; +import { ChevronLeft } from 'lucide-react-native'; +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + ActivityIndicator, + Alert, + ScrollView, + StyleSheet, + Text, + TouchableOpacity, +} from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; + +interface Category { + id: number; + name: string; + is_leaf: boolean; +} + +export default function CategorySelectScreen() { + const router = useRouter(); + const { t } = useTranslation(); + const { phone, stir, person_type } = useLocalSearchParams<{ + phone: string; + stir: string; + person_type: 'band' | 'ytt'; + }>(); + + const [selected, setSelected] = useState(null); + const [categories, setCategories] = useState([]); + const [history, setHistory] = useState([]); + + /** ROOT categories */ + const { isLoading } = useQuery({ + queryKey: ['categories-root'], + queryFn: () => products_api.getCategorys(), + select: (res) => { + setCategories(res.data.data); + }, + }); + + /** CHILD categories */ + const childMutation = useMutation({ + mutationFn: (id: number) => products_api.getCategorys({ parent: id }), + onSuccess: (res) => { + setHistory((prev) => [...prev, categories]); + setCategories(res.data.data); + }, + }); + + /** REGISTER */ + const registerMutation = useMutation({ + mutationFn: (body: { + phone: string; + stir: string; + person_type: string; + activate_types: number[]; + }) => auth_api.register(body), + onSuccess: async () => { + router.replace('/(auth)/register-confirm'); + await AsyncStorage.setItem('phone', phone); + }, + onError: (err: AxiosError) => { + const errMessage = (err.response?.data as { data: { stir: string[] } }).data.stir[0]; + const errMessageDetail = (err.response?.data as { data: { detail: string } }).data.detail; + + const errrAlert = errMessage ? errMessage : errMessageDetail; + + Alert.alert(t('Xatolik yuz berdi'), errMessage || errrAlert || t('erroXatolik yuz berdi')); + }, + }); + + const onCategoryPress = (cat: Category) => { + if (cat.is_leaf) { + setSelected(cat.id); + } else { + childMutation.mutate(cat.id); + } + }; + + const goBack = () => { + if (history.length === 0) return; + const prev = history[history.length - 1]; + setCategories(prev); + setHistory((h) => h.slice(0, -1)); + setSelected(null); + }; + + return ( + + + + + + + + {history.length > 0 && ( + + + {t('Orqaga')} + + )} + {t("Yo'nalishni tanlang")} + + {isLoading || childMutation.isPending ? ( + + ) : ( + categories.map((c) => { + const active = selected === c.id; + return ( + onCategoryPress(c)} + > + {c.name} + + ); + }) + )} + + + { + if (!selected) return; + registerMutation.mutate({ + activate_types: [selected], + person_type, + phone: `998${phone}`, + stir, + }); + }} + > + + {t('Tadiqlash')} + + + + ); +} + +const styles = StyleSheet.create({ + title: { + fontSize: 18, + fontWeight: '700', + color: '#ffffff', + marginBottom: 12, + }, + safeArea: { + flex: 1, + backgroundColor: '#0f172a', + }, + + container: { + paddingHorizontal: 20, + paddingTop: 16, + paddingBottom: 70, + gap: 12, + }, + + backBtn: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + marginBottom: 12, + }, + + backText: { + fontSize: 14, + color: '#3b82f6', + fontWeight: '600', + }, + + item: { + paddingVertical: 18, + paddingHorizontal: 18, + borderRadius: 16, + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + backgroundColor: 'rgba(255,255,255,0.06)', + borderWidth: 1, + borderColor: 'rgba(255,255,255,0.12)', + }, + + itemActive: { + backgroundColor: 'rgba(59,130,246,0.15)', + borderColor: 'rgba(59,130,246,0.6)', + }, + + text: { + fontSize: 15, + fontWeight: '600', + color: '#cbd5f5', + }, + + textActive: { + color: '#ffffff', + fontWeight: '800', + }, + + arrow: { + fontSize: 18, + color: '#94a3b8', + }, + + bottom: { + position: 'absolute', + bottom: 20, + left: 16, + right: 16, + height: 54, + borderRadius: 16, + backgroundColor: '#3b82f6', + alignItems: 'center', + justifyContent: 'center', + shadowColor: '#3b82f6', + shadowOffset: { width: 0, height: 8 }, + shadowOpacity: 0.35, + shadowRadius: 12, + elevation: 10, + }, + + bottomDisabled: { + backgroundColor: '#64748b', + }, + + bottomText: { + color: '#ffffff', + fontWeight: '800', + fontSize: 16, + }, + + decorCircle1: { + position: 'absolute', + top: -120, + right: -80, + width: 300, + height: 300, + borderRadius: 150, + backgroundColor: 'rgba(59,130,246,0.12)', + }, + + decorCircle2: { + position: 'absolute', + bottom: -120, + left: -100, + width: 280, + height: 280, + borderRadius: 140, + backgroundColor: 'rgba(16,185,129,0.1)', + }, +}); diff --git a/app/(dashboard)/_layout.tsx b/app/(dashboard)/_layout.tsx new file mode 100644 index 0000000..f1f6ec0 --- /dev/null +++ b/app/(dashboard)/_layout.tsx @@ -0,0 +1,143 @@ +import { useTheme } from '@/components/ThemeContext'; +import { RefreshProvider } from '@/components/ui/RefreshContext'; +import { BottomSheetModalProvider } from '@gorhom/bottom-sheet'; +import { Tabs } from 'expo-router'; +import { Home, Megaphone, PlusCircle, User } from 'lucide-react-native'; +import { useTranslation } from 'react-i18next'; +import { Text } from 'react-native'; +import { GestureHandlerRootView } from 'react-native-gesture-handler'; + +export default function TabsLayout() { + const { isDark } = useTheme(); + const { t } = useTranslation(); + + return ( + + + + + ( + + {t('Bosh sahifa')} + + ), + tabBarIcon: ({ color, size }) => , + }} + /> + + ( + + {t("E'lon joylashtirish")} + + ), + tabBarIcon: ({ color, size }) => , + }} + /> + + ( + + {t("E'lonlar")} + + ), + tabBarIcon: ({ color, size }) => , + }} + /> + + ( + + {t('Profil')} + + ), + tabBarIcon: ({ color, size }) => , + }} + /> + + + + + ); +} diff --git a/app/(dashboard)/announcements.tsx b/app/(dashboard)/announcements.tsx new file mode 100644 index 0000000..fa1cdab --- /dev/null +++ b/app/(dashboard)/announcements.tsx @@ -0,0 +1,21 @@ +import { useTheme } from '@/components/ThemeContext'; +import { FilterProvider } from '@/components/ui/FilterContext'; +import { CustomHeader } from '@/components/ui/Header'; +import DashboardScreen from '@/screens/announcements/ui/AnnouncementsList'; +import { Stack } from 'expo-router'; +import { SafeAreaView } from 'react-native-safe-area-context'; + +export default function Announcements() { + const { isDark } = useTheme(); + return ( + + + + + + + + ); +} diff --git a/app/(dashboard)/create-announcements.tsx b/app/(dashboard)/create-announcements.tsx new file mode 100644 index 0000000..15f95d5 --- /dev/null +++ b/app/(dashboard)/create-announcements.tsx @@ -0,0 +1,17 @@ +import { useTheme } from '@/components/ThemeContext'; +import { FilterProvider } from '@/components/ui/FilterContext'; +import { CustomHeader } from '@/components/ui/Header'; +import CreateAdsScreens from '@/screens/create-ads/ui/CreateAdsScreens'; +import { SafeAreaView } from 'react-native-safe-area-context'; + +export default function CreateAnnouncements() { + const { isDark } = useTheme(); + return ( + + + + + + + ); +} diff --git a/app/(dashboard)/index.tsx b/app/(dashboard)/index.tsx new file mode 100644 index 0000000..ad77adc --- /dev/null +++ b/app/(dashboard)/index.tsx @@ -0,0 +1,33 @@ +// pages/home/index.tsx +import { useAuth } from '@/components/AuthProvider'; +import { useTheme } from '@/components/ThemeContext'; +import { FilterProvider } from '@/components/ui/FilterContext'; +import { CustomHeader } from '@/components/ui/Header'; +import HomeScreen from '@/screens/home/ui/HomeScreen'; +import { router } from 'expo-router'; +import { useEffect } from 'react'; +import { SafeAreaView } from 'react-native-safe-area-context'; + +export default function Index() { + const { isDark } = useTheme(); + const { isAuthenticated, isLoading } = useAuth(); + + useEffect(() => { + if (!isLoading && !isAuthenticated) { + router.replace('/(auth)'); + } + }, [isAuthenticated, isLoading]); + + if (isLoading || !isAuthenticated) { + return null; // Loading vaqtida yoki auth yo‘q bo‘lsa hech narsa ko‘rmasin + } + + return ( + + + + + + + ); +} diff --git a/app/(dashboard)/profile.tsx b/app/(dashboard)/profile.tsx new file mode 100644 index 0000000..fd33adb --- /dev/null +++ b/app/(dashboard)/profile.tsx @@ -0,0 +1,17 @@ +import { useTheme } from '@/components/ThemeContext'; +import { CustomHeader } from '@/components/ui/Header'; +import Profile from '@/screens/profile/ui/ProfileScreen'; +import { SafeAreaView } from 'react-native-safe-area-context'; + +export default function ProfileScreen() { + const { isDark } = useTheme(); + return ( + + + + + ); +} diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx deleted file mode 100644 index 54e11d0..0000000 --- a/app/(tabs)/_layout.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { Tabs } from 'expo-router'; -import React from 'react'; - -import { HapticTab } from '@/components/haptic-tab'; -import { IconSymbol } from '@/components/ui/icon-symbol'; -import { Colors } from '@/constants/theme'; -import { useColorScheme } from '@/hooks/use-color-scheme'; - -export default function TabLayout() { - const colorScheme = useColorScheme(); - - return ( - - , - }} - /> - , - }} - /> - - ); -} diff --git a/app/(tabs)/explore.tsx b/app/(tabs)/explore.tsx deleted file mode 100644 index 71518f9..0000000 --- a/app/(tabs)/explore.tsx +++ /dev/null @@ -1,112 +0,0 @@ -import { Image } from 'expo-image'; -import { Platform, StyleSheet } from 'react-native'; - -import { Collapsible } from '@/components/ui/collapsible'; -import { ExternalLink } from '@/components/external-link'; -import ParallaxScrollView from '@/components/parallax-scroll-view'; -import { ThemedText } from '@/components/themed-text'; -import { ThemedView } from '@/components/themed-view'; -import { IconSymbol } from '@/components/ui/icon-symbol'; -import { Fonts } from '@/constants/theme'; - -export default function TabTwoScreen() { - return ( - - }> - - - Explore - - - This app includes example code to help you get started. - - - This app has two screens:{' '} - app/(tabs)/index.tsx and{' '} - app/(tabs)/explore.tsx - - - The layout file in app/(tabs)/_layout.tsx{' '} - sets up the tab navigator. - - - Learn more - - - - - You can open this project on Android, iOS, and the web. To open the web version, press{' '} - w in the terminal running this project. - - - - - For static images, you can use the @2x and{' '} - @3x suffixes to provide files for - different screen densities - - - - Learn more - - - - - This template has light and dark mode support. The{' '} - useColorScheme() hook lets you inspect - what the user's current color scheme is, and so you can adjust UI colors accordingly. - - - Learn more - - - - - This template includes an example of an animated component. The{' '} - components/HelloWave.tsx component uses - the powerful{' '} - - react-native-reanimated - {' '} - library to create a waving hand animation. - - {Platform.select({ - ios: ( - - The components/ParallaxScrollView.tsx{' '} - component provides a parallax effect for the header image. - - ), - })} - - - ); -} - -const styles = StyleSheet.create({ - headerImage: { - color: '#808080', - bottom: -90, - left: -35, - position: 'absolute', - }, - titleContainer: { - flexDirection: 'row', - gap: 8, - }, -}); diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx deleted file mode 100644 index 786b736..0000000 --- a/app/(tabs)/index.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import { Image } from 'expo-image'; -import { Platform, StyleSheet } from 'react-native'; - -import { HelloWave } from '@/components/hello-wave'; -import ParallaxScrollView from '@/components/parallax-scroll-view'; -import { ThemedText } from '@/components/themed-text'; -import { ThemedView } from '@/components/themed-view'; -import { Link } from 'expo-router'; - -export default function HomeScreen() { - return ( - - }> - - Welcome! - - - - Step 1: Try it - - Edit app/(tabs)/index.tsx to see changes. - Press{' '} - - {Platform.select({ - ios: 'cmd + d', - android: 'cmd + m', - web: 'F12', - })} - {' '} - to open developer tools. - - - - - - Step 2: Explore - - - - alert('Action pressed')} /> - alert('Share pressed')} - /> - - alert('Delete pressed')} - /> - - - - - - {`Tap the Explore tab to learn more about what's included in this starter app.`} - - - - Step 3: Get a fresh start - - {`When you're ready, run `} - npm run reset-project to get a fresh{' '} - app directory. This will move the current{' '} - app to{' '} - app-example. - - - - ); -} - -const styles = StyleSheet.create({ - titleContainer: { - flexDirection: 'row', - alignItems: 'center', - gap: 8, - }, - stepContainer: { - gap: 8, - marginBottom: 8, - }, - reactLogo: { - height: 178, - width: 290, - bottom: 0, - left: 0, - position: 'absolute', - }, -}); diff --git a/app/_layout.tsx b/app/_layout.tsx index f518c9b..d1bb677 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -1,24 +1,34 @@ -import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native'; +import { AuthProvider } from '@/components/AuthProvider'; +import QueryProvider from '@/components/QueryProvider'; +import { ThemeProvider } from '@/components/ThemeContext'; +import i18n from '@/i18n/i18n'; +import { ProfileDataProvider } from '@/screens/profile/lib/ProfileDataContext'; import { Stack } from 'expo-router'; import { StatusBar } from 'expo-status-bar'; +import { I18nextProvider } from 'react-i18next'; import 'react-native-reanimated'; -import { useColorScheme } from '@/hooks/use-color-scheme'; - -export const unstable_settings = { - anchor: '(tabs)', -}; - -export default function RootLayout() { - const colorScheme = useColorScheme(); - +function AppContent() { return ( - - - - - - - + <> + + + + ); +} + +export default function RootLayout() { + return ( + + + + + + + + + + + ); } diff --git a/app/index.tsx b/app/index.tsx new file mode 100644 index 0000000..f49fe74 --- /dev/null +++ b/app/index.tsx @@ -0,0 +1,21 @@ +import { useAuth } from '@/components/AuthProvider'; +import { Redirect } from 'expo-router'; +import { ActivityIndicator, View } from 'react-native'; + +export default function Index() { + const { isAuthenticated, isLoading } = useAuth(); + + if (isLoading) { + return ( + + + + ); + } + + if (isAuthenticated) { + return ; + } + + return ; +} diff --git a/app/modal.tsx b/app/modal.tsx deleted file mode 100644 index 6dfbc1a..0000000 --- a/app/modal.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { Link } from 'expo-router'; -import { StyleSheet } from 'react-native'; - -import { ThemedText } from '@/components/themed-text'; -import { ThemedView } from '@/components/themed-view'; - -export default function ModalScreen() { - return ( - - This is a modal - - Go to home screen - - - ); -} - -const styles = StyleSheet.create({ - container: { - flex: 1, - alignItems: 'center', - justifyContent: 'center', - padding: 20, - }, - link: { - marginTop: 15, - paddingVertical: 15, - }, -}); diff --git a/app/profile/_layout.tsx b/app/profile/_layout.tsx new file mode 100644 index 0000000..89e07da --- /dev/null +++ b/app/profile/_layout.tsx @@ -0,0 +1,16 @@ +import { RefreshProvider } from '@/components/ui/RefreshContext'; +import { BottomSheetModalProvider } from '@gorhom/bottom-sheet'; +import { Stack } from 'expo-router'; +import { GestureHandlerRootView } from 'react-native-gesture-handler'; + +export default function TabsLayout() { + return ( + + + + + + + + ); +} diff --git a/app/profile/bonuses.tsx b/app/profile/bonuses.tsx new file mode 100644 index 0000000..a69d610 --- /dev/null +++ b/app/profile/bonuses.tsx @@ -0,0 +1,5 @@ +import BonusesScreen from '@/screens/profile/ui/BonusesScreen'; + +export default function Bonuses() { + return ; +} diff --git a/app/profile/categories.tsx b/app/profile/categories.tsx new file mode 100644 index 0000000..f6daa31 --- /dev/null +++ b/app/profile/categories.tsx @@ -0,0 +1,227 @@ +import { useTheme } from '@/components/ThemeContext'; +import CategorySelection from '@/components/ui/IndustrySelection'; +import { user_api } from '@/screens/profile/lib/api'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useRouter } from 'expo-router'; +import { ArrowLeft, XIcon } from 'lucide-react-native'; +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + ActivityIndicator, + Alert, + FlatList, + Pressable, + ScrollView, + StyleSheet, + Text, + ToastAndroid, + TouchableOpacity, + View, +} from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; + +export default function PersonalInfoScreen() { + const router = useRouter(); + const queryClient = useQueryClient(); + const { isDark } = useTheme(); + const { t } = useTranslation(); + + const theme = { + background: isDark ? '#0f172a' : '#f8fafc', + text: isDark ? '#ffffff' : '#0f172a', + textSecondary: isDark ? '#64748b' : '#94a3b8', + primary: '#3b82f6', + tabBg: isDark ? '#1e293b' : '#e0e7ff', + tabText: isDark ? '#ffffff' : '#4338ca', + deleteBg: isDark ? '#394e73' : '#cbd5e1', + deleteIcon: isDark ? '#f8fafc' : '#475569', + shadow: isDark ? '#000' : '#64748b', + }; + + const [selectedCategories, setSelectedCategories] = useState([]); + + const { data: me, isLoading } = useQuery({ + queryKey: ['get_me'], + queryFn: () => user_api.getMe(), + }); + + useEffect(() => { + if (me?.data.data?.activate_types) { + setSelectedCategories(me.data.data.activate_types.map((a: any) => a)); + } + }, [me]); + + const updateMutation = useMutation({ + mutationFn: (body: { + first_name: string; + industries: { + id: number; + name: string; + code: string; + external_id: null | number; + level: number; + is_leaf: boolean; + icon_name: null | string; + parent: { + id: number; + name: string; + code: string; + }; + }[]; + phone: string; + person_type: 'employee' | 'legal_entity' | 'ytt' | 'band'; + activate_types: number[]; + }) => user_api.updateMe(body), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['get_me'] }); + router.push('/profile/personal-info'); + ToastAndroid.show(t("Ma'lumotlar yangilandi"), ToastAndroid.TOP); + }, + onError: () => { + Alert.alert(t('Xatolik yzu berdi'), t("Ma'lumotlarni yangilashda xatolik yuz berdi")); + }, + }); + + const removeCategory = (id: string | number) => { + setSelectedCategories((prev) => prev.filter((c) => c.id !== id)); + }; + + const renderTab = ({ item }: { item: any }) => ( + + + {item.name} + + removeCategory(item.id)} + style={[styles.deleteTab, { backgroundColor: theme.deleteBg }]} + > + + + + ); + + if (isLoading) { + return ( + + + router.push('/profile/personal-info')}> + + + {t('Faoliyat sohalari')} + + {t('Tayyor')} + + + + + ); + } + + return ( + + + router.push('/profile/personal-info')}> + + + {t('Faoliyat sohalari')} + { + if (me) { + const activate_types = selectedCategories.map((e) => e.id) ?? []; + updateMutation.mutate({ + person_type: me?.data.data.person_type, + first_name: me?.data.data.first_name, + phone: me.data.data.phone, + industries: selectedCategories, + activate_types, + }); + } + }} + > + {updateMutation.isPending ? ( + + ) : ( + {t('Tayyor')} + )} + + + + + {selectedCategories.length > 0 && ( + String(item.id)} + renderItem={renderTab} + horizontal + showsHorizontalScrollIndicator={false} + keyboardShouldPersistTaps="handled" + contentContainerStyle={styles.tabsContainer} + style={styles.tabsList} + ItemSeparatorComponent={() => } + /> + )} + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + tabsList: { + maxHeight: 56, + }, + tabsContainer: { + alignItems: 'center', + marginBottom: 20, + }, + tabWrapper: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 8, + paddingHorizontal: 12, + borderRadius: 20, + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.2, + shadowRadius: 3, + elevation: 3, + }, + tabText: { + fontSize: 14, + fontWeight: '600', + marginRight: 6, + maxWidth: 200, + flexShrink: 1, + }, + deleteTab: { + padding: 4, + borderRadius: 12, + justifyContent: 'center', + alignItems: 'center', + }, + loadingText: { + fontSize: 16, + textAlign: 'center', + marginTop: 40, + }, + topHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + padding: 16, + alignItems: 'center', + }, + headerTitle: { fontSize: 18, fontWeight: '700' }, +}); diff --git a/app/profile/employees.tsx b/app/profile/employees.tsx new file mode 100644 index 0000000..e7b0986 --- /dev/null +++ b/app/profile/employees.tsx @@ -0,0 +1,5 @@ +import { EmployeesTab } from '@/screens/profile/ui/EmployeesTab'; + +export default function EmployeesScreen() { + return ; +} diff --git a/app/profile/employees/[id].tsx b/app/profile/employees/[id].tsx new file mode 100644 index 0000000..0c7e2ee --- /dev/null +++ b/app/profile/employees/[id].tsx @@ -0,0 +1,5 @@ +import { EmployeesTab } from '@/screens/profile/ui/EmployeesTab'; + +export default function EmployeeDetailScreen() { + return ; +} diff --git a/app/profile/employees/add.tsx b/app/profile/employees/add.tsx new file mode 100644 index 0000000..482b992 --- /dev/null +++ b/app/profile/employees/add.tsx @@ -0,0 +1,9 @@ +import AddEmployee from '@/screens/profile/ui/AddEmployee'; + +export default function AddEmployeeScreen() { + return ( + <> + + + ); +} diff --git a/app/profile/my-ads.tsx b/app/profile/my-ads.tsx new file mode 100644 index 0000000..b525b8a --- /dev/null +++ b/app/profile/my-ads.tsx @@ -0,0 +1,12 @@ +import { useTheme } from '@/components/ThemeContext'; +import { AnnouncementsTab } from '@/screens/profile/ui/AnnouncementsTab'; +import { SafeAreaView } from 'react-native-safe-area-context'; + +export default function MyAds() { + const { isDark } = useTheme(); + return ( + + + + ); +} diff --git a/app/profile/personal-info.tsx b/app/profile/personal-info.tsx new file mode 100644 index 0000000..a0f018a --- /dev/null +++ b/app/profile/personal-info.tsx @@ -0,0 +1,346 @@ +import { useTheme } from '@/components/ThemeContext'; +import { formatNumber, formatPhone, normalizeDigits } from '@/constants/formatPhone'; +import { user_api } from '@/screens/profile/lib/api'; +import { UserInfoResponseData } from '@/screens/profile/lib/type'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useRouter } from 'expo-router'; +import { ArrowLeft, Edit2, Plus } from 'lucide-react-native'; +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + ActivityIndicator, + Alert, + Image, + Pressable, + ScrollView, + StyleSheet, + Text, + TextInput, + ToastAndroid, + View, +} from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; + +export default function PersonalInfoScreen() { + const router = useRouter(); + const queryClient = useQueryClient(); + const { isDark } = useTheme(); + const { t } = useTranslation(); + + const theme = { + background: isDark ? '#0f172a' : '#f8fafc', + cardBg: isDark ? '#1e293b' : '#ffffff', + text: isDark ? '#f8fafc' : '#0f172a', + textSecondary: isDark ? '#94a3b8' : '#64748b', + textTertiary: isDark ? '#64748b' : '#94a3b8', + inputBg: isDark ? '#1e293b' : '#f1f5f9', + inputBorder: isDark ? '#334155' : '#e2e8f0', + primary: '#3b82f6', + chipBg: isDark ? '#1e293b' : '#e0e7ff', + chipText: isDark ? '#f8fafc' : '#4338ca', + divider: isDark ? '#334155' : '#cbd5e1', + placeholder: isDark ? '#64748b' : '#94a3b8', + }; + + const [isEditing, setIsEditing] = useState(false); + const [editData, setEditData] = useState(null); + const [phone, setPhone] = useState(''); + const [focused, setFocused] = useState(false); + const [showCategories, setShowCategories] = useState(false); + const [selectedCategories, setSelectedCategories] = useState([]); + + const { data: me, isLoading } = useQuery({ + queryKey: ['get_me'], + queryFn: () => user_api.getMe(), + }); + + useEffect(() => { + if (me?.data.data) { + setEditData(me.data); + + const rawPhone = normalizeDigits(me.data.data.phone || ''); + setPhone(rawPhone.startsWith('998') ? rawPhone.slice(3) : rawPhone); + + setSelectedCategories(me.data.data.activate_types ?? []); + } + }, [me]); + + const updateMutation = useMutation({ + mutationFn: (body: any) => user_api.updateMe(body), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['get_me'] }); + setIsEditing(false); + setShowCategories(false); + ToastAndroid.show(t("Ma'lumotlar yangilandi"), ToastAndroid.TOP); + }, + onError: () => { + Alert.alert(t('Xatolik yuz berdi'), t('Yangilashda xatolik yuz berdi')); + }, + }); + + const handleSave = () => { + if (!editData) return; + + updateMutation.mutate({ + first_name: editData.data.first_name, + phone: normalizeDigits(phone), + person_type: editData.data.person_type, + industries: editData.data.activate_types, + activate_types: editData.data.activate_types.map((e) => e.id), + + company_name: editData.data.company_name, + stir: editData.data.stir, + director_full_name: editData.data.director_full_name, + address: editData.data.address, + }); + }; + + const removeCategory = (id: number) => { + setSelectedCategories((prev) => prev.filter((c) => c.id !== id)); + }; + + if (isLoading) { + return ( + + + setIsEditing(false)}> + + + {t('Tahrirlash')} + + {t('Saqlash')} + + + + + ); + } + + /* ===================== EDIT MODE ===================== */ + if (isEditing && editData) { + return ( + + + setIsEditing(false)}> + + + {t('Tahrirlash')} + + {updateMutation.isPending ? ( + + ) : ( + {t('Saqlash')} + )} + + + + + + {t('Ism')} + setEditData((prev) => prev && { ...prev, first_name: text })} + placeholderTextColor={theme.placeholder} + /> + + {t('Telefon raqami')} + + + + + +998 + + + + setPhone(normalizeDigits(text))} + onFocus={() => setFocused(true)} + onBlur={() => setFocused(false)} + keyboardType="phone-pad" + placeholder="90 123 45 67" + maxLength={12} + placeholderTextColor={theme.placeholder} + /> + + + + + ); + } + + /* ===================== VIEW MODE ===================== */ + return ( + + + router.push('/profile')}> + + + {t("Shaxsiy ma'lumotlar")} + setIsEditing(true)}> + + + + + + + {t('Ism')} + {me?.data.data.first_name} + + + {t('Telefon raqami')} + + + {me && formatNumber(me?.data.data.phone)} + + + + {me?.data.data.person_type !== 'employee' && ( + + + {t('Kompaniya')} + + + {me?.data.data.company_image && ( + + )} + + {t('Nomi')} + + {me?.data.data.company_name} + + + {t('STIR')} + {me?.data.data.stir} + + {t('Direktor')} + + {me?.data.data.director_full_name} + + {me?.data.data.address && ( + <> + + {t('Manzil')} + + + {me?.data.data.address} + + + )} + + )} + + + + {t('Faoliyat sohalari')} + + router.push('/profile/categories')} + /> + + + {me?.data.data.activate_types.map((field: any) => ( + + {field.name} + + ))} + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + fieldsContainer: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 }, + fieldChip: { + paddingHorizontal: 16, + paddingVertical: 10, + borderRadius: 16, + }, + fieldText: { fontSize: 14, fontWeight: '600' }, + section: { + padding: 10, + marginTop: 0, + }, + content: { + padding: 16, + }, + loadingText: { + textAlign: 'center', + marginTop: 40, + }, + infoCard: { + borderRadius: 20, + padding: 16, + marginBottom: 16, + }, + infoLabel: { + fontSize: 13, + marginTop: 8, + }, + infoValue: { + fontSize: 16, + fontWeight: '600', + }, + sectionTitle: { + fontWeight: '700', + fontSize: 16, + marginBottom: 12, + }, + editSection: { + gap: 12, + }, + label: { + fontSize: 13, + }, + input: { + borderRadius: 14, + padding: 14, + }, + phoneInputContainer: { + flexDirection: 'row', + borderRadius: 14, + padding: 14, + }, + phoneInput: { + flex: 1, + }, + saveButton: { + fontSize: 16, + fontWeight: '600', + }, + companyImage: { + width: 90, + height: 90, + borderRadius: 45, + alignSelf: 'center', + marginBottom: 12, + }, + topHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + padding: 16, + alignItems: 'center', + }, + headerTitle: { fontSize: 18, fontWeight: '700' }, + prefixContainer: { flexDirection: 'row', alignItems: 'center', marginRight: 12 }, + prefix: { fontSize: 17, fontWeight: '600' }, + prefixFocused: {}, + divider: { width: 1, height: 24, marginLeft: 12 }, +}); diff --git a/app/profile/products.tsx b/app/profile/products.tsx new file mode 100644 index 0000000..f2e959c --- /dev/null +++ b/app/profile/products.tsx @@ -0,0 +1,5 @@ +import MyServicesScreen from '@/screens/profile/ui/MyServices'; + +export default function ProductsScreen() { + return ; +} diff --git a/app/profile/products/add.tsx b/app/profile/products/add.tsx new file mode 100644 index 0000000..f1d5dca --- /dev/null +++ b/app/profile/products/add.tsx @@ -0,0 +1,5 @@ +import AddService from '@/screens/profile/ui/AddService'; + +export default function AddProductScreen() { + return ; +} diff --git a/app/profile/products/edit/[id].tsx b/app/profile/products/edit/[id].tsx new file mode 100644 index 0000000..1df57b3 --- /dev/null +++ b/app/profile/products/edit/[id].tsx @@ -0,0 +1,5 @@ +import EditServices from '@/screens/profile/ui/EditServices'; + +export default function EditProductScreen() { + return ; +} diff --git a/app/profile/settings.tsx b/app/profile/settings.tsx new file mode 100644 index 0000000..7223170 --- /dev/null +++ b/app/profile/settings.tsx @@ -0,0 +1,166 @@ +import GB from '@/assets/images/GB.png'; +import RU from '@/assets/images/RU.png'; +import UZ from '@/assets/images/UZ.png'; +import { useTheme } from '@/components/ThemeContext'; +import { saveLang } from '@/hooks/storage.native'; +import { useLanguage } from '@/i18n/useLanguage'; +import { useQueryClient } from '@tanstack/react-query'; +import { Image } from 'expo-image'; +import { useRouter } from 'expo-router'; +import { ChevronLeft, Moon, Sun } from 'lucide-react-native'; +import { useTranslation } from 'react-i18next'; +import { Pressable, ScrollView, StyleSheet, Switch, Text, View } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; + +export default function SettingsScreen() { + const router = useRouter(); + const { language, changeLanguage } = useLanguage(); + const { t, i18n } = useTranslation(); + const queryClient = useQueryClient(); + + const selectLanguage = async (lang: string) => { + changeLanguage(lang as 'uz' | 'ru' | 'en'); + await i18n.changeLanguage(lang); + queryClient.invalidateQueries(); + await saveLang(lang); + }; + const { isDark, toggleTheme } = useTheme(); + + const languages = [ + { code: 'uz', label: "O'zbek", icon: UZ }, + { code: 'ru', label: 'Русский', icon: RU }, + { code: 'en', label: 'English', icon: GB }, + ]; + + return ( + + + {/* Header */} + + router.back()}> + + + + + {t('Sozlamalar')} + + + + + + {/* Language Cards */} + + {t('Tilni tanlang')} + + {languages.map((lang) => { + const active = language === lang.code; + return ( + selectLanguage(lang.code)} + style={[ + styles.langCard, + isDark ? styles.darkCard : styles.lightCard, + active && styles.langActiveCard, + ]} + > + + + + {lang.label} + + + + ); + })} + + {/* Theme */} + + + {t('Rejimni tanlang')} + + + + {isDark ? : } + + {isDark ? t('Tungi rejim') : t("Yorug' rejim")} + + + + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + + /* Backgrounds */ + lightBg: { backgroundColor: '#f9fafb' }, + darkBg: { backgroundColor: '#0f172a' }, + + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + padding: 16, + }, + + headerTitle: { + fontSize: 18, + fontWeight: '600', + }, + + sectionTitle: { + fontSize: 16, + fontWeight: '600', + paddingHorizontal: 16, + marginBottom: 8, + }, + + card: { + borderRadius: 14, + padding: 16, + marginHorizontal: 16, + marginVertical: 8, + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + + lightCard: { backgroundColor: '#ffffff' }, + darkCard: { backgroundColor: '#1e293b', borderWidth: 1, borderColor: '#334155' }, + + row: { flexDirection: 'row', alignItems: 'center', gap: 10 }, + + label: { fontSize: 15, fontWeight: '500' }, + darkText: { color: '#f8fafc' }, + lightText: { color: '#0f172a' }, + + /* Language Cards */ + langCard: { + borderRadius: 14, + padding: 16, + marginHorizontal: 16, + marginVertical: 6, + flexDirection: 'row', + alignItems: 'center', + }, + langActiveCard: { backgroundColor: '#3b82f6' }, +}); diff --git a/assets/images/GB.png b/assets/images/GB.png new file mode 100644 index 0000000..6b9df52 Binary files /dev/null and b/assets/images/GB.png differ diff --git a/assets/images/Payme_NEW.png b/assets/images/Payme_NEW.png new file mode 100644 index 0000000..82167fb Binary files /dev/null and b/assets/images/Payme_NEW.png differ diff --git a/assets/images/RU.png b/assets/images/RU.png new file mode 100644 index 0000000..9600ecb Binary files /dev/null and b/assets/images/RU.png differ diff --git a/assets/images/UZ.png b/assets/images/UZ.png new file mode 100644 index 0000000..dc4ed17 Binary files /dev/null and b/assets/images/UZ.png differ diff --git a/assets/images/logo.png b/assets/images/logo.png new file mode 100644 index 0000000..4ac508d Binary files /dev/null and b/assets/images/logo.png differ diff --git a/assets/images/payme.png b/assets/images/payme.png new file mode 100644 index 0000000..097ceb7 Binary files /dev/null and b/assets/images/payme.png differ diff --git a/babel.config.js b/babel.config.js new file mode 100644 index 0000000..c854b8b --- /dev/null +++ b/babel.config.js @@ -0,0 +1,22 @@ +module.exports = function (api) { + api.cache(true); + + return { + presets: [['babel-preset-expo'], 'nativewind/babel'], + + plugins: [ + [ + 'module-resolver', + { + root: ['./'], + + alias: { + '@': './', + 'tailwind.config': './tailwind.config.js', + }, + }, + ], + 'react-native-worklets/plugin', + ], + }; +}; diff --git a/components/AuthProvider.tsx b/components/AuthProvider.tsx new file mode 100644 index 0000000..032798f --- /dev/null +++ b/components/AuthProvider.tsx @@ -0,0 +1,48 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { createContext, useContext, useEffect, useState } from 'react'; + +type AuthContextType = { + isAuthenticated: boolean; + isLoading: boolean; + login: (token: string) => Promise; + logout: () => Promise; +}; + +const AuthContext = createContext(undefined); + +export function AuthProvider({ children }: { children: React.ReactNode }) { + const [isAuthenticated, setIsAuthenticated] = useState(false); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + const checkToken = async () => { + const token = await AsyncStorage.getItem('access_token'); + setIsAuthenticated(!!token); + setIsLoading(false); + }; + checkToken(); + }, []); + + const login = async (token: string) => { + await AsyncStorage.setItem('access_token', token); + setIsAuthenticated(true); + }; + + const logout = async () => { + await AsyncStorage.removeItem('access_token'); + await AsyncStorage.removeItem('refresh_token'); + setIsAuthenticated(false); + }; + + return ( + + {children} + + ); +} + +export const useAuth = () => { + const context = useContext(AuthContext); + if (!context) throw new Error('useAuth must be used within AuthProvider'); + return context; +}; diff --git a/components/QueryProvider.tsx b/components/QueryProvider.tsx new file mode 100644 index 0000000..d92aa10 --- /dev/null +++ b/components/QueryProvider.tsx @@ -0,0 +1,25 @@ +'use client'; + +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { ReactNode, useState } from 'react'; + +const QueryProvider = ({ children }: { children: ReactNode }) => { + const [queryClient] = useState( + () => + new QueryClient({ + defaultOptions: { + queries: { + refetchOnWindowFocus: false, + retry: 1, + staleTime: 1000 * 60 * 5, + refetchOnReconnect: false, + refetchOnMount: false, + refetchInterval: 1000 * 60 * 5, + }, + }, + }) + ); + return {children}; +}; + +export default QueryProvider; diff --git a/components/ThemeContext.tsx b/components/ThemeContext.tsx new file mode 100644 index 0000000..b877573 --- /dev/null +++ b/components/ThemeContext.tsx @@ -0,0 +1,59 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; +import React, { createContext, useContext, useEffect, useState } from 'react'; +import { Appearance } from 'react-native'; + +type ThemeType = 'light' | 'dark'; + +interface ThemeContextProps { + theme: ThemeType; + isDark: boolean; + toggleTheme: () => void; +} + +const ThemeContext = createContext(null); + +export function ThemeProvider({ children }: { children: React.ReactNode }) { + const systemTheme = Appearance.getColorScheme(); + + const [theme, setTheme] = useState(systemTheme === 'dark' ? 'dark' : 'light'); + + // 🔹 Load saved theme + useEffect(() => { + (async () => { + const savedTheme = await AsyncStorage.getItem('APP_THEME'); + if (savedTheme === 'dark' || savedTheme === 'light') { + setTheme(savedTheme); + } + })(); + }, []); + + // 🔹 Save theme + useEffect(() => { + AsyncStorage.setItem('APP_THEME', theme); + }, [theme]); + + const toggleTheme = () => { + setTheme((prev) => (prev === 'dark' ? 'light' : 'dark')); + }; + + return ( + + {children} + + ); +} + +// Custom hook +export function useTheme() { + const ctx = useContext(ThemeContext); + if (!ctx) { + throw new Error('useTheme must be used inside ThemeProvider'); + } + return ctx; +} diff --git a/components/ui/AuthHeader.tsx b/components/ui/AuthHeader.tsx new file mode 100644 index 0000000..208bd98 --- /dev/null +++ b/components/ui/AuthHeader.tsx @@ -0,0 +1,151 @@ +import { languages } from '@/constants/languages'; +import { getLang, saveLang } from '@/hooks/storage.native'; +import { useLanguage } from '@/i18n/useLanguage'; +import { useQueryClient } from '@tanstack/react-query'; +import { useRouter } from 'expo-router'; +import { ArrowLeft, Check, ChevronDown, Globe } from 'lucide-react-native'; +import React, { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { StyleSheet, Text, TouchableOpacity, View } from 'react-native'; + +export default function AuthHeader({ back = true }: { back?: boolean }) { + const router = useRouter(); + const { language, changeLanguage, getLanguageName } = useLanguage(); + const { i18n } = useTranslation(); + const [open, setOpen] = useState(false); + const queryClient = useQueryClient(); + + useEffect(() => { + const loadLanguage = async () => { + const lang = await getLang(); + if (lang === 'uz' || lang === 'ru' || lang === 'en') { + changeLanguage(lang); + } + }; + + loadLanguage(); + }, [language]); + + const selectLanguage = async (lang: string) => { + changeLanguage(lang as any); + queryClient.invalidateQueries(); + await i18n.changeLanguage(lang); + await saveLang(lang); + setOpen(false); + }; + + return ( + + {/* Back */} + {back && ( + router.back()} activeOpacity={0.7}> + + + )} + + {/* Language */} + + setOpen(!open)} activeOpacity={0.7}> + + {getLanguageName()} + + + + {open && ( + + {languages.map((l) => { + const active = language === l.code; + return ( + selectLanguage(l.code)} + > + + {l.name} + + {active && ( + + + + )} + + ); + })} + + )} + + + ); +} + +const styles = StyleSheet.create({ + header: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingHorizontal: 24, + paddingVertical: 12, + zIndex: 50, + }, + back: { + width: 44, + height: 44, + borderRadius: 12, + backgroundColor: 'rgba(255,255,255,0.1)', + alignItems: 'center', + justifyContent: 'center', + borderWidth: 1, + borderColor: 'rgba(255,255,255,0.15)', + }, + langBtn: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + backgroundColor: 'rgba(255,255,255,0.1)', + paddingHorizontal: 14, + paddingVertical: 10, + borderRadius: 12, + borderWidth: 1, + borderColor: 'rgba(255,255,255,0.15)', + }, + langText: { + color: '#94a3b8', + fontWeight: '600', + fontSize: 14, + }, + dropdown: { + position: 'absolute', + top: 52, + right: 0, + backgroundColor: '#fff', + borderRadius: 16, + padding: 8, + minWidth: 180, + elevation: 20, + }, + option: { + flexDirection: 'row', + justifyContent: 'space-between', + padding: 12, + borderRadius: 12, + }, + optionActive: { + backgroundColor: '#eff6ff', + }, + optionText: { + color: '#475569', + fontWeight: '600', + }, + optionTextActive: { + color: '#3b82f6', + }, + check: { + width: 20, + height: 20, + borderRadius: 10, + backgroundColor: '#3b82f6', + alignItems: 'center', + justifyContent: 'center', + }, +}); diff --git a/components/ui/CategorySelect.tsx b/components/ui/CategorySelect.tsx new file mode 100644 index 0000000..9e81944 --- /dev/null +++ b/components/ui/CategorySelect.tsx @@ -0,0 +1,255 @@ +import { useTheme } from '@/components/ThemeContext'; +import { products_api } from '@/screens/home/lib/api'; +import { useMutation, useQuery } from '@tanstack/react-query'; +import { AxiosError } from 'axios'; +import { ChevronLeft, ChevronRight } from 'lucide-react-native'; +import React, { Dispatch, SetStateAction, useState } from 'react'; +import { + ActivityIndicator, + FlatList, + StyleSheet, + Text, + TouchableOpacity, + View, +} from 'react-native'; + +interface Category { + id: number; + name: string; + code: string; + external_id: string | null; + level: number; + is_leaf: boolean; + icon_name: string | null; +} + +interface CategoryResponse { + data: { + data: Category[]; + }; +} + +interface Props { + selectedCategories: Category | null; + setSelectedCategories: Dispatch>; +} + +interface HistoryItem { + parentId: number | null; + categories: Category[]; +} + +export default function CategorySelect({ selectedCategories, setSelectedCategories }: Props) { + const { isDark } = useTheme(); + const [currentCategories, setCurrentCategories] = useState([]); + const [currentParentId, setCurrentParentId] = useState(null); + const [history, setHistory] = useState([]); + + // Root categories + const { isLoading: rootLoading, error: rootError } = useQuery({ + queryKey: ['categories'], + queryFn: async () => products_api.getCategorys(), + select(data) { + setCurrentCategories(data.data.data); + setCurrentParentId(null); + setHistory([]); + return data; + }, + }); + + // Child categories + const { mutate, isPending: mutatePending } = useMutation({ + mutationFn: (id: number) => products_api.getCategorys({ parent: id }), + onSuccess: (response: CategoryResponse, id) => { + const childCategories = response.data.data; + setHistory((prev) => [...prev, { parentId: currentParentId, categories: currentCategories }]); + setCurrentCategories(childCategories); + setCurrentParentId(id); + }, + onError: (err: AxiosError) => { + console.error('Child category loading error:', err); + }, + }); + + const toggleCategory = (category: Category) => { + if (category.is_leaf) { + setSelectedCategories(category); + } else { + mutate(category.id); + } + }; + + const goBack = () => { + if (history.length > 0) { + const previous = history[history.length - 1]; + setCurrentCategories(previous.categories); + setCurrentParentId(previous.parentId); + setHistory((prev) => prev.slice(0, -1)); + } + }; + + const isLoading = rootLoading || mutatePending; + const error = rootError; + + const renderCategory = ({ item: category }: { item: Category }) => { + const isSelected = selectedCategories?.id === category.id; + return ( + toggleCategory(category)} + > + + {category.name} + + {!category.is_leaf && ( + + )} + + ); + }; + + if (isLoading && currentCategories.length === 0) { + return ( + + + + ); + } + + if (error && currentCategories.length === 0) { + return ( + + Ma'lumot yuklashda xatolik yuz berdi + + ); + } + + if (currentCategories.length === 0) { + return ( + + Kategoriyalar topilmadi + + ); + } + + return ( + + + {history.length > 0 && ( + + + + )} + + + item.id.toString()} + scrollEnabled={false} + showsVerticalScrollIndicator={true} + ItemSeparatorComponent={() => } + /> + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + marginBottom: 20, + }, + darkBg: { + backgroundColor: '#0f172a', + }, + lightBg: { + backgroundColor: '#f8fafc', + }, + centerContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + header: { + flexDirection: 'row', + alignItems: 'center', + gap: 12, + marginBottom: 12, + }, + backButton: { + paddingHorizontal: 12, + paddingVertical: 6, + borderRadius: 8, + }, + darkBackButton: { + backgroundColor: '#1e293b', + }, + lightBackButton: { + backgroundColor: '#ffffff', + }, + chip: { + paddingHorizontal: 16, + paddingVertical: 12, + borderRadius: 20, + borderWidth: 1, + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + shadowColor: '#000', + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.05, + shadowRadius: 2, + elevation: 1, + }, + darkChip: { + backgroundColor: '#1e293b', + borderColor: '#334155', + }, + lightChip: { + backgroundColor: '#ffffff', + borderColor: '#e2e8f0', + }, + chipSelected: { + backgroundColor: '#3b82f6', + borderColor: '#3b82f6', + shadowColor: '#3b82f6', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.3, + shadowRadius: 4, + elevation: 3, + }, + chipText: { + fontSize: 14, + fontWeight: '500', + flex: 1, + }, + darkChipText: { + color: '#cbd5e1', + }, + lightChipText: { + color: '#64748b', + }, + chipTextSelected: { + color: '#ffffff', + fontWeight: '600', + }, + darkText: { + color: '#f1f5f9', + }, + lightText: { + color: '#0f172a', + }, +}); diff --git a/components/ui/Combobox.tsx b/components/ui/Combobox.tsx new file mode 100644 index 0000000..d2a5a7c --- /dev/null +++ b/components/ui/Combobox.tsx @@ -0,0 +1,164 @@ +import { Check, ChevronDown } from 'lucide-react-native'; +import React, { useState } from 'react'; +import { FlatList, Modal, StyleSheet, Text, TextInput, TouchableOpacity, View } from 'react-native'; + +interface ComboboxProps { + value: string; + onChange: (value: string, label: string) => void; + items: { label: string; value: string }[]; + placeholder?: string; + disabled?: boolean; +} + +export default function Combobox({ value, onChange, items, placeholder, disabled }: ComboboxProps) { + const [isOpen, setIsOpen] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); + + const selectedItem = items.find((item) => item.value === value); + const displayText = selectedItem ? selectedItem.label : placeholder || 'Select...'; + + const filteredItems = items.filter((item) => + item.label.toLowerCase().includes(searchQuery.toLowerCase()) + ); + + const handleSelect = (item: { label: string; value: string }) => { + onChange(item.value, item.label); + setIsOpen(false); + setSearchQuery(''); + }; + + return ( + <> + !disabled && setIsOpen(true)} + disabled={disabled} + > + + {displayText} + + + + + setIsOpen(false)} + > + + + + Select Option + setIsOpen(false)}> + Done + + + + + + item.value} + renderItem={({ item }) => ( + handleSelect(item)}> + {item.label} + {value === item.value && } + + )} + style={styles.optionsList} + /> + + + + + ); +} + +const styles = StyleSheet.create({ + trigger: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + backgroundColor: '#fff', + borderWidth: 1, + borderColor: '#d1d5db', + borderRadius: 8, + paddingHorizontal: 12, + paddingVertical: 12, + }, + triggerDisabled: { + backgroundColor: '#f9fafb', + borderColor: '#e5e7eb', + }, + triggerText: { + fontSize: 14, + color: '#374151', + flex: 1, + }, + triggerTextDisabled: { + color: '#9ca3af', + }, + modalOverlay: { + flex: 1, + backgroundColor: 'rgba(0, 0, 0, 0.5)', + justifyContent: 'flex-end', + }, + modalContent: { + backgroundColor: '#fff', + borderTopLeftRadius: 20, + borderTopRightRadius: 20, + maxHeight: '80%', + paddingBottom: 20, + }, + modalHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + padding: 16, + borderBottomWidth: 1, + borderBottomColor: '#e5e7eb', + }, + modalTitle: { + fontSize: 18, + fontWeight: '600', + color: '#111827', + }, + closeButton: { + fontSize: 16, + color: '#2563eb', + fontWeight: '600', + }, + searchInput: { + margin: 16, + padding: 12, + backgroundColor: '#f9fafb', + borderRadius: 8, + fontSize: 14, + borderWidth: 1, + borderColor: '#e5e7eb', + }, + optionsList: { + flex: 1, + }, + option: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 16, + paddingVertical: 14, + borderBottomWidth: 1, + borderBottomColor: '#f3f4f6', + }, + optionText: { + fontSize: 15, + color: '#374151', + flex: 1, + }, +}); diff --git a/components/ui/CompanyList.tsx b/components/ui/CompanyList.tsx new file mode 100644 index 0000000..b387f66 --- /dev/null +++ b/components/ui/CompanyList.tsx @@ -0,0 +1,315 @@ +import { BASE_URL } from '@/api/URLs'; +import { useTheme } from '@/components/ThemeContext'; +import { products_api } from '@/screens/home/lib/api'; +import { CompanyResponse } from '@/screens/home/lib/types'; +import { BottomSheetBackdrop, BottomSheetModal, BottomSheetScrollView } from '@gorhom/bottom-sheet'; +import { useInfiniteQuery } from '@tanstack/react-query'; +import { Building2, ChevronRight, MapPin, Package } from 'lucide-react-native'; +import React, { useCallback, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + ActivityIndicator, + Dimensions, + FlatList, + Image, + StyleSheet, + Text, + TouchableOpacity, + View, +} from 'react-native'; + +const { width: SCREEN_WIDTH } = Dimensions.get('window'); + +export default function CompanyList({ query }: { query: string }) { + const { isDark } = useTheme(); + const { t } = useTranslation(); + const bottomSheetModalRef = useRef(null); + const [selectedCompany, setSelectedCompany] = useState(null); + + const PAGE_SIZE = 10; + + const { data, isLoading, isError, fetchNextPage, hasNextPage, isFetchingNextPage } = + useInfiniteQuery({ + queryKey: ['company-list', query], + queryFn: async ({ pageParam = 1 }) => { + const response = await products_api.getCompany({ + page: pageParam, + page_size: PAGE_SIZE, + search: query, + }); + return response.data.data; + }, + getNextPageParam: (lastPage) => + lastPage.current_page < lastPage.total_pages ? lastPage.current_page + 1 : undefined, + initialPageParam: 1, + }); + + const allCompanies = data?.pages.flatMap((page) => page.results) ?? []; + + const handlePresentModal = useCallback((company: CompanyResponse) => { + setSelectedCompany(company); + bottomSheetModalRef.current?.present(); + }, []); + + const renderBackdrop = useCallback( + (props: any) => ( + + ), + [] + ); + + if (isLoading) { + return ( + + + + ); + } + + if (isError) { + return ( + + {t('Xatolik yuz berdi')} + + ); + } + + return ( + <> + item.id.toString()} + renderItem={({ item }) => ( + handlePresentModal(item)} + > + + + + {item.company_name} + + + + + + + {item.country_name}, {item.region_name} + + + + )} + onEndReached={() => hasNextPage && !isFetchingNextPage && fetchNextPage()} + onEndReachedThreshold={0.4} + ListFooterComponent={ + isFetchingNextPage ? : null + } + showsVerticalScrollIndicator={false} + /> + + + + {selectedCompany && ( + <> + + + + {selectedCompany.company_name} + + + + + {selectedCompany.country_name}, {selectedCompany.region_name},{' '} + {selectedCompany.district_name} + + + + + + + {selectedCompany.product_service_company && + selectedCompany.product_service_company.length > 0 && ( + <> + + {t('Mahsulotlar')} ({selectedCompany.product_service_company.length}) + + {selectedCompany.product_service_company.map((product) => ( + + + {product.files?.[0]?.file ? ( + + ) : ( + + )} + + + + {product.title} + + + {product.description || 'Tavsif mavjud emas.'} + + + + ))} + + )} + + )} + + + + ); +} + +const CARD_WIDTH = SCREEN_WIDTH - 32; + +const styles = StyleSheet.create({ + center: { flex: 1, justifyContent: 'center', alignItems: 'center' }, + + // --- Company Card --- + card: { + width: CARD_WIDTH - 4, + marginLeft: 2, + borderRadius: 16, + padding: 16, + marginBottom: 8, + shadowColor: '#000', + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.2, + shadowRadius: 8, + elevation: 2, + }, + darkCard: { + backgroundColor: '#1e293b', + }, + lightCard: { + backgroundColor: '#ffffff', + }, + cardHeader: { flexDirection: 'row', alignItems: 'center', gap: 10, marginBottom: 6 }, + cardTitle: { fontSize: 17, fontWeight: '700', flex: 1 }, + darkText: { + color: '#f1f5f9', + }, + lightText: { + color: '#0f172a', + }, + cardLocation: { flexDirection: 'row', alignItems: 'center', gap: 6 }, + cardLocationText: { fontSize: 13 }, + darkSubText: { + color: '#64748b', + }, + lightSubText: { + color: '#94a3b8', + }, + + // --- Bottom Sheet --- + sheetContent: { padding: 20, paddingBottom: 40 }, + sheetHeader: { alignItems: 'center', marginBottom: 20 }, + sheetTitle: { + fontSize: 22, + fontWeight: '800', + marginTop: 8, + textAlign: 'center', + }, + sheetLocation: { flexDirection: 'row', alignItems: 'center', gap: 6, marginTop: 6 }, + sheetLocationText: { fontSize: 14 }, + divider: { height: 1, marginBottom: 16 }, + darkDivider: { + backgroundColor: '#334155', + }, + lightDivider: { + backgroundColor: '#e2e8f0', + }, + sectionLabel: { + fontSize: 14, + fontWeight: '700', + textTransform: 'uppercase', + marginBottom: 12, + }, + + // --- Product Card --- + productCard: { + flexDirection: 'row', + borderRadius: 12, + padding: 16, + marginBottom: 12, + gap: 16, + alignItems: 'flex-start', + }, + darkProductCard: { + backgroundColor: '#1e293b', + }, + lightProductCard: { + backgroundColor: '#f8fafc', + }, + productImage: { + width: 60, + height: 60, + borderRadius: 8, + alignItems: 'center', + justifyContent: 'center', + overflow: 'hidden', + borderWidth: 1, + }, + darkProductImage: { + backgroundColor: '#0f172a', + borderColor: '#334155', + }, + lightProductImage: { + backgroundColor: '#ffffff', + borderColor: '#e2e8f0', + }, + productImg: { width: '100%', height: '100%', borderRadius: 8 }, + productInfo: { flex: 1 }, + productTitle: { fontSize: 15, fontWeight: '700' }, + productDesc: { fontSize: 13, marginTop: 2 }, +}); diff --git a/components/ui/CountriesList.tsx b/components/ui/CountriesList.tsx new file mode 100644 index 0000000..f0b3de7 --- /dev/null +++ b/components/ui/CountriesList.tsx @@ -0,0 +1,221 @@ +import { useTheme } from '@/components/ThemeContext'; +import { products_api } from '@/screens/home/lib/api'; +import { useInfiniteQuery } from '@tanstack/react-query'; +import { Building2, ChevronDown, ChevronUp } from 'lucide-react-native'; +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + ActivityIndicator, + Dimensions, + FlatList, + StyleSheet, + Text, + TouchableOpacity, + View, +} from 'react-native'; + +const { width: SCREEN_WIDTH } = Dimensions.get('window'); +const PAGE_SIZE = 10; + +export default function CountriesList({ search }: { search: string }) { + const { isDark } = useTheme(); + const { t } = useTranslation(); + const [openedCountryId, setOpenedCountryId] = useState(null); + + const { data, isLoading, isError, fetchNextPage, hasNextPage, isFetchingNextPage } = + useInfiniteQuery({ + queryKey: ['countries-list-infinite', search], + queryFn: async ({ pageParam = 1 }) => { + const response = await products_api.getCountry({ + page: pageParam, + page_size: PAGE_SIZE, + search: search, + }); + return response.data.data; + }, + getNextPageParam: (lastPage) => + lastPage.current_page < lastPage.total_pages ? lastPage.current_page + 1 : undefined, + initialPageParam: 1, + }); + + const allCountries = data?.pages.flatMap((page) => page.results) ?? []; + + const toggleAccordion = (id: number) => setOpenedCountryId((prev) => (prev === id ? null : id)); + + const loadMore = () => hasNextPage && !isFetchingNextPage && fetchNextPage(); + + if (isLoading) { + return ( + + + + ); + } + + if (isError) { + return ( + + {t('Xatolik yuz berdi')} + + ); + } + + if (allCountries.length === 0) { + return ( + + + {t('Natija topilmadi')} + + + ); + } + + return ( + item.id.toString()} + contentContainerStyle={{ gap: 12 }} + onEndReached={loadMore} + onEndReachedThreshold={0.4} + ListFooterComponent={ + isFetchingNextPage ? : null + } + showsVerticalScrollIndicator={false} + renderItem={({ item }) => { + const isOpen = openedCountryId === item.id; + + return ( + + {/* Davlat sarlavhasi */} + toggleAccordion(item.id)} + activeOpacity={0.8} + > + + {item.name} + + + + {item.companies.length} {t('ta korxona')} + + {isOpen ? ( + + ) : ( + + )} + + + + {/* Ochiladigan qism */} + {isOpen && ( + + {item.companies.map((company) => ( + + + + + {company.company_name} + + + {company.service_count} {t('ta mahsulot/xizmat')} + + + + ))} + + )} + + ); + }} + /> + ); +} + +const CARD_WIDTH = SCREEN_WIDTH - 32; + +const styles = StyleSheet.create({ + center: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: 40 }, + loadingText: { fontSize: 16, marginTop: 10 }, + errorText: { fontSize: 16, color: '#ef4444', textAlign: 'center' }, + emptyContainer: { padding: 40, alignItems: 'center' }, + emptyText: { fontSize: 16 }, + + countryCard: { + width: CARD_WIDTH - 4, + borderRadius: 16, + overflow: 'hidden', + shadowColor: '#000', + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.2, + shadowRadius: 6, + elevation: 2, + marginLeft: 2, + }, + darkCard: { + backgroundColor: '#1e293b', + }, + lightCard: { + backgroundColor: '#ffffff', + }, + countryHeader: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + padding: 16, + }, + countryName: { fontSize: 17, fontWeight: '700' }, + darkText: { + color: '#f1f5f9', + }, + lightText: { + color: '#0f172a', + }, + rightSide: { flexDirection: 'row', alignItems: 'center', gap: 8 }, + companyCount: { fontSize: 14 }, + darkSubText: { + color: '#64748b', + }, + lightSubText: { + color: '#94a3b8', + }, + + companiesContainer: { paddingHorizontal: 16, paddingBottom: 16, gap: 10 }, + companyItem: { + flexDirection: 'row', + alignItems: 'center', + gap: 12, + paddingVertical: 10, + paddingHorizontal: 12, + borderRadius: 12, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.15, + shadowRadius: 4, + elevation: 3, + }, + darkCompanyItem: { + backgroundColor: '#0f172a', + }, + lightCompanyItem: { + backgroundColor: '#f8fafc', + }, + companyInfo: { flex: 1 }, + companyName: { fontSize: 15, fontWeight: '600' }, + serviceCount: { fontSize: 13, marginTop: 2 }, +}); diff --git a/components/ui/FilterContext.tsx b/components/ui/FilterContext.tsx new file mode 100644 index 0000000..33702da --- /dev/null +++ b/components/ui/FilterContext.tsx @@ -0,0 +1,91 @@ +import { FilterData } from '@/types'; +import { cities, companies, countries, industries, states } from '@/types/data'; +import createContextHook from '@nkzw/create-context-hook'; +import { useEffect, useMemo, useState } from 'react'; + +export const [FilterProvider, useFilter] = createContextHook(() => { + const [filterData, setFilterDataState] = useState({ + country: { name: '', iso: 'all' }, + state: { name: '', iso: 'all' }, + city: { name: '', iso: 'all' }, + industries: [], + }); + + const [step, setStep] = useState<'filter' | 'items'>('filter'); + const [currentPage, setCurrentPage] = useState(1); + const itemsPerPage = 10; + + const setFilterData = (updates: Partial) => { + setFilterDataState((prev) => ({ ...prev, ...updates })); + }; + + const availableStates = useMemo(() => { + if (filterData.country.iso === 'all' || !filterData.country.iso) { + return states; + } + return states.filter((s) => s.country_iso === filterData.country.iso); + }, [filterData.country.iso]); + + const availableCities = useMemo(() => { + if (filterData.state.iso === 'all' || !filterData.state.iso) { + return cities; + } + return cities.filter((c) => c.state_iso === filterData.state.iso); + }, [filterData.state.iso]); + + const filteredCompanies = useMemo(() => { + return companies.filter((company) => { + const countryMatch = + !filterData.country.iso || + filterData.country.iso === 'all' || + company.country === filterData.country.iso; + const industryMatch = + filterData.industries.length === 0 || + (company.industry && filterData.industries.includes(company.industry)); + return countryMatch && industryMatch; + }); + }, [filterData]); + + const totalItems = filteredCompanies.length; + const totalPages = Math.ceil(totalItems / itemsPerPage); + + const paginatedData = useMemo(() => { + const startIndex = (currentPage - 1) * itemsPerPage; + const endIndex = startIndex + itemsPerPage; + return filteredCompanies.slice(startIndex, endIndex); + }, [filteredCompanies, currentPage]); + + useEffect(() => { + if (filterData.country.iso !== 'all') { + const hasState = availableStates.some((s) => s.iso === filterData.state.iso); + if (!hasState) { + setFilterData({ state: { name: '', iso: 'all' }, city: { name: '', iso: 'all' } }); + } + } + }, [filterData.country.iso, availableStates]); + + useEffect(() => { + if (filterData.state.iso !== 'all') { + const hasCity = availableCities.some((c) => c.iso === filterData.city.iso); + if (!hasCity) { + setFilterData({ city: { name: '', iso: 'all' } }); + } + } + }, [filterData.state.iso, availableCities]); + + return { + filterData, + setFilterData, + countries, + states: availableStates, + cities: availableCities, + industries, + totalItems, + step, + setStep, + allData: paginatedData, + currentPage, + setCurrentPage, + totalPages, + }; +}); diff --git a/components/ui/FilterUI.tsx b/components/ui/FilterUI.tsx new file mode 100644 index 0000000..56baf19 --- /dev/null +++ b/components/ui/FilterUI.tsx @@ -0,0 +1,350 @@ +import { useTheme } from '@/components/ThemeContext'; +import { products_api } from '@/screens/home/lib/api'; +import { useMutation, useQuery } from '@tanstack/react-query'; +import { AxiosError } from 'axios'; +import { XIcon } from 'lucide-react-native'; +import React, { Dispatch, SetStateAction, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + ActivityIndicator, + ScrollView, + StyleSheet, + Text, + TouchableOpacity, + View, +} from 'react-native'; +import CategorySelect from './CategorySelect'; + +interface FilterUIProps { + back: () => void; + onApply?: (data: any) => void; + setStep: Dispatch>; + setFiltered: Dispatch>; +} + +interface Category { + id: number; + name: string; + code: string; + external_id: string | null; + level: number; + is_leaf: boolean; + icon_name: string | null; +} + +export default function FilterUI({ back, onApply, setStep, setFiltered }: FilterUIProps) { + const { isDark } = useTheme(); + const { t } = useTranslation(); + const [selectedCategories, setSelectedCategories] = useState(null); + const [selectedCountry, setSelectedCountry] = useState('all'); + const [selectedRegion, setSelectedRegion] = useState('all'); + const [selectedDistrict, setSelectedDistrict] = useState('all'); + + const { data: countryResponse, isLoading } = useQuery({ + queryKey: ['country-detail'], + queryFn: async () => products_api.getStates(), + select: (res) => res.data?.data || [], + }); + + const { mutate } = useMutation({ + mutationFn: (params: any) => products_api.businessAbout(params), + onSuccess: (data) => { + setStep('items'); + setFiltered(data.data.data.results); + }, + onError: (error: AxiosError) => console.log(error), + }); + + const handleApply = () => { + const countryObj = countryResponse?.find((c) => c.id?.toString() === selectedCountry); + const regionObj = countryObj?.region.find((r) => r.id?.toString() === selectedRegion); + const districtObj = regionObj?.districts.find((d) => d.id?.toString() === selectedDistrict); + + mutate({ + country: countryObj?.name || '', + region: regionObj?.name || '', + district: districtObj?.name || '', + types: selectedCategories ? selectedCategories.id : undefined, + }); + }; + + const regions = useMemo(() => { + if (selectedCountry === 'all') return []; + const country = countryResponse?.find((c) => c.id?.toString() === selectedCountry); + return country?.region || []; + }, [countryResponse, selectedCountry]); + + const districts = useMemo(() => { + if (selectedRegion === 'all') return []; + const region = regions.find((r) => r.id?.toString() === selectedRegion); + return region?.districts || []; + }, [regions, selectedRegion]); + + if (isLoading) { + return ( + + + + ); + } + + // Single Tag Component + const Tag = ({ + label, + selected, + onPress, + }: { + label: string; + selected: boolean; + onPress: () => void; + }) => ( + + + {label} + + + ); + + return ( + + {/* Header */} + + + + + + + {/* Scrollable Content */} + + {/* Country Filter */} + + {t('Davlat')} + + + setSelectedCountry('all')} + /> + {countryResponse?.map((c) => ( + { + setSelectedCountry(c.id?.toString() || 'all'); + setSelectedRegion('all'); + setSelectedDistrict('all'); + }} + /> + ))} + + + {/* Region Filter */} + {regions.length > 0 && ( + <> + + {t('Viloyat')} + + + setSelectedRegion('all')} + /> + {regions.map((r) => ( + { + setSelectedRegion(r.id?.toString() || 'all'); + setSelectedDistrict('all'); + }} + /> + ))} + + + )} + + {/* District Filter */} + {districts.length > 0 && ( + <> + + {t('Tuman')} + + + setSelectedDistrict('all')} + /> + {districts.map((d) => ( + setSelectedDistrict(d.id?.toString() || 'all')} + /> + ))} + + + )} + + {/* Industry Selection */} + + {t('Sohalar')} + + + + + {/* Fixed Apply Button */} + + + {t("Natijalarni ko'rish")} + + + + ); +} + +const styles = StyleSheet.create({ + container: { flex: 1 }, + darkBg: { + backgroundColor: '#0f172a', + }, + lightBg: { + backgroundColor: '#f8fafc', + }, + backBtn: { + paddingHorizontal: 10, + paddingVertical: 10, + borderRadius: 10, + marginTop: 10, + borderWidth: 1, + shadowColor: '#000', + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.05, + shadowRadius: 2, + elevation: 1, + }, + darkBackBtn: { + backgroundColor: '#1e293b', + borderColor: '#334155', + }, + lightBackBtn: { + backgroundColor: '#ffffff', + borderColor: '#e2e8f0', + }, + btn: { + justifyContent: 'flex-end', + alignItems: 'flex-end', + paddingHorizontal: 16, + }, + sectionTitle: { + fontSize: 16, + fontWeight: '700', + marginBottom: 10, + }, + darkText: { + color: '#f1f5f9', + }, + lightText: { + color: '#0f172a', + }, + scrollRow: { flexDirection: 'row', marginBottom: 12, gap: 10 }, + + tag: { + paddingHorizontal: 16, + paddingVertical: 10, + borderRadius: 12, + marginRight: 10, + borderWidth: 1, + shadowColor: '#000', + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.05, + shadowRadius: 2, + elevation: 1, + }, + darkTag: { + backgroundColor: '#1e293b', + borderColor: '#334155', + }, + lightTag: { + backgroundColor: '#ffffff', + borderColor: '#e2e8f0', + }, + tagSelected: { + backgroundColor: '#3b82f6', + borderColor: '#3b82f6', + shadowColor: '#3b82f6', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.3, + shadowRadius: 4, + elevation: 5, + }, + tagText: { + fontWeight: '500', + }, + darkTagText: { + color: '#cbd5e1', + }, + lightTagText: { + color: '#64748b', + }, + tagTextSelected: { + color: '#ffffff', + fontWeight: '600', + }, + + applyBtnWrapper: { + position: 'absolute', + bottom: 55, + left: 16, + right: 16, + zIndex: 10, + }, + applyBtn: { + backgroundColor: '#3b82f6', + padding: 16, + borderRadius: 12, + alignItems: 'center', + shadowColor: '#3b82f6', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.3, + shadowRadius: 4, + elevation: 5, + marginBottom: 20, + }, + applyBtnText: { color: '#ffffff', fontWeight: '700', fontSize: 16 }, +}); diff --git a/components/ui/FilteredItems.tsx b/components/ui/FilteredItems.tsx new file mode 100644 index 0000000..c35311c --- /dev/null +++ b/components/ui/FilteredItems.tsx @@ -0,0 +1,369 @@ +import { useTheme } from '@/components/ThemeContext'; +import { products_api } from '@/screens/home/lib/api'; +import { businessAboutDetailResData } from '@/screens/home/lib/types'; +import { useMutation } from '@tanstack/react-query'; +import { ChevronLeft, FileText, Phone, User } from 'lucide-react-native'; +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + ActivityIndicator, + FlatList, + Image, + Linking, + ScrollView, + StyleSheet, + Text, + TouchableOpacity, + View, +} from 'react-native'; + +interface FilteredItemsProps { + data: { id: number; company_name: string }[]; + back: () => void; +} + +export default function FilteredItems({ data, back }: FilteredItemsProps) { + const { isDark } = useTheme(); + const { t } = useTranslation(); + const [selectedItem, setSelectedItem] = useState(null); + + const { mutate, isPending } = useMutation({ + mutationFn: (id: number) => products_api.businessAboutDetail(id), + onSuccess: (data) => setSelectedItem(data.data.data), + }); + + if (isPending) { + return ( + + + + ); + } + + if (selectedItem) { + return ( + + + {/* Back Button */} + setSelectedItem(null)} + style={[styles.backButton, isDark ? styles.darkBackButton : styles.lightBackButton]} + > + + + + {/* Company Name */} + + {selectedItem.company_name} + + + {/* Company Image */} + {selectedItem.company_image && ( + + )} + + {/* Info Card */} + + {selectedItem.director_full_name && ( + + + + + {t('Rahbar')} + + + {selectedItem.director_full_name} + + + + )} + + {selectedItem.phone && ( + Linking.openURL(`tel:+${selectedItem.phone}`)} + > + + + + {t('Telefon raqami')} + + + +{selectedItem.phone} + + + + )} + + {selectedItem.product_service_company?.length > 0 && ( + + + + + {t('Mahsulot va xizmatlar')} + + + + {selectedItem.product_service_company.map((item, index) => ( + + + {item.title} + + {item.description && ( + + {item.description} + + )} + + {item.category?.length > 0 && ( + + {item.category.map((cat) => ( + + + {cat.name} + + + ))} + + )} + + {item.files?.length > 0 && ( + + + {t('Fayllar')}: + + {item.files.map((file, idx) => ( + Linking.openURL(file.file)} + style={styles.fileLink} + > + + {t('Fayl')} {idx + 1} {t('ochish')} + + + ))} + + )} + + ))} + + )} + + + + ); + } + + // List view + return ( + + + + + + + {data && data.length > 0 ? ( + item.id.toString()} + renderItem={({ item }) => ( + mutate(item.id)} + > + + {item.company_name} + + + )} + contentContainerStyle={styles.listContainer} + /> + ) : ( + + + {t('Natija topilmadi')} + + + )} + + + ); +} + +const styles = StyleSheet.create({ + container: { flex: 1 }, + darkBg: { + backgroundColor: '#0f172a', + }, + lightBg: { + backgroundColor: '#f8fafc', + }, + content: { + padding: 16, + maxWidth: 500, + width: '100%', + alignSelf: 'center', + }, + center: { flex: 1, justifyContent: 'center', alignItems: 'center' }, + backButton: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 16, + padding: 10, + borderRadius: 12, + alignSelf: 'flex-start', + gap: 8, + }, + darkBackButton: { + backgroundColor: '#1e293b', + }, + lightBackButton: { + backgroundColor: '#ffffff', + }, + backText: { fontSize: 16, color: '#3b82f6', fontWeight: '600' }, + listContainer: { gap: 12 }, + itemCard: { + borderRadius: 12, + padding: 16, + borderWidth: 1, + shadowColor: '#000', + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.05, + shadowRadius: 2, + elevation: 2, + }, + darkItemCard: { + backgroundColor: '#1e293b', + borderColor: '#334155', + }, + lightItemCard: { + backgroundColor: '#ffffff', + borderColor: '#e2e8f0', + }, + itemTitle: { fontSize: 17, fontWeight: '600' }, + darkText: { + color: '#f1f5f9', + }, + lightText: { + color: '#0f172a', + }, + emptyContainer: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 40 }, + emptyText: { fontSize: 16, textAlign: 'center' }, + darkSubText: { + color: '#64748b', + }, + lightSubText: { + color: '#94a3b8', + }, + + // Detail + detailTitle: { fontSize: 26, fontWeight: '700', marginBottom: 16 }, + companyImage: { + width: '100%', + height: 220, + borderRadius: 16, + marginBottom: 20, + }, + darkImageBg: { + backgroundColor: '#1e293b', + }, + lightImageBg: { + backgroundColor: '#f1f5f9', + }, + infoCard: { + borderRadius: 16, + padding: 20, + borderWidth: 1, + gap: 16, + }, + darkCard: { + backgroundColor: '#1e293b', + borderColor: '#334155', + }, + lightCard: { + backgroundColor: '#ffffff', + borderColor: '#e2e8f0', + }, + infoRow: { flexDirection: 'row', alignItems: 'center', gap: 12, paddingVertical: 8 }, + infoTextContainer: { flex: 1 }, + infoLabel: { fontSize: 14, marginBottom: 4 }, + infoValue: { fontSize: 16, fontWeight: '500' }, + + section: { marginTop: 8 }, + sectionHeader: { flexDirection: 'row', alignItems: 'center', gap: 10, marginBottom: 12 }, + sectionTitle: { fontSize: 18, fontWeight: '700' }, + serviceItem: { + borderRadius: 12, + padding: 16, + marginBottom: 12, + }, + darkServiceItem: { + backgroundColor: '#0f172a', + }, + lightServiceItem: { + backgroundColor: '#f8fafc', + }, + serviceTitle: { fontSize: 17, fontWeight: '600', marginBottom: 8 }, + serviceDescription: { fontSize: 15, lineHeight: 22, marginBottom: 12 }, + + tagsContainer: { flexDirection: 'row', flexWrap: 'wrap', gap: 8, marginTop: 8 }, + tag: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + paddingHorizontal: 12, + paddingVertical: 6, + borderRadius: 20, + }, + darkTag: { + backgroundColor: '#1e293b', + }, + lightTag: { + backgroundColor: '#ffffff', + }, + tagText: { fontSize: 13, fontWeight: '500' }, + + filesContainer: { marginTop: 12 }, + filesTitle: { fontSize: 14, fontWeight: '600', marginBottom: 8 }, + fileLink: { paddingVertical: 6 }, + fileLinkText: { color: '#3b82f6', fontSize: 15, textDecorationLine: 'underline' }, +}); diff --git a/components/ui/Header.tsx b/components/ui/Header.tsx new file mode 100644 index 0000000..666825d --- /dev/null +++ b/components/ui/Header.tsx @@ -0,0 +1,92 @@ +import Logo from '@/assets/images/logo.png'; +import { useTheme } from '@/components/ThemeContext'; +import { LogOut } from 'lucide-react-native'; +import { useTranslation } from 'react-i18next'; +import { Image, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { useAuth } from '../AuthProvider'; + +export const CustomHeader = ({ logoutbtn = false }: { logoutbtn?: boolean }) => { + const { isDark } = useTheme(); + const { t } = useTranslation(); + const { logout } = useAuth(); + + return ( + + + + + + + {t('app.name', 'InfoTarget')} + + + {logoutbtn && ( + + + + )} + + ); +}; + +const styles = StyleSheet.create({ + container: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 20, + paddingVertical: 16, + borderBottomWidth: 1, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.05, + shadowRadius: 4, + elevation: 3, + }, + + darkBg: { + backgroundColor: '#0f172a', + borderBottomColor: '#1e293b', + }, + + lightBg: { + backgroundColor: '#ffffff', + borderBottomColor: '#e2e8f0', + }, + + logoWrapper: { + flexDirection: 'row', + alignItems: 'center', + gap: 12, + }, + + logoContainer: { + width: 40, + height: 40, + borderRadius: 10, + backgroundColor: 'rgba(59, 130, 246, 0.1)', + alignItems: 'center', + justifyContent: 'center', + padding: 4, + }, + + logo: { + width: 32, + height: 32, + resizeMode: 'contain', + }, + + title: { + fontSize: 20, + fontWeight: '700', + letterSpacing: 0.3, + }, + + darkText: { + color: '#f8fafc', + }, + + lightText: { + color: '#0f172a', + }, +}); diff --git a/components/ui/IndustrySelection.tsx b/components/ui/IndustrySelection.tsx new file mode 100644 index 0000000..ce4cb99 --- /dev/null +++ b/components/ui/IndustrySelection.tsx @@ -0,0 +1,236 @@ +import { products_api } from '@/screens/home/lib/api'; +import { useMutation, useQuery } from '@tanstack/react-query'; +import { AxiosError } from 'axios'; +import { ChevronLeft, ChevronRight } from 'lucide-react-native'; +import React, { Dispatch, SetStateAction, useState } from 'react'; +import { + ActivityIndicator, + FlatList, + StyleSheet, + Text, + TouchableOpacity, + View, +} from 'react-native'; +import { useTheme } from '../ThemeContext'; + +interface Category { + id: number; + name: string; + code: string; + external_id: string | null; + level: number; + is_leaf: boolean; + icon_name: string | null; +} + +interface CategoryResponse { + data: { + data: Category[]; + }; +} + +interface Props { + selectedCategories: Category[]; + setSelectedCategories: Dispatch>; +} + +export default function CategorySelection({ selectedCategories, setSelectedCategories }: Props) { + const [currentCategories, setCurrentCategories] = useState([]); + const [history, setHistory] = useState<{ parentId: number | null; categories: Category[] }[]>([]); + const [currentParentId, setCurrentParentId] = useState(null); + const { isDark } = useTheme(); + + const theme = { + cardBg: isDark ? '#1e293b' : '#f8fafc', + cardBorder: isDark ? '#334155' : '#e2e8f0', + text: isDark ? '#cbd5e1' : '#334155', + textSelected: '#ffffff', + primary: '#2563eb', + primaryBg: '#3b82f6', + error: '#ef4444', + backButtonBg: isDark ? '#1e293b' : '#e2e8f0', + chevronColor: isDark ? '#94a3b8' : '#64748b', + shadow: isDark ? '#000' : '#94a3b8', + }; + + const { + data: rootData, + isLoading: rootLoading, + error: rootError, + } = useQuery({ + queryKey: ['categories'], + queryFn: async () => products_api.getCategorys(), + select(data) { + setCurrentCategories(data.data.data); + setCurrentParentId(null); + return data; + }, + }); + + const { mutate, isPending: mutatePending } = useMutation({ + mutationFn: (id: number) => products_api.getCategorys({ parent: id }), + onSuccess: (response: CategoryResponse, id) => { + const childCategories = response.data.data; + setHistory((prev) => [...prev, { parentId: currentParentId, categories: currentCategories }]); + setCurrentCategories(childCategories); + setCurrentParentId(id); + }, + onError: (err: AxiosError) => { + console.error('Child yuklashda xato:', err); + }, + }); + + const toggleCategory = (category: Category) => { + if (category.is_leaf) { + setSelectedCategories((prev) => { + const exists = prev.find((c) => c.id === category.id); + if (exists) return prev.filter((c) => c.id !== category.id); + return [...prev, category]; + }); + } else { + mutate(category.id); + } + }; + + const goBack = () => { + if (history.length > 0) { + const previous = history[history.length - 1]; + setCurrentCategories(previous.categories); + setCurrentParentId(previous.parentId); + setHistory((prev) => prev.slice(0, -1)); + } + }; + + const isLoading = rootLoading || mutatePending; + const error = rootError; + + const renderCategory = ({ item: category }: { item: Category }) => { + const isSelected = selectedCategories.some((c) => c.id === category.id); + return ( + toggleCategory(category)} + > + + {category.name} + + {!category.is_leaf && ( + + )} + + ); + }; + + if (isLoading && currentCategories.length === 0) { + return ( + + + + ); + } + + if (error && currentCategories.length === 0) { + return ( + + Ma'lumot yuklashda xatolik yuz berdi + + ); + } + + if (currentCategories.length === 0) { + return ( + + Kategoriyalar topilmadi + + ); + } + + return ( + + + {history.length > 0 && ( + + + + )} + + + item.id.toString()} + showsVerticalScrollIndicator={true} + ItemSeparatorComponent={() => } + /> + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + marginBottom: 20, + }, + centerContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + header: { + flexDirection: 'row', + alignItems: 'center', + gap: 12, + marginBottom: 12, + paddingHorizontal: 12, + }, + backButton: { + paddingHorizontal: 12, + paddingVertical: 6, + borderRadius: 8, + }, + chip: { + paddingHorizontal: 16, + paddingVertical: 12, + borderRadius: 20, + borderWidth: 1, + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.05, + shadowRadius: 2, + elevation: 1, + }, + chipSelected: { + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.3, + shadowRadius: 4, + elevation: 3, + }, + chipText: { + fontSize: 14, + fontWeight: '500', + flex: 1, + }, + chipTextSelected: { + fontWeight: '600', + }, +}); diff --git a/components/ui/ProductList.tsx b/components/ui/ProductList.tsx new file mode 100644 index 0000000..248af86 --- /dev/null +++ b/components/ui/ProductList.tsx @@ -0,0 +1,376 @@ +import { useTheme } from '@/components/ThemeContext'; +import { products_api } from '@/screens/home/lib/api'; +import { ProductResponse } from '@/screens/home/lib/types'; +import { BottomSheetBackdrop, BottomSheetModal, BottomSheetScrollView } from '@gorhom/bottom-sheet'; +import { useInfiniteQuery } from '@tanstack/react-query'; +import { ResizeMode, Video } from 'expo-av'; +import { Info, Package, PlayCircle } from 'lucide-react-native'; +import React, { useCallback, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + ActivityIndicator, + Dimensions, + Image, + StyleSheet, + Text, + TouchableOpacity, + View, +} from 'react-native'; +import { FlatList } from 'react-native-gesture-handler'; + +const { width: SCREEN_WIDTH } = Dimensions.get('window'); +const IMAGE_WIDTH = SCREEN_WIDTH - 40; +const PAGE_SIZE = 10; + +type Props = { query: string }; + +export default function ProductList({ query }: Props) { + const { t } = useTranslation(); + const { isDark } = useTheme(); + const trimmedQuery = query.trim(); + const [selectedProduct, setSelectedProduct] = useState(null); + const [currentImageIndex, setCurrentImageIndex] = useState(0); + const bottomSheetModalRef = useRef(null); + + const { data, isLoading, isError, fetchNextPage, hasNextPage, isFetchingNextPage } = + useInfiniteQuery({ + queryKey: ['products-list', trimmedQuery], + queryFn: async ({ pageParam = 1 }) => { + const response = await products_api.getProducts({ + page: pageParam, + page_size: PAGE_SIZE, + search: trimmedQuery, + }); + return response.data.data; + }, + getNextPageParam: (lastPage) => + lastPage.current_page < lastPage.total_pages ? lastPage.current_page + 1 : undefined, + initialPageParam: 1, + }); + + const allProducts = data?.pages.flatMap((p) => p.results) ?? []; + + const handlePresentModalPress = useCallback((product: ProductResponse) => { + setSelectedProduct(product); + setCurrentImageIndex(0); + bottomSheetModalRef.current?.present(); + }, []); + + const renderBackdrop = useCallback( + (props: any) => ( + + ), + [] + ); + + const isVideoFile = (url?: string) => url?.toLowerCase().match(/\.(mp4|mov|avi|mkv|webm)$/i); + + const renderItem = ({ item }: { item: ProductResponse }) => { + const mainFile = item.files?.[0]?.file; + const isVideo = isVideoFile(mainFile); + + return ( + handlePresentModalPress(item)} + > + + {mainFile ? ( + <> + + {isVideo && ( + + + + )} + + ) : ( + + + + )} + + + + {item.title} + + + + {item.company} + + + + + ); + }; + + const renderCarouselItem = ({ item }: { item: { id: number; file: string } }) => { + const isVideo = isVideoFile(item.file); + return ( + + {isVideo ? ( + + ); + }; + + if (isLoading && !data) + return ( + + + + ); + + if (isError) + return ( + + {t('Xatolik yuz berdi')} + + ); + + return ( + <> + item.id.toString()} + renderItem={renderItem} + numColumns={2} + columnWrapperStyle={{ justifyContent: 'space-between', marginBottom: 8 }} + contentContainerStyle={{}} + onEndReached={() => hasNextPage && !isFetchingNextPage && fetchNextPage()} + onEndReachedThreshold={0.4} + ListFooterComponent={ + isFetchingNextPage ? ( + + + + ) : null + } + ListEmptyComponent={ + data ? ( + + {t('Natija topilmadi')} + + ) : null + } + /> + + + + {selectedProduct && ( + <> + + item.id.toString()} + horizontal + pagingEnabled + showsHorizontalScrollIndicator={false} + onMomentumScrollEnd={(e) => { + const index = Math.round(e.nativeEvent.contentOffset.x / IMAGE_WIDTH); + setCurrentImageIndex(index); + }} + /> + {selectedProduct.files.length > 1 && ( + + {selectedProduct.files.map((_, i) => ( + + ))} + + )} + + + + + {selectedProduct.title} + + + {selectedProduct.company} + + + + + + + + + + {t("Batafsil ma'lumot")} + + + + {selectedProduct.description || "Ma'lumot mavjud emas."} + + + + )} + + + + ); +} + +const styles = StyleSheet.create({ + listContainer: { gap: 0, paddingBottom: 20 }, + card: { + borderRadius: 16, + overflow: 'hidden', + width: (SCREEN_WIDTH - 40) / 2, + marginLeft: 2, + shadowColor: '#000', + shadowOpacity: 0.15, + shadowOffset: { width: 0, height: 4 }, + shadowRadius: 50, + elevation: 4, + }, + darkCard: { + backgroundColor: '#1e293b', + }, + lightCard: { + backgroundColor: '#ffffff', + }, + imageContainer: { + width: '100%', + height: 160, + }, + darkImageBg: { + backgroundColor: '#0f172a', + }, + lightImageBg: { + backgroundColor: '#f1f5f9', + }, + image: { width: '100%', height: '100%' }, + videoIconOverlay: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: 'rgba(0,0,0,0.25)', + }, + placeholder: { flex: 1, alignItems: 'center', justifyContent: 'center' }, + info: { padding: 12 }, + title: { + fontSize: 16, + fontWeight: '700', + marginBottom: 4, + }, + darkText: { + color: '#f1f5f9', + }, + lightText: { + color: '#0f172a', + }, + companyRow: { flexDirection: 'row', alignItems: 'center', gap: 6 }, + companyText: { + fontSize: 13, + }, + darkSubText: { + color: '#64748b', + }, + lightSubText: { + color: '#94a3b8', + }, + + // Bottom Sheet + sheetContent: { flex: 1 }, + sheetContentContainer: { paddingHorizontal: 20, paddingBottom: 40 }, + carouselWrapper: { + width: IMAGE_WIDTH, + height: 280, + marginBottom: 20, + borderRadius: 16, + overflow: 'hidden', + alignSelf: 'center', + }, + carouselImageContainer: { width: IMAGE_WIDTH, height: 280, backgroundColor: '#e2e8f0' }, + carouselImage: { width: '100%', height: '100%' }, + pagination: { + position: 'absolute', + bottom: 12, + flexDirection: 'row', + width: '100%', + justifyContent: 'center', + alignItems: 'center', + gap: 8, + }, + paginationDot: { width: 8, height: 8, borderRadius: 4, backgroundColor: 'rgba(255,255,255,0.4)' }, + paginationDotActive: { backgroundColor: '#3b82f6', width: 20 }, + + sheetHeader: { marginBottom: 16 }, + sheetTitle: { + fontSize: 20, + fontWeight: '800', + marginBottom: 8, + }, + sheetCompanyBadge: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + backgroundColor: '#3b82f6', + alignSelf: 'flex-start', + paddingHorizontal: 10, + paddingVertical: 5, + borderRadius: 12, + }, + sheetCompanyText: { fontSize: 13, color: '#ffffff', fontWeight: '700' }, + divider: { + height: 1, + marginBottom: 20, + }, + darkDivider: { + backgroundColor: '#334155', + }, + lightDivider: { + backgroundColor: '#e2e8f0', + }, + section: { marginBottom: 20 }, + sectionTitleRow: { flexDirection: 'row', alignItems: 'center', gap: 8, marginBottom: 8 }, + sectionLabel: { + fontSize: 16, + fontWeight: '700', + }, + sheetDescription: { + fontSize: 15, + lineHeight: 22, + }, + + center: { flex: 1, justifyContent: 'center', alignItems: 'center' }, + footerLoader: { paddingVertical: 20, alignItems: 'center' }, +}); diff --git a/components/ui/RefreshContext.tsx b/components/ui/RefreshContext.tsx new file mode 100644 index 0000000..74f71a7 --- /dev/null +++ b/components/ui/RefreshContext.tsx @@ -0,0 +1,37 @@ +import { useQueryClient } from '@tanstack/react-query'; +import { createContext, useCallback, useContext, useState } from 'react'; + +type RefreshContextType = { + refreshing: boolean; + onRefresh: () => Promise; +}; + +const RefreshContext = createContext(null); + +export function RefreshProvider({ children }: { children: React.ReactNode }) { + const queryClient = useQueryClient(); + const [refreshing, setRefreshing] = useState(false); + + const onRefresh = useCallback(async () => { + if (refreshing) return; + + setRefreshing(true); + try { + await queryClient.refetchQueries(); + } catch (err) { + console.error('Global refresh error:', err); + } finally { + setRefreshing(false); + } + }, [queryClient, refreshing]); + + return ( + {children} + ); +} + +export function useGlobalRefresh() { + const ctx = useContext(RefreshContext); + if (!ctx) throw new Error('useGlobalRefresh must be used inside RefreshProvider'); + return ctx; +} diff --git a/components/ui/SearchTabs.tsx b/components/ui/SearchTabs.tsx new file mode 100644 index 0000000..11bd232 --- /dev/null +++ b/components/ui/SearchTabs.tsx @@ -0,0 +1,132 @@ +import { useTheme } from '@/components/ThemeContext'; +import { TabKey } from '@/types'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { FlatList } from 'react-native-gesture-handler'; + +interface SearchTabsProps { + value: TabKey; + onChange: (tab: TabKey) => void; +} + +export default function SearchTabs({ value, onChange }: SearchTabsProps) { + const { isDark } = useTheme(); + const { t } = useTranslation(); + + const tabs: { key: TabKey; label: string }[] = [ + { key: 'products', label: 'Tovar va xizmatlar' }, + { key: 'companies', label: 'Yuridik shaxslar' }, + { key: 'countries', label: 'Davlatlar' }, + ]; + + const renderTab = ({ item }: { item: { key: TabKey; label: string } }) => { + const isActive = value === item.key; + return ( + onChange(item.key)} + activeOpacity={0.8} + > + + {t(item.label)} + + + ); + }; + + return ( + + + item.key} + horizontal + showsHorizontalScrollIndicator={false} + contentContainerStyle={styles.container} + ItemSeparatorComponent={() => } + /> + + + ); +} + +const styles = StyleSheet.create({ + shadowWrapper: { + borderRadius: 12, + shadowColor: '#000', + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.05, + shadowRadius: 2, + elevation: 2, + }, + + wrapper: { + borderRadius: 12, + overflow: 'hidden', // ENDI ISHLAYDI + }, + + darkWrapper: { + backgroundColor: '#0f172a', + }, + lightWrapper: { + backgroundColor: '#ffffff', + }, + container: { + paddingHorizontal: 2, + paddingVertical: 2, + overflow: 'hidden', + }, + tab: { + paddingVertical: 10, + paddingHorizontal: 20, + borderRadius: 12, + overflow: 'hidden', + alignItems: 'center', + justifyContent: 'center', + shadowColor: '#000', + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.05, + shadowRadius: 2, + elevation: 1, + }, + darkTab: { + backgroundColor: '#1e293b', + }, + lightTab: { + backgroundColor: '#f8fafc', + }, + activeTab: { + backgroundColor: '#3b82f6', + shadowColor: '#3b82f6', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.3, + shadowRadius: 4, + elevation: 3, + }, + tabText: { + fontSize: 14, + fontWeight: '500', + }, + darkTabText: { + color: '#cbd5e1', + }, + lightTabText: { + color: '#64748b', + }, + activeTabText: { + color: '#ffffff', + fontWeight: '600', + }, +}); diff --git a/components/ui/gluestack-ui-provider/config.ts b/components/ui/gluestack-ui-provider/config.ts new file mode 100644 index 0000000..f388cc6 --- /dev/null +++ b/components/ui/gluestack-ui-provider/config.ts @@ -0,0 +1,309 @@ +'use client'; +import { vars } from 'nativewind'; + +export const config = { + light: vars({ + '--color-primary-0': '179 179 179', + '--color-primary-50': '153 153 153', + '--color-primary-100': '128 128 128', + '--color-primary-200': '115 115 115', + '--color-primary-300': '102 102 102', + '--color-primary-400': '82 82 82', + '--color-primary-500': '51 51 51', + '--color-primary-600': '41 41 41', + '--color-primary-700': '31 31 31', + '--color-primary-800': '13 13 13', + '--color-primary-900': '10 10 10', + '--color-primary-950': '8 8 8', + + /* Secondary */ + '--color-secondary-0': '253 253 253', + '--color-secondary-50': '251 251 251', + '--color-secondary-100': '246 246 246', + '--color-secondary-200': '242 242 242', + '--color-secondary-300': '237 237 237', + '--color-secondary-400': '230 230 231', + '--color-secondary-500': '217 217 219', + '--color-secondary-600': '198 199 199', + '--color-secondary-700': '189 189 189', + '--color-secondary-800': '177 177 177', + '--color-secondary-900': '165 164 164', + '--color-secondary-950': '157 157 157', + + /* Tertiary */ + '--color-tertiary-0': '255 250 245', + '--color-tertiary-50': '255 242 229', + '--color-tertiary-100': '255 233 213', + '--color-tertiary-200': '254 209 170', + '--color-tertiary-300': '253 180 116', + '--color-tertiary-400': '251 157 75', + '--color-tertiary-500': '231 129 40', + '--color-tertiary-600': '215 117 31', + '--color-tertiary-700': '180 98 26', + '--color-tertiary-800': '130 73 23', + '--color-tertiary-900': '108 61 19', + '--color-tertiary-950': '84 49 18', + + /* Error */ + '--color-error-0': '254 233 233', + '--color-error-50': '254 226 226', + '--color-error-100': '254 202 202', + '--color-error-200': '252 165 165', + '--color-error-300': '248 113 113', + '--color-error-400': '239 68 68', + '--color-error-500': '230 53 53', + '--color-error-600': '220 38 38', + '--color-error-700': '185 28 28', + '--color-error-800': '153 27 27', + '--color-error-900': '127 29 29', + '--color-error-950': '83 19 19', + + /* Success */ + '--color-success-0': '228 255 244', + '--color-success-50': '202 255 232', + '--color-success-100': '162 241 192', + '--color-success-200': '132 211 162', + '--color-success-300': '102 181 132', + '--color-success-400': '72 151 102', + '--color-success-500': '52 131 82', + '--color-success-600': '42 121 72', + '--color-success-700': '32 111 62', + '--color-success-800': '22 101 52', + '--color-success-900': '20 83 45', + '--color-success-950': '27 50 36', + + /* Warning */ + '--color-warning-0': '255 249 245', + '--color-warning-50': '255 244 236', + '--color-warning-100': '255 231 213', + '--color-warning-200': '254 205 170', + '--color-warning-300': '253 173 116', + '--color-warning-400': '251 149 75', + '--color-warning-500': '231 120 40', + '--color-warning-600': '215 108 31', + '--color-warning-700': '180 90 26', + '--color-warning-800': '130 68 23', + '--color-warning-900': '108 56 19', + '--color-warning-950': '84 45 18', + + /* Info */ + '--color-info-0': '236 248 254', + '--color-info-50': '199 235 252', + '--color-info-100': '162 221 250', + '--color-info-200': '124 207 248', + '--color-info-300': '87 194 246', + '--color-info-400': '50 180 244', + '--color-info-500': '13 166 242', + '--color-info-600': '11 141 205', + '--color-info-700': '9 115 168', + '--color-info-800': '7 90 131', + '--color-info-900': '5 64 93', + '--color-info-950': '3 38 56', + + /* Typography */ + '--color-typography-0': '254 254 255', + '--color-typography-50': '245 245 245', + '--color-typography-100': '229 229 229', + '--color-typography-200': '219 219 220', + '--color-typography-300': '212 212 212', + '--color-typography-400': '163 163 163', + '--color-typography-500': '140 140 140', + '--color-typography-600': '115 115 115', + '--color-typography-700': '82 82 82', + '--color-typography-800': '64 64 64', + '--color-typography-900': '38 38 39', + '--color-typography-950': '23 23 23', + + /* Outline */ + '--color-outline-0': '253 254 254', + '--color-outline-50': '243 243 243', + '--color-outline-100': '230 230 230', + '--color-outline-200': '221 220 219', + '--color-outline-300': '211 211 211', + '--color-outline-400': '165 163 163', + '--color-outline-500': '140 141 141', + '--color-outline-600': '115 116 116', + '--color-outline-700': '83 82 82', + '--color-outline-800': '65 65 65', + '--color-outline-900': '39 38 36', + '--color-outline-950': '26 23 23', + + /* Background */ + '--color-background-0': '255 255 255', + '--color-background-50': '246 246 246', + '--color-background-100': '242 241 241', + '--color-background-200': '220 219 219', + '--color-background-300': '213 212 212', + '--color-background-400': '162 163 163', + '--color-background-500': '142 142 142', + '--color-background-600': '116 116 116', + '--color-background-700': '83 82 82', + '--color-background-800': '65 64 64', + '--color-background-900': '39 38 37', + '--color-background-950': '18 18 18', + + /* Background Special */ + '--color-background-error': '254 241 241', + '--color-background-warning': '255 243 234', + '--color-background-success': '237 252 242', + '--color-background-muted': '247 248 247', + '--color-background-info': '235 248 254', + + /* Focus Ring Indicator */ + '--color-indicator-primary': '55 55 55', + '--color-indicator-info': '83 153 236', + '--color-indicator-error': '185 28 28', + }), + dark: vars({ + '--color-primary-0': '166 166 166', + '--color-primary-50': '175 175 175', + '--color-primary-100': '186 186 186', + '--color-primary-200': '197 197 197', + '--color-primary-300': '212 212 212', + '--color-primary-400': '221 221 221', + '--color-primary-500': '230 230 230', + '--color-primary-600': '240 240 240', + '--color-primary-700': '250 250 250', + '--color-primary-800': '253 253 253', + '--color-primary-900': '254 249 249', + '--color-primary-950': '253 252 252', + + /* Secondary */ + '--color-secondary-0': '20 20 20', + '--color-secondary-50': '23 23 23', + '--color-secondary-100': '31 31 31', + '--color-secondary-200': '39 39 39', + '--color-secondary-300': '44 44 44', + '--color-secondary-400': '56 57 57', + '--color-secondary-500': '63 64 64', + '--color-secondary-600': '86 86 86', + '--color-secondary-700': '110 110 110', + '--color-secondary-800': '135 135 135', + '--color-secondary-900': '150 150 150', + '--color-secondary-950': '164 164 164', + + /* Tertiary */ + '--color-tertiary-0': '84 49 18', + '--color-tertiary-50': '108 61 19', + '--color-tertiary-100': '130 73 23', + '--color-tertiary-200': '180 98 26', + '--color-tertiary-300': '215 117 31', + '--color-tertiary-400': '231 129 40', + '--color-tertiary-500': '251 157 75', + '--color-tertiary-600': '253 180 116', + '--color-tertiary-700': '254 209 170', + '--color-tertiary-800': '255 233 213', + '--color-tertiary-900': '255 242 229', + '--color-tertiary-950': '255 250 245', + + /* Error */ + '--color-error-0': '83 19 19', + '--color-error-50': '127 29 29', + '--color-error-100': '153 27 27', + '--color-error-200': '185 28 28', + '--color-error-300': '220 38 38', + '--color-error-400': '230 53 53', + '--color-error-500': '239 68 68', + '--color-error-600': '249 97 96', + '--color-error-700': '229 91 90', + '--color-error-800': '254 202 202', + '--color-error-900': '254 226 226', + '--color-error-950': '254 233 233', + + /* Success */ + '--color-success-0': '27 50 36', + '--color-success-50': '20 83 45', + '--color-success-100': '22 101 52', + '--color-success-200': '32 111 62', + '--color-success-300': '42 121 72', + '--color-success-400': '52 131 82', + '--color-success-500': '72 151 102', + '--color-success-600': '102 181 132', + '--color-success-700': '132 211 162', + '--color-success-800': '162 241 192', + '--color-success-900': '202 255 232', + '--color-success-950': '228 255 244', + + /* Warning */ + '--color-warning-0': '84 45 18', + '--color-warning-50': '108 56 19', + '--color-warning-100': '130 68 23', + '--color-warning-200': '180 90 26', + '--color-warning-300': '215 108 31', + '--color-warning-400': '231 120 40', + '--color-warning-500': '251 149 75', + '--color-warning-600': '253 173 116', + '--color-warning-700': '254 205 170', + '--color-warning-800': '255 231 213', + '--color-warning-900': '255 244 237', + '--color-warning-950': '255 249 245', + + /* Info */ + '--color-info-0': '3 38 56', + '--color-info-50': '5 64 93', + '--color-info-100': '7 90 131', + '--color-info-200': '9 115 168', + '--color-info-300': '11 141 205', + '--color-info-400': '13 166 242', + '--color-info-500': '50 180 244', + '--color-info-600': '87 194 246', + '--color-info-700': '124 207 248', + '--color-info-800': '162 221 250', + '--color-info-900': '199 235 252', + '--color-info-950': '236 248 254', + + /* Typography */ + '--color-typography-0': '23 23 23', + '--color-typography-50': '38 38 39', + '--color-typography-100': '64 64 64', + '--color-typography-200': '82 82 82', + '--color-typography-300': '115 115 115', + '--color-typography-400': '140 140 140', + '--color-typography-500': '163 163 163', + '--color-typography-600': '212 212 212', + '--color-typography-700': '219 219 220', + '--color-typography-800': '229 229 229', + '--color-typography-900': '245 245 245', + '--color-typography-950': '254 254 255', + + /* Outline */ + '--color-outline-0': '26 23 23', + '--color-outline-50': '39 38 36', + '--color-outline-100': '65 65 65', + '--color-outline-200': '83 82 82', + '--color-outline-300': '115 116 116', + '--color-outline-400': '140 141 141', + '--color-outline-500': '165 163 163', + '--color-outline-600': '211 211 211', + '--color-outline-700': '221 220 219', + '--color-outline-800': '230 230 230', + '--color-outline-900': '243 243 243', + '--color-outline-950': '253 254 254', + + /* Background */ + '--color-background-0': '18 18 18', + '--color-background-50': '39 38 37', + '--color-background-100': '65 64 64', + '--color-background-200': '83 82 82', + '--color-background-300': '116 116 116', + '--color-background-400': '142 142 142', + '--color-background-500': '162 163 163', + '--color-background-600': '213 212 212', + '--color-background-700': '229 228 228', + '--color-background-800': '242 241 241', + '--color-background-900': '246 246 246', + '--color-background-950': '255 255 255', + + /* Background Special */ + '--color-background-error': '66 43 43', + '--color-background-warning': '65 47 35', + '--color-background-success': '28 43 33', + '--color-background-muted': '51 51 51', + '--color-background-info': '26 40 46', + + /* Focus Ring Indicator */ + '--color-indicator-primary': '247 247 247', + '--color-indicator-info': '161 199 245', + '--color-indicator-error': '232 70 69', + }), +}; diff --git a/components/ui/gluestack-ui-provider/index.next15.tsx b/components/ui/gluestack-ui-provider/index.next15.tsx new file mode 100644 index 0000000..4fafc40 --- /dev/null +++ b/components/ui/gluestack-ui-provider/index.next15.tsx @@ -0,0 +1,87 @@ +// This is a Next.js 15 compatible version of the GluestackUIProvider +'use client'; +import React, { useEffect, useLayoutEffect } from 'react'; +import { config } from './config'; +import { OverlayProvider } from '@gluestack-ui/core/overlay/creator'; +import { ToastProvider } from '@gluestack-ui/core/toast/creator'; +import { setFlushStyles } from '@gluestack-ui/utils/nativewind-utils'; +import { script } from './script'; + +const variableStyleTagId = 'nativewind-style'; +const createStyle = (styleTagId: string) => { + const style = document.createElement('style'); + style.id = styleTagId; + style.appendChild(document.createTextNode('')); + return style; +}; + +export const useSafeLayoutEffect = + typeof window !== 'undefined' ? useLayoutEffect : useEffect; + +export function GluestackUIProvider({ + mode = 'light', + ...props +}: { + mode?: 'light' | 'dark' | 'system'; + children?: React.ReactNode; +}) { + let cssVariablesWithMode = ``; + Object.keys(config).forEach((configKey) => { + cssVariablesWithMode += + configKey === 'dark' ? `\n .dark {\n ` : `\n:root {\n`; + const cssVariables = Object.keys( + config[configKey as keyof typeof config] + ).reduce((acc: string, curr: string) => { + acc += `${curr}:${config[configKey as keyof typeof config][curr]}; `; + return acc; + }, ''); + cssVariablesWithMode += `${cssVariables} \n}`; + }); + + setFlushStyles(cssVariablesWithMode); + + const handleMediaQuery = React.useCallback((e: MediaQueryListEvent) => { + script(e.matches ? 'dark' : 'light'); + }, []); + + useSafeLayoutEffect(() => { + if (mode !== 'system') { + const documentElement = document.documentElement; + if (documentElement) { + documentElement.classList.add(mode); + documentElement.classList.remove(mode === 'light' ? 'dark' : 'light'); + documentElement.style.colorScheme = mode; + } + } + }, [mode]); + + useSafeLayoutEffect(() => { + if (mode !== 'system') return; + const media = window.matchMedia('(prefers-color-scheme: dark)'); + + media.addListener(handleMediaQuery); + + return () => media.removeListener(handleMediaQuery); + }, [handleMediaQuery]); + + useSafeLayoutEffect(() => { + if (typeof window !== 'undefined') { + const documentElement = document.documentElement; + if (documentElement) { + const head = documentElement.querySelector('head'); + let style = head?.querySelector(`[id='${variableStyleTagId}']`); + if (!style) { + style = createStyle(variableStyleTagId); + style.innerHTML = cssVariablesWithMode; + if (head) head.appendChild(style); + } + } + } + }, []); + + return ( + + {props.children} + + ); +} diff --git a/components/ui/gluestack-ui-provider/index.tsx b/components/ui/gluestack-ui-provider/index.tsx new file mode 100644 index 0000000..3453713 --- /dev/null +++ b/components/ui/gluestack-ui-provider/index.tsx @@ -0,0 +1,38 @@ +import React, { useEffect } from 'react'; +import { config } from './config'; +import { View, ViewProps } from 'react-native'; +import { OverlayProvider } from '@gluestack-ui/core/overlay/creator'; +import { ToastProvider } from '@gluestack-ui/core/toast/creator'; +import { useColorScheme } from 'nativewind'; + +export type ModeType = 'light' | 'dark' | 'system'; + +export function GluestackUIProvider({ + mode = 'light', + ...props +}: { + mode?: ModeType; + children?: React.ReactNode; + style?: ViewProps['style']; +}) { + const { colorScheme, setColorScheme } = useColorScheme(); + + useEffect(() => { + setColorScheme(mode); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [mode]); + + return ( + + + {props.children} + + + ); +} diff --git a/components/ui/gluestack-ui-provider/index.web.tsx b/components/ui/gluestack-ui-provider/index.web.tsx new file mode 100644 index 0000000..610b6ad --- /dev/null +++ b/components/ui/gluestack-ui-provider/index.web.tsx @@ -0,0 +1,96 @@ +'use client'; +import React, { useEffect, useLayoutEffect } from 'react'; +import { config } from './config'; +import { OverlayProvider } from '@gluestack-ui/core/overlay/creator'; +import { ToastProvider } from '@gluestack-ui/core/toast/creator'; +import { setFlushStyles } from '@gluestack-ui/utils/nativewind-utils'; +import { script } from './script'; + +export type ModeType = 'light' | 'dark' | 'system'; + +const variableStyleTagId = 'nativewind-style'; +const createStyle = (styleTagId: string) => { + const style = document.createElement('style'); + style.id = styleTagId; + style.appendChild(document.createTextNode('')); + return style; +}; + +export const useSafeLayoutEffect = + typeof window !== 'undefined' ? useLayoutEffect : useEffect; + +export function GluestackUIProvider({ + mode = 'light', + ...props +}: { + mode?: ModeType; + children?: React.ReactNode; +}) { + let cssVariablesWithMode = ``; + Object.keys(config).forEach((configKey) => { + cssVariablesWithMode += + configKey === 'dark' ? `\n .dark {\n ` : `\n:root {\n`; + const cssVariables = Object.keys( + config[configKey as keyof typeof config] + ).reduce((acc: string, curr: string) => { + acc += `${curr}:${config[configKey as keyof typeof config][curr]}; `; + return acc; + }, ''); + cssVariablesWithMode += `${cssVariables} \n}`; + }); + + setFlushStyles(cssVariablesWithMode); + + const handleMediaQuery = React.useCallback((e: MediaQueryListEvent) => { + script(e.matches ? 'dark' : 'light'); + }, []); + + useSafeLayoutEffect(() => { + if (mode !== 'system') { + const documentElement = document.documentElement; + if (documentElement) { + documentElement.classList.add(mode); + documentElement.classList.remove(mode === 'light' ? 'dark' : 'light'); + documentElement.style.colorScheme = mode; + } + } + }, [mode]); + + useSafeLayoutEffect(() => { + if (mode !== 'system') return; + const media = window.matchMedia('(prefers-color-scheme: dark)'); + + media.addListener(handleMediaQuery); + + return () => media.removeListener(handleMediaQuery); + }, [handleMediaQuery]); + + useSafeLayoutEffect(() => { + if (typeof window !== 'undefined') { + const documentElement = document.documentElement; + if (documentElement) { + const head = documentElement.querySelector('head'); + let style = head?.querySelector(`[id='${variableStyleTagId}']`); + if (!style) { + style = createStyle(variableStyleTagId); + style.innerHTML = cssVariablesWithMode; + if (head) head.appendChild(style); + } + } + } + }, []); + + return ( + <> +