Livrare Lot 3 (Frontend): aplicație web, aplicație mobilă Android, extensie browser
- Surse complete web (React/Vite) + mobil (React Native/Expo) + extensie (MV3) - Documentație de livrare: ghid utilizare, matrice trasabilitate cerințe, raport testare furnizor - Artefacte binare: imagine Docker didi-frontend:lot3-1.0, APK, extensie v3.2.6 + SHA256SUMS - Configurare adresă platformă externalizată (build args / .env / config.js) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
4
android/didi-app-source/.env.example
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
# Adresa platformei DiDi (nginx frontend / gateway). Exemple:
|
||||
# LAN integrare: http://10.11.10.18
|
||||
# Productie: https://didi.exemplu.ro
|
||||
EXPO_PUBLIC_DIDI_BASE_URL=
|
||||
47
android/didi-app-source/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
|
||||
# Expo
|
||||
.expo/
|
||||
dist/
|
||||
web-build/
|
||||
expo-env.d.ts
|
||||
|
||||
# Native
|
||||
.kotlin/
|
||||
*.orig.*
|
||||
*.jks
|
||||
*.p8
|
||||
*.p12
|
||||
*.key
|
||||
*.mobileprovision
|
||||
|
||||
# Metro
|
||||
.metro-health-check*
|
||||
|
||||
# debug
|
||||
npm-debug.*
|
||||
yarn-debug.*
|
||||
yarn-error.*
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
|
||||
# Proiectul nativ /android ESTE versionat în această livrare (conține patch-uri
|
||||
# manuale de manifest — share intents, cleartext — care s-ar pierde la `expo prebuild`).
|
||||
/ios
|
||||
.env
|
||||
# fișiere locale de build din proiectul nativ
|
||||
/android/local.properties
|
||||
/android/app/build/
|
||||
/android/.gradle/
|
||||
/android/build/
|
||||
30
android/didi-app-source/App.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import React from 'react';
|
||||
import { View, ActivityIndicator } from 'react-native';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { ShareIntentProvider } from 'expo-share-intent';
|
||||
import { useFonts } from 'expo-font';
|
||||
import AppNavigator from './src/navigation/AppNavigator';
|
||||
import { COLORS } from './src/theme/colors';
|
||||
|
||||
export default function App() {
|
||||
const [fontsLoaded] = useFonts({
|
||||
'Merriweather-Regular': require('./assets/fonts/Merriweather-Regular.ttf'),
|
||||
'Merriweather-Italic': require('./assets/fonts/Merriweather-Italic.ttf'),
|
||||
'Merriweather-Bold': require('./assets/fonts/Merriweather-Bold.ttf'),
|
||||
});
|
||||
|
||||
if (!fontsLoaded) {
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: COLORS.bg.primary, justifyContent: 'center', alignItems: 'center' }}>
|
||||
<ActivityIndicator color={COLORS.brand.primaryLight} size="large" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ShareIntentProvider>
|
||||
<StatusBar style="light" />
|
||||
<AppNavigator />
|
||||
</ShareIntentProvider>
|
||||
);
|
||||
}
|
||||
16
android/didi-app-source/android/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
# OSX
|
||||
#
|
||||
.DS_Store
|
||||
|
||||
# Android/IntelliJ
|
||||
#
|
||||
build/
|
||||
.idea
|
||||
.gradle
|
||||
local.properties
|
||||
*.iml
|
||||
*.hprof
|
||||
.cxx/
|
||||
|
||||
# Bundle artifacts
|
||||
*.jsbundle
|
||||
182
android/didi-app-source/android/app/build.gradle
Normal file
|
|
@ -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 'eu.didi365.mobile'
|
||||
defaultConfig {
|
||||
applicationId 'eu.didi365.mobile'
|
||||
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..<options.size()) options[i] = options[i].trim();
|
||||
// `[] - ""` is essentially `[""].filter(Boolean)` removing all empty strings.
|
||||
options -= ""
|
||||
|
||||
if (options.length > 0) {
|
||||
println "android.packagingOptions.$prop += $options ($options.length)"
|
||||
// Ex: android.packagingOptions.pickFirsts += '**/SCCS/**'
|
||||
options.each {
|
||||
android.packagingOptions[prop] += it
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// The version of react-native is set by the React Native Gradle Plugin
|
||||
implementation("com.facebook.react:react-android")
|
||||
|
||||
def isGifEnabled = (findProperty('expo.gif.enabled') ?: "") == "true";
|
||||
def isWebpEnabled = (findProperty('expo.webp.enabled') ?: "") == "true";
|
||||
def isWebpAnimatedEnabled = (findProperty('expo.webp.animated') ?: "") == "true";
|
||||
|
||||
if (isGifEnabled) {
|
||||
// For animated gif support
|
||||
implementation("com.facebook.fresco:animated-gif:${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
|
||||
}
|
||||
}
|
||||
BIN
android/didi-app-source/android/app/debug.keystore
Normal file
14
android/didi-app-source/android/app/proguard-rules.pro
vendored
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
# Add project specific ProGuard rules here.
|
||||
# By default, the flags in this file are appended to flags specified
|
||||
# in /usr/local/Cellar/android-sdk/24.3.3/tools/proguard/proguard-android.txt
|
||||
# You can edit the include path and order by changing the proguardFiles
|
||||
# directive in build.gradle.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# react-native-reanimated
|
||||
-keep class com.swmansion.reanimated.** { *; }
|
||||
-keep class com.facebook.react.turbomodule.** { *; }
|
||||
|
||||
# Add any project specific keep options here:
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
|
||||
|
||||
<application android:usesCleartextTraffic="true" tools:targetApi="28" tools:ignore="GoogleAppIndexingWarning" tools:replace="android:usesCleartextTraffic" />
|
||||
</manifest>
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
|
||||
|
||||
<application android:usesCleartextTraffic="true" tools:targetApi="28" tools:ignore="GoogleAppIndexingWarning" tools:replace="android:usesCleartextTraffic" />
|
||||
</manifest>
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools">
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS"/>
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
|
||||
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
|
||||
<uses-permission android:name="android.permission.VIBRATE"/>
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW"/>
|
||||
<category android:name="android.intent.category.BROWSABLE"/>
|
||||
<data android:scheme="https"/>
|
||||
</intent>
|
||||
</queries>
|
||||
<application android:usesCleartextTraffic="true" android:name=".MainApplication" android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round" android:allowBackup="true" android:theme="@style/AppTheme" android:supportsRtl="true" android:enableOnBackInvokedCallback="false" android:fullBackupContent="@xml/secure_store_backup_rules" android:dataExtractionRules="@xml/secure_store_data_extraction_rules">
|
||||
<meta-data android:name="expo.modules.updates.ENABLED" android:value="false"/>
|
||||
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_CHECK_ON_LAUNCH" android:value="ALWAYS"/>
|
||||
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_LAUNCH_WAIT_MS" android:value="0"/>
|
||||
<activity android:name=".MainActivity" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout|uiMode" android:launchMode="singleTask" android:windowSoftInputMode="adjustResize" android:theme="@style/Theme.App.SplashScreen" android:exported="true" android:screenOrientation="portrait">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW"/>
|
||||
<category android:name="android.intent.category.DEFAULT"/>
|
||||
<category android:name="android.intent.category.BROWSABLE"/>
|
||||
<data android:scheme="didi"/>
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND"/>
|
||||
<data android:mimeType="text/plain"/>
|
||||
<category android:name="android.intent.category.DEFAULT"/>
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND"/>
|
||||
<data android:mimeType="image/*"/>
|
||||
<category android:name="android.intent.category.DEFAULT"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
</manifest>
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
package eu.didi365.mobile
|
||||
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
|
||||
import com.facebook.react.ReactActivity
|
||||
import com.facebook.react.ReactActivityDelegate
|
||||
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled
|
||||
import com.facebook.react.defaults.DefaultReactActivityDelegate
|
||||
|
||||
import expo.modules.ReactActivityDelegateWrapper
|
||||
|
||||
class MainActivity : ReactActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
// Set the theme to AppTheme BEFORE onCreate to support
|
||||
// coloring the background, status bar, and navigation bar.
|
||||
// This is required for expo-splash-screen.
|
||||
setTheme(R.style.AppTheme);
|
||||
super.onCreate(null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the name of the main component registered from JavaScript. This is used to schedule
|
||||
* rendering of the component.
|
||||
*/
|
||||
override fun getMainComponentName(): String = "main"
|
||||
|
||||
/**
|
||||
* Returns the instance of the [ReactActivityDelegate]. We use [DefaultReactActivityDelegate]
|
||||
* which allows you to enable New Architecture with a single boolean flags [fabricEnabled]
|
||||
*/
|
||||
override fun createReactActivityDelegate(): ReactActivityDelegate {
|
||||
return ReactActivityDelegateWrapper(
|
||||
this,
|
||||
BuildConfig.IS_NEW_ARCHITECTURE_ENABLED,
|
||||
object : DefaultReactActivityDelegate(
|
||||
this,
|
||||
mainComponentName,
|
||||
fabricEnabled
|
||||
){})
|
||||
}
|
||||
|
||||
/**
|
||||
* Align the back button behavior with Android S
|
||||
* where moving root activities to background instead of finishing activities.
|
||||
* @see <a href="https://developer.android.com/reference/android/app/Activity#onBackPressed()">onBackPressed</a>
|
||||
*/
|
||||
override fun invokeDefaultOnBackPressed() {
|
||||
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.R) {
|
||||
if (!moveTaskToBack(false)) {
|
||||
// For non-root activities, use the default implementation to finish them.
|
||||
super.invokeDefaultOnBackPressed()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Use the default back button implementation on Android S
|
||||
// because it's doing more than [Activity.moveTaskToBack] in fact.
|
||||
super.invokeDefaultOnBackPressed()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
package eu.didi365.mobile
|
||||
|
||||
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<ReactPackage> =
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
|
@ -0,0 +1,6 @@
|
|||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="@color/splashscreen_background"/>
|
||||
<item>
|
||||
<bitmap android:gravity="center" android:src="@drawable/splashscreen_logo"/>
|
||||
</item>
|
||||
</layer-list>
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Copyright (C) 2014 The Android Open Source Project
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
<inset xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:insetLeft="@dimen/abc_edit_text_inset_horizontal_material"
|
||||
android:insetRight="@dimen/abc_edit_text_inset_horizontal_material"
|
||||
android:insetTop="@dimen/abc_edit_text_inset_top_material"
|
||||
android:insetBottom="@dimen/abc_edit_text_inset_bottom_material"
|
||||
>
|
||||
|
||||
<selector>
|
||||
<!--
|
||||
This file is a copy of abc_edit_text_material (https://bit.ly/3k8fX7I).
|
||||
The item below with state_pressed="false" and state_focused="false" causes a NullPointerException.
|
||||
NullPointerException:tempt to invoke virtual method 'android.graphics.drawable.Drawable android.graphics.drawable.Drawable$ConstantState.newDrawable(android.content.res.Resources)'
|
||||
|
||||
<item android:state_pressed="false" android:state_focused="false" android:drawable="@drawable/abc_textfield_default_mtrl_alpha"/>
|
||||
|
||||
For more info, see https://bit.ly/3CdLStv (react-native/pull/29452) and https://bit.ly/3nxOMoR.
|
||||
-->
|
||||
<item android:state_enabled="false" android:drawable="@drawable/abc_textfield_default_mtrl_alpha"/>
|
||||
<item android:drawable="@drawable/abc_textfield_activated_mtrl_alpha"/>
|
||||
</selector>
|
||||
|
||||
</inset>
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/iconBackground"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/iconBackground"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 8 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 8 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
|
@ -0,0 +1 @@
|
|||
<resources/>
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
<resources>
|
||||
<color name="splashscreen_background">#060010</color>
|
||||
<color name="iconBackground">#0d1424</color>
|
||||
<color name="colorPrimary">#023c69</color>
|
||||
<color name="colorPrimaryDark">#060010</color>
|
||||
</resources>
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
<resources>
|
||||
<string name="app_name">didi</string>
|
||||
<string name="expo_splash_screen_resize_mode" translatable="false">contain</string>
|
||||
<string name="expo_splash_screen_status_bar_translucent" translatable="false">false</string>
|
||||
</resources>
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<style name="AppTheme" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||
<item name="android:enforceNavigationBarContrast" tools:targetApi="29">true</item>
|
||||
<item name="android:editTextBackground">@drawable/rn_edit_text_material</item>
|
||||
<item name="colorPrimary">@color/colorPrimary</item>
|
||||
<item name="android:statusBarColor">#060010</item>
|
||||
</style>
|
||||
<style name="Theme.App.SplashScreen" parent="AppTheme">
|
||||
<item name="android:windowBackground">@drawable/ic_launcher_background</item>
|
||||
</style>
|
||||
</resources>
|
||||
24
android/didi-app-source/android/build.gradle
Normal file
|
|
@ -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"
|
||||
67
android/didi-app-source/android/gradle.properties
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
# 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=-Xmx6144m -XX:MaxMetaspaceSize=1024m
|
||||
|
||||
# When configured, Gradle will run in incubating parallel mode.
|
||||
# This option should only be used with decoupled projects. More details, visit
|
||||
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
|
||||
org.gradle.parallel=true
|
||||
|
||||
# AndroidX package structure to make it clearer which packages are bundled with the
|
||||
# Android operating system, and which are packaged with your app's APK
|
||||
# https://developer.android.com/topic/libraries/support-library/androidx-rn
|
||||
android.useAndroidX=true
|
||||
|
||||
# Enable AAPT2 PNG crunching
|
||||
android.enablePngCrunchInReleaseBuilds=true
|
||||
|
||||
# Use this property to specify which architecture you want to build.
|
||||
# You can also override it from the CLI using
|
||||
# ./gradlew <task> -PreactNativeArchitectures=x86_64
|
||||
reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64
|
||||
|
||||
# Use this property to enable support to the new architecture.
|
||||
# This will allow you to use TurboModules and the Fabric render in
|
||||
# your application. You should enable this flag either if you want
|
||||
# to write custom TurboModules/Fabric components OR use libraries that
|
||||
# are providing them.
|
||||
newArchEnabled=true
|
||||
|
||||
# Use this property to enable or disable the Hermes JS engine.
|
||||
# If set to false, you will be using JSC instead.
|
||||
hermesEnabled=true
|
||||
|
||||
# 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
|
||||
# JDK-ul (17) se setează prin variabila de mediu JAVA_HOME sau, opțional, aici:
|
||||
# org.gradle.java.home=/cale/catre/jdk-17
|
||||
BIN
android/didi-app-source/android/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
7
android/didi-app-source/android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
|
|
@ -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
|
||||
251
android/didi-app-source/android/gradlew
vendored
Executable file
|
|
@ -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" "$@"
|
||||
94
android/didi-app-source/android/gradlew.bat
vendored
Normal file
|
|
@ -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
|
||||
39
android/didi-app-source/android/settings.gradle
Normal file
|
|
@ -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 = 'didi'
|
||||
|
||||
expoAutolinking.useExpoVersionCatalog()
|
||||
|
||||
include ':app'
|
||||
includeBuild(expoAutolinking.reactNativeGradlePlugin)
|
||||
49
android/didi-app-source/app.json
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
{
|
||||
"expo": {
|
||||
"name": "didi",
|
||||
"slug": "didi-app",
|
||||
"version": "1.0.0",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/icon.png",
|
||||
"userInterfaceStyle": "dark",
|
||||
"scheme": "didi",
|
||||
"newArchEnabled": true,
|
||||
"splash": {
|
||||
"image": "./assets/splash-icon.png",
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#060010"
|
||||
},
|
||||
"ios": {
|
||||
"supportsTablet": true,
|
||||
"bundleIdentifier": "eu.didi365.mobile"
|
||||
},
|
||||
"android": {
|
||||
"package": "eu.didi365.mobile",
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/adaptive-icon.png",
|
||||
"backgroundColor": "#0d1424"
|
||||
},
|
||||
"edgeToEdgeEnabled": true,
|
||||
"predictiveBackGestureEnabled": false,
|
||||
"usesCleartextTraffic": true
|
||||
},
|
||||
"web": {
|
||||
"favicon": "./assets/favicon.png"
|
||||
},
|
||||
"plugins": [
|
||||
"expo-secure-store",
|
||||
"expo-web-browser",
|
||||
"expo-notifications",
|
||||
[
|
||||
"expo-share-intent",
|
||||
{
|
||||
"androidIntentFilters": [
|
||||
"text/plain",
|
||||
"image/*"
|
||||
]
|
||||
}
|
||||
],
|
||||
"expo-font"
|
||||
]
|
||||
}
|
||||
}
|
||||
BIN
android/didi-app-source/assets/adaptive-icon.png
Normal file
|
After Width: | Height: | Size: 51 KiB |
BIN
android/didi-app-source/assets/favicon.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
1482
android/didi-app-source/assets/fonts/Merriweather-Bold.ttf
Normal file
1482
android/didi-app-source/assets/fonts/Merriweather-Italic.ttf
Normal file
1482
android/didi-app-source/assets/fonts/Merriweather-Regular.ttf
Normal file
BIN
android/didi-app-source/assets/icon.png
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
android/didi-app-source/assets/splash-icon.png
Normal file
|
After Width: | Height: | Size: 54 KiB |
9
android/didi-app-source/babel.config.js
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
module.exports = function (api) {
|
||||
api.cache(true);
|
||||
return {
|
||||
presets: ['babel-preset-expo'],
|
||||
// react-native-worklets/plugin is required by react-native-reanimated v4 and
|
||||
// MUST be listed last.
|
||||
plugins: ['react-native-worklets/plugin'],
|
||||
};
|
||||
};
|
||||
8
android/didi-app-source/index.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { registerRootComponent } from 'expo';
|
||||
|
||||
import App from './App';
|
||||
|
||||
// registerRootComponent calls AppRegistry.registerComponent('main', () => App);
|
||||
// It also ensures that whether you load the app in Expo Go or in a native build,
|
||||
// the environment is set up appropriately
|
||||
registerRootComponent(App);
|
||||
12173
android/didi-app-source/package-lock.json
generated
Normal file
51
android/didi-app-source/package.json
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
{
|
||||
"name": "didi-app",
|
||||
"version": "1.0.0",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"android": "expo run:android",
|
||||
"ios": "expo run:ios",
|
||||
"web": "expo start --web"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/metro-runtime": "~6.1.2",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
"@react-navigation/native": "^7.1.28",
|
||||
"@react-navigation/native-stack": "^7.13.0",
|
||||
"axios": "^1.13.5",
|
||||
"buffer": "^6.0.3",
|
||||
"expo": "~54.0.33",
|
||||
"expo-auth-session": "~7.0.10",
|
||||
"expo-av": "~16.0.8",
|
||||
"expo-crypto": "~15.0.8",
|
||||
"expo-document-picker": "~14.0.8",
|
||||
"expo-font": "~14.0.11",
|
||||
"expo-image-picker": "~17.0.10",
|
||||
"expo-linear-gradient": "~15.0.8",
|
||||
"expo-linking": "~8.0.11",
|
||||
"expo-notifications": "~0.32.16",
|
||||
"expo-secure-store": "~15.0.8",
|
||||
"expo-share-intent": "^5.1.1",
|
||||
"expo-status-bar": "~3.0.9",
|
||||
"expo-web-browser": "~15.0.10",
|
||||
"lucide-react-native": "^1.14.0",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-native": "0.81.5",
|
||||
"react-native-reanimated": "~4.1.1",
|
||||
"react-native-safe-area-context": "~5.6.0",
|
||||
"react-native-screens": "~4.16.0",
|
||||
"react-native-svg": "15.12.1",
|
||||
"react-native-web": "^0.21.0",
|
||||
"react-native-webview": "13.15.0",
|
||||
"zustand": "^5.0.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "~19.1.0",
|
||||
"babel-preset-expo": "^55.0.21",
|
||||
"sharp": "^0.34.5",
|
||||
"typescript": "~5.9.2"
|
||||
},
|
||||
"private": true
|
||||
}
|
||||
80
android/didi-app-source/scripts/generate-icons.js
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
const sharp = require('sharp');
|
||||
const path = require('path');
|
||||
|
||||
const ASSETS = path.join(__dirname, '..', 'assets');
|
||||
|
||||
function gradient(id) {
|
||||
return `<linearGradient id="${id}" x1="0" y1="1" x2="1" y2="0">
|
||||
<stop offset="0%" stop-color="#0A0014"/>
|
||||
<stop offset="100%" stop-color="#2D1066"/>
|
||||
</linearGradient>`;
|
||||
}
|
||||
|
||||
// Full square icon (iOS + fallback)
|
||||
function makeIcon(size) {
|
||||
const ts = Math.round(size * 0.32);
|
||||
const cx = size / 2;
|
||||
const cy = size / 2 + ts * 0.1;
|
||||
return `<svg width="${size}" height="${size}" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>${gradient('bg')}</defs>
|
||||
<rect width="${size}" height="${size}" fill="url(#bg)"/>
|
||||
<text x="${cx}" y="${cy}"
|
||||
font-family="Arial Rounded MT Bold, Verdana, Arial, sans-serif"
|
||||
font-size="${ts}" font-weight="700" fill="white"
|
||||
text-anchor="middle" dominant-baseline="central"
|
||||
letter-spacing="4">didi</text>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
// Adaptive foreground: FULL SQUARE gradient + text (no circle!)
|
||||
// Android handles the masking (circle, squircle, etc.)
|
||||
function makeAdaptive(size) {
|
||||
const ts = Math.round(size * 0.25);
|
||||
const cx = size / 2;
|
||||
const cy = size / 2 + ts * 0.1;
|
||||
return `<svg width="${size}" height="${size}" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>${gradient('fg')}</defs>
|
||||
<rect width="${size}" height="${size}" fill="url(#fg)"/>
|
||||
<text x="${cx}" y="${cy}"
|
||||
font-family="Arial Rounded MT Bold, Verdana, Arial, sans-serif"
|
||||
font-size="${ts}" font-weight="700" fill="white"
|
||||
text-anchor="middle" dominant-baseline="central"
|
||||
letter-spacing="3">didi</text>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
// Favicon
|
||||
function makeFavicon(size) {
|
||||
const ts = Math.round(size * 0.40);
|
||||
return `<svg width="${size}" height="${size}" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>${gradient('fv')}</defs>
|
||||
<rect width="${size}" height="${size}" rx="${size * 0.15}" fill="url(#fv)"/>
|
||||
<text x="${size/2}" y="${size/2 + ts * 0.08}"
|
||||
font-family="Arial Rounded MT Bold, Verdana, Arial, sans-serif"
|
||||
font-size="${ts}" font-weight="700" fill="white"
|
||||
text-anchor="middle" dominant-baseline="central"
|
||||
letter-spacing="1">didi</text>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await sharp(Buffer.from(makeIcon(1024)))
|
||||
.png().toFile(path.join(ASSETS, 'icon.png'));
|
||||
console.log('icon.png');
|
||||
|
||||
await sharp(Buffer.from(makeAdaptive(1024)))
|
||||
.png().toFile(path.join(ASSETS, 'adaptive-icon.png'));
|
||||
console.log('adaptive-icon.png');
|
||||
|
||||
await sharp(Buffer.from(makeIcon(1024)))
|
||||
.png().toFile(path.join(ASSETS, 'splash-icon.png'));
|
||||
console.log('splash-icon.png');
|
||||
|
||||
await sharp(Buffer.from(makeFavicon(256)))
|
||||
.resize(48, 48).png().toFile(path.join(ASSETS, 'favicon.png'));
|
||||
console.log('favicon.png');
|
||||
|
||||
console.log('Done!');
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
BIN
android/didi-app-source/sdsd.jpeg
Normal file
|
After Width: | Height: | Size: 86 KiB |
136
android/didi-app-source/src/api/analysis.ts
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
import { skillsApi } from './axios';
|
||||
import { TIMEOUTS } from './config';
|
||||
import type {
|
||||
UploadResponse,
|
||||
AnalysisSession,
|
||||
V3Response,
|
||||
V3QueueStatus,
|
||||
V3AsyncSubmitData,
|
||||
} from '../types/analysis';
|
||||
|
||||
export async function uploadMedia(
|
||||
fileUri: string,
|
||||
fileName: string,
|
||||
fileType: string,
|
||||
userId: string
|
||||
): Promise<string> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', {
|
||||
uri: fileUri,
|
||||
name: fileName,
|
||||
type: fileType,
|
||||
} as any);
|
||||
formData.append('user_id', userId);
|
||||
|
||||
const response = await skillsApi.post<V3Response<UploadResponse['data']>>('media/upload', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
timeout: TIMEOUTS.UPLOAD,
|
||||
});
|
||||
|
||||
const data = response.data?.data;
|
||||
return data?.public_url || '';
|
||||
}
|
||||
|
||||
const ALLOWED_SKILLS = ['techniques', 'claims', 'ai-tampered', 'domain', 'context', 'source-assessment'];
|
||||
|
||||
export async function analyzeSkill(
|
||||
skill: string,
|
||||
body: { text?: string; media_url?: string; user_id?: string; [key: string]: any }
|
||||
): Promise<AnalysisSession> {
|
||||
if (!ALLOWED_SKILLS.includes(skill)) {
|
||||
throw new Error(`Invalid skill: ${skill}`);
|
||||
}
|
||||
const endpoint = body.media_url
|
||||
? `${skill}/analyze-media`
|
||||
: `${skill}/analyze`;
|
||||
const response = await skillsApi.post<V3Response>(
|
||||
endpoint,
|
||||
body,
|
||||
{ timeout: TIMEOUTS.SKILL }
|
||||
);
|
||||
return response.data?.data || response.data as any;
|
||||
}
|
||||
|
||||
export async function analyzePipeline(
|
||||
body: { text?: string; media_url?: string; media_type?: string; user_id?: string; plan_type?: number; [key: string]: any }
|
||||
): Promise<V3Response> {
|
||||
const endpoint = body.media_url
|
||||
? 'pipeline/analyze-media'
|
||||
: 'pipeline/analyze';
|
||||
const response = await skillsApi.post<V3Response>(
|
||||
endpoint,
|
||||
body,
|
||||
{ timeout: TIMEOUTS.SKILL }
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function submitPipelineAsync(
|
||||
body: Record<string, any>
|
||||
): Promise<{ session_id: string; async: boolean; data: V3AsyncSubmitData | AnalysisSession }> {
|
||||
const response = await skillsApi.post<V3Response<V3AsyncSubmitData>>(
|
||||
'pipeline/analyze-async',
|
||||
body,
|
||||
{ timeout: TIMEOUTS.SKILL }
|
||||
);
|
||||
|
||||
const res = response.data;
|
||||
|
||||
// Async path: 202 — session_id is inside data
|
||||
if (res.async && res.data?.session_id) {
|
||||
return { session_id: res.data.session_id, async: true, data: res.data };
|
||||
}
|
||||
|
||||
// Sync fallback: RabbitMQ down — full result returned directly
|
||||
return { session_id: (res.data as any)?.session_id || '', async: false, data: res.data };
|
||||
}
|
||||
|
||||
export async function submitSkillAsync(
|
||||
skill: string,
|
||||
body: Record<string, any>
|
||||
): Promise<{ session_id: string; async: boolean; data: V3AsyncSubmitData | AnalysisSession }> {
|
||||
if (!ALLOWED_SKILLS.includes(skill)) {
|
||||
throw new Error(`Invalid skill: ${skill}`);
|
||||
}
|
||||
const response = await skillsApi.post<V3Response<V3AsyncSubmitData>>(
|
||||
`${skill}/analyze-async`,
|
||||
body,
|
||||
{ timeout: TIMEOUTS.SKILL }
|
||||
);
|
||||
|
||||
const res = response.data;
|
||||
|
||||
// Async path: session_id is inside data
|
||||
if (res.async && res.data?.session_id) {
|
||||
return { session_id: res.data.session_id, async: true, data: res.data };
|
||||
}
|
||||
|
||||
// Sync fallback: full result returned directly
|
||||
return { session_id: (res.data as any)?.session_id || '', async: false, data: res.data };
|
||||
}
|
||||
|
||||
export async function pollQueueStatus(sessionId: string, signal?: AbortSignal): Promise<V3QueueStatus> {
|
||||
const response = await skillsApi.get<V3Response<AnalysisSession>>(
|
||||
`pipeline/${encodeURIComponent(sessionId)}/queue-status`,
|
||||
{ signal }
|
||||
);
|
||||
|
||||
const session = response.data?.data;
|
||||
const queue = session?._queue;
|
||||
|
||||
return {
|
||||
session,
|
||||
progress: queue?.progress || 0,
|
||||
total_components: queue?.total_components || 5,
|
||||
completed_components: queue?.completed_components || [],
|
||||
status: session?.status || 'pending',
|
||||
};
|
||||
}
|
||||
|
||||
export async function getPipelineResult(sessionId: string, signal?: AbortSignal): Promise<AnalysisSession> {
|
||||
const response = await skillsApi.get<V3Response>(
|
||||
`pipeline/${encodeURIComponent(sessionId)}/result`,
|
||||
{ signal }
|
||||
);
|
||||
return response.data?.data || response.data as any;
|
||||
}
|
||||
41
android/didi-app-source/src/api/auth.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import axios from 'axios';
|
||||
import { AUTH_CONFIG, getRedirectUri } from './config';
|
||||
import { mainApi } from './axios';
|
||||
import { TokenResponse, UserProfile } from '../types/auth';
|
||||
|
||||
export async function exchangeCodeForTokens(
|
||||
code: string,
|
||||
codeVerifier: string
|
||||
): Promise<TokenResponse> {
|
||||
const params = new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
client_id: AUTH_CONFIG.CLIENT_ID,
|
||||
code,
|
||||
redirect_uri: getRedirectUri(),
|
||||
code_verifier: codeVerifier,
|
||||
});
|
||||
|
||||
const response = await axios.post(AUTH_CONFIG.TOKEN_URL, params.toString(), {
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function fetchProfile(): Promise<UserProfile> {
|
||||
const response = await mainApi.get('auth/me');
|
||||
// API returns { success: true, data: { ... } }
|
||||
return response.data?.data || response.data;
|
||||
}
|
||||
|
||||
export async function fetchSubscriptionUsage() {
|
||||
const response = await mainApi.get('subscriptions/usage');
|
||||
// API returns { success: true, data: { credits, plan, subscription } }
|
||||
return response.data?.data || response.data;
|
||||
}
|
||||
|
||||
export async function fetchCredits(): Promise<{ credits_remained: number; credits_spent: number }> {
|
||||
const response = await mainApi.get('auth/credits');
|
||||
return response.data?.data || response.data;
|
||||
}
|
||||
62
android/didi-app-source/src/api/axios.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import axios from 'axios';
|
||||
import { Platform } from 'react-native';
|
||||
import { API_CONFIG } from './config';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
|
||||
const USER_AGENT =
|
||||
'Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36 DidiApp/1.0';
|
||||
|
||||
// Browser forbids setting User-Agent — only set on native
|
||||
const nativeHeaders = Platform.OS !== 'web' ? { 'User-Agent': USER_AGENT } : {};
|
||||
|
||||
if (Platform.OS !== 'web') {
|
||||
axios.defaults.headers.common['User-Agent'] = USER_AGENT;
|
||||
}
|
||||
|
||||
export const skillsApi = axios.create({
|
||||
baseURL: API_CONFIG.SKILLS_API,
|
||||
timeout: 300000,
|
||||
headers: nativeHeaders,
|
||||
});
|
||||
|
||||
export const mainApi = axios.create({
|
||||
baseURL: API_CONFIG.MAIN_API,
|
||||
timeout: 30000,
|
||||
headers: nativeHeaders,
|
||||
});
|
||||
|
||||
// Request interceptor - attach token & auto-refresh
|
||||
const attachToken = async (config: any) => {
|
||||
const { accessToken, expiresAt, refreshAccessToken } = useAuthStore.getState();
|
||||
|
||||
if (expiresAt && Date.now() > expiresAt - 30000) {
|
||||
const newToken = await refreshAccessToken();
|
||||
if (newToken) {
|
||||
config.headers.Authorization = `Bearer ${newToken}`;
|
||||
}
|
||||
} else if (accessToken) {
|
||||
config.headers.Authorization = `Bearer ${accessToken}`;
|
||||
}
|
||||
|
||||
return config;
|
||||
};
|
||||
|
||||
// Response interceptor - retry on 401 (max once to prevent infinite loops)
|
||||
const handle401 = async (error: any) => {
|
||||
const config = error.config;
|
||||
if (error.response?.status === 401 && !config?._retried) {
|
||||
config._retried = true;
|
||||
const newToken = await useAuthStore.getState().refreshAccessToken();
|
||||
if (newToken) {
|
||||
config.headers.Authorization = `Bearer ${newToken}`;
|
||||
return axios.request(config);
|
||||
}
|
||||
}
|
||||
return Promise.reject(error);
|
||||
};
|
||||
|
||||
skillsApi.interceptors.request.use(attachToken);
|
||||
skillsApi.interceptors.response.use((r) => r, handle401);
|
||||
|
||||
mainApi.interceptors.request.use(attachToken);
|
||||
mainApi.interceptors.response.use((r) => r, handle401);
|
||||
58
android/didi-app-source/src/api/config.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
// Adresa platformei DiDi se setează EXCLUSIV la build, prin variabila de mediu
|
||||
// EXPO_PUBLIC_DIDI_BASE_URL (fișierul .env din rădăcina proiectului — vezi .env.example).
|
||||
// Toate endpoint-urile (auth prin prefixul /auth al Keycloak, API-uri prin gateway) derivă
|
||||
// din această singură valoare; nu hardcoda adrese în cod.
|
||||
const DIDI_BASE_URL = (process.env.EXPO_PUBLIC_DIDI_BASE_URL ?? '').replace(/\/+$/, '');
|
||||
if (!DIDI_BASE_URL) {
|
||||
console.error('[CONFIG] EXPO_PUBLIC_DIDI_BASE_URL nu este setat — vezi .env.example');
|
||||
}
|
||||
const AUTH_BASE_URL = `${DIDI_BASE_URL}/auth`;
|
||||
const KEYCLOAK_REALM = 'didi-clients';
|
||||
const REALM_BASE = `${AUTH_BASE_URL}/realms/${KEYCLOAK_REALM}`;
|
||||
const OIDC_BASE = `${REALM_BASE}/protocol/openid-connect`;
|
||||
|
||||
export const AUTH_CONFIG = {
|
||||
AUTH_BASE_URL,
|
||||
KEYCLOAK_REALM,
|
||||
CLIENT_ID: 'didi-mobile-app',
|
||||
REDIRECT_URI: 'didi://callback',
|
||||
REDIRECT_URI_WEB: typeof window !== 'undefined' && typeof window.location !== 'undefined'
|
||||
? `${window.location.origin}/auth/callback`
|
||||
: 'http://localhost:8081/auth/callback',
|
||||
SCOPES: 'openid profile email roles',
|
||||
|
||||
AUTH_URL: `${OIDC_BASE}/auth`,
|
||||
TOKEN_URL: `${OIDC_BASE}/token`,
|
||||
REGISTER_URL: `${OIDC_BASE}/registrations`,
|
||||
USERINFO_URL: `${OIDC_BASE}/userinfo`,
|
||||
LOGOUT_URL: `${OIDC_BASE}/logout`,
|
||||
PASSWORD_URL: `${REALM_BASE}/account/credentials/password`,
|
||||
};
|
||||
|
||||
import { Platform } from 'react-native';
|
||||
|
||||
export function getRedirectUri(): string {
|
||||
return Platform.OS === 'web' ? AUTH_CONFIG.REDIRECT_URI_WEB : AUTH_CONFIG.REDIRECT_URI;
|
||||
}
|
||||
|
||||
export const API_CONFIG = {
|
||||
MAIN_API: `${DIDI_BASE_URL}/api/`,
|
||||
MAIN_API_V1: `${DIDI_BASE_URL}/api/v1/`,
|
||||
SKILLS_API: `${DIDI_BASE_URL}/agent-v3/api/v3/`,
|
||||
SCRAPER_API: `${DIDI_BASE_URL}/scraper/`,
|
||||
};
|
||||
|
||||
export const TIMEOUTS = {
|
||||
AUTH: 30000,
|
||||
PIPELINE_TEXT: 120000,
|
||||
PIPELINE_URL: 120000,
|
||||
PIPELINE_MEDIA: 300000,
|
||||
SKILL: 300000,
|
||||
UPLOAD: 60000,
|
||||
SCRAPER: 120000,
|
||||
};
|
||||
|
||||
export const POLL_CONFIG = {
|
||||
INTERVAL: 2500,
|
||||
MAX_DURATION: 600000,
|
||||
};
|
||||
42
android/didi-app-source/src/api/history.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { skillsApi } from './axios';
|
||||
import type {
|
||||
AnalysisSession,
|
||||
V3HistoryResponse,
|
||||
V3Response,
|
||||
} from '../types/analysis';
|
||||
|
||||
export async function getHistory(
|
||||
userId: string,
|
||||
page: number = 1,
|
||||
limit: number = 20
|
||||
): Promise<{ items: AnalysisSession[]; pagination: V3HistoryResponse['data']['pagination'] }> {
|
||||
const params = new URLSearchParams({
|
||||
user_id: userId,
|
||||
page: String(page),
|
||||
limit: String(limit),
|
||||
});
|
||||
const response = await skillsApi.get<V3HistoryResponse>(
|
||||
`pipeline/history?${params.toString()}`
|
||||
);
|
||||
const data = response.data?.data || response.data;
|
||||
return {
|
||||
items: Array.isArray((data as any)?.items) ? (data as any).items : [],
|
||||
pagination: (data as any)?.pagination || { page, limit, total: 0, pages: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
export async function getHistoryDetail(
|
||||
sessionId: string,
|
||||
userId: string
|
||||
): Promise<AnalysisSession> {
|
||||
const response = await skillsApi.get<V3Response>(
|
||||
`pipeline/history/${encodeURIComponent(sessionId)}?user_id=${encodeURIComponent(userId)}`
|
||||
);
|
||||
return response.data?.data || response.data as any;
|
||||
}
|
||||
|
||||
export async function deleteHistoryItem(sessionId: string, userId: string): Promise<void> {
|
||||
await skillsApi.delete(
|
||||
`pipeline/history/${encodeURIComponent(sessionId)}?user_id=${encodeURIComponent(userId)}`
|
||||
);
|
||||
}
|
||||
48
android/didi-app-source/src/api/techniqueDefinitions.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { skillsApi } from './axios';
|
||||
import type { TechniqueDefinition } from '../types/analysis';
|
||||
|
||||
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
let cache: TechniqueDefinition[] | null = null;
|
||||
let cacheTimestamp = 0;
|
||||
let pendingRequest: Promise<TechniqueDefinition[]> | null = null;
|
||||
|
||||
export async function getTechniqueDefinitions(): Promise<TechniqueDefinition[]> {
|
||||
// Return cache if fresh
|
||||
if (cache && Date.now() - cacheTimestamp < CACHE_TTL) {
|
||||
return cache;
|
||||
}
|
||||
|
||||
// Deduplicate concurrent requests
|
||||
if (pendingRequest) return pendingRequest;
|
||||
|
||||
pendingRequest = (async () => {
|
||||
try {
|
||||
const response = await skillsApi.get<{ success: boolean; data: TechniqueDefinition[] }>(
|
||||
'techniques/definitions',
|
||||
{ timeout: 15000 }
|
||||
);
|
||||
const defs = response.data?.data || [];
|
||||
cache = defs;
|
||||
cacheTimestamp = Date.now();
|
||||
return defs;
|
||||
} catch {
|
||||
// If endpoint is down, return cached data or empty — zero errors
|
||||
return cache || [];
|
||||
} finally {
|
||||
pendingRequest = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return pendingRequest;
|
||||
}
|
||||
|
||||
/** Build a lookup map: technique_name → definition */
|
||||
export async function getTechniqueDefinitionsMap(): Promise<Map<string, TechniqueDefinition>> {
|
||||
const defs = await getTechniqueDefinitions();
|
||||
const map = new Map<string, TechniqueDefinition>();
|
||||
for (const d of defs) {
|
||||
map.set(d.technique_name, d);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
105
android/didi-app-source/src/components/GlassCard.tsx
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import React from 'react';
|
||||
import { View, TouchableOpacity, Text, StyleSheet, ViewStyle } from 'react-native';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { COLORS, SPACING, RADIUS, FONT_SIZES, FONT_WEIGHTS, LAYOUT } from '../theme/colors';
|
||||
|
||||
interface GlassCardProps {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
iconLabel?: string;
|
||||
iconColors?: readonly [string, string];
|
||||
onPress?: () => void;
|
||||
style?: ViewStyle;
|
||||
borderColor?: string;
|
||||
}
|
||||
|
||||
export default function GlassCard({
|
||||
title,
|
||||
subtitle,
|
||||
iconLabel,
|
||||
iconColors,
|
||||
onPress,
|
||||
style,
|
||||
borderColor,
|
||||
}: GlassCardProps) {
|
||||
const Wrapper = onPress ? TouchableOpacity : View;
|
||||
|
||||
return (
|
||||
<Wrapper
|
||||
activeOpacity={0.7}
|
||||
onPress={onPress}
|
||||
accessibilityRole={onPress ? 'button' : undefined}
|
||||
accessibilityLabel={onPress ? `${title}. ${subtitle}` : undefined}
|
||||
style={[
|
||||
styles.card,
|
||||
borderColor ? { borderColor } : undefined,
|
||||
style,
|
||||
]}
|
||||
>
|
||||
<View style={styles.content}>
|
||||
{iconLabel && (
|
||||
<LinearGradient
|
||||
colors={iconColors ? [...iconColors] as [string, string] : [COLORS.brand.primary, COLORS.brand.accentMagenta]}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={styles.iconCircle}
|
||||
>
|
||||
<Text style={styles.iconText}>{iconLabel}</Text>
|
||||
</LinearGradient>
|
||||
)}
|
||||
<View style={styles.textContainer}>
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
<Text style={styles.subtitle}>{subtitle}</Text>
|
||||
</View>
|
||||
{onPress && <Text style={styles.arrow}>›</Text>}
|
||||
</View>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
backgroundColor: COLORS.bg.card,
|
||||
borderWidth: 1,
|
||||
borderColor: COLORS.border.accent,
|
||||
borderRadius: RADIUS.xxl,
|
||||
padding: SPACING.lg,
|
||||
marginBottom: SPACING.sm + 4,
|
||||
},
|
||||
content: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
iconCircle: {
|
||||
width: LAYOUT.ICON_CIRCLE_SIZE,
|
||||
height: LAYOUT.ICON_CIRCLE_SIZE,
|
||||
borderRadius: RADIUS.full,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: SPACING.md,
|
||||
},
|
||||
iconText: {
|
||||
color: COLORS.text.primary,
|
||||
fontSize: FONT_SIZES.lg,
|
||||
fontWeight: FONT_WEIGHTS.bold,
|
||||
},
|
||||
textContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
title: {
|
||||
color: COLORS.text.primary,
|
||||
fontSize: FONT_SIZES.base,
|
||||
fontWeight: FONT_WEIGHTS.bold,
|
||||
},
|
||||
subtitle: {
|
||||
color: COLORS.text.secondary,
|
||||
fontSize: FONT_SIZES.sm,
|
||||
marginTop: 2,
|
||||
},
|
||||
arrow: {
|
||||
color: COLORS.text.hint,
|
||||
fontSize: FONT_SIZES.xxl,
|
||||
fontWeight: FONT_WEIGHTS.bold,
|
||||
},
|
||||
});
|
||||
309
android/didi-app-source/src/components/HistoryCard.tsx
Normal file
|
|
@ -0,0 +1,309 @@
|
|||
import React from 'react';
|
||||
import { View, Text, TouchableOpacity, StyleSheet, ActivityIndicator } from 'react-native';
|
||||
import {
|
||||
AlertCircle, Cpu, ShieldCheck, ShieldAlert, AlertOctagon, AlertTriangle,
|
||||
CheckSquare, HelpCircle, FileText, Image as ImageIcon, Music, Video, Globe, Type,
|
||||
} from 'lucide-react-native';
|
||||
import type { LucideIcon } from 'lucide-react-native';
|
||||
import {
|
||||
COLORS,
|
||||
SPACING,
|
||||
RADIUS,
|
||||
FONT_SIZES,
|
||||
FONT_WEIGHTS,
|
||||
ACCENT,
|
||||
getBadgeBgColor,
|
||||
} from '../theme/colors';
|
||||
import type { AnalysisSession } from '../types/analysis';
|
||||
import { useTranslation, getTranslation, enumLabel } from '../i18n';
|
||||
|
||||
interface RunningItemProgress {
|
||||
progress: number;
|
||||
completedComponents: string[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
item: AnalysisSession;
|
||||
onPress: () => void;
|
||||
onLongPress: () => void;
|
||||
progress?: RunningItemProgress;
|
||||
}
|
||||
|
||||
function formatRelativeTime(dateStr: string): string {
|
||||
const { t } = getTranslation();
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMin = Math.floor(diffMs / 60000);
|
||||
if (diffMin < 1) return t('justNow');
|
||||
if (diffMin < 60) return t('minutesAgo', { min: diffMin });
|
||||
const diffHr = Math.floor(diffMin / 60);
|
||||
if (diffHr < 24) return t('hoursAgo', { hr: diffHr });
|
||||
const diffDay = Math.floor(diffHr / 24);
|
||||
if (diffDay < 7) return t('daysAgo', { day: diffDay });
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
const TYPE_ICON: Record<string, LucideIcon> = {
|
||||
text: Type,
|
||||
image: ImageIcon,
|
||||
audio: Music,
|
||||
video: Video,
|
||||
url: Globe,
|
||||
};
|
||||
|
||||
interface CardSummary {
|
||||
badgeScore: number;
|
||||
label: string;
|
||||
badgeColor: string;
|
||||
badgeIcon: LucideIcon;
|
||||
}
|
||||
|
||||
/** Decide what to show on the right side of the card based on session type. */
|
||||
function summarizeSession(item: AnalysisSession, t: (k: any) => string, isRo: boolean): CardSummary {
|
||||
const componentsRun = item.components_run || [];
|
||||
const isSingle = componentsRun.length === 1;
|
||||
const single = isSingle ? componentsRun[0] : null;
|
||||
|
||||
if (single === 'domain' || single === 'source_assessment') {
|
||||
const trust = (item as any).source_trust_score ?? 0;
|
||||
return {
|
||||
badgeScore: trust,
|
||||
label: (item as any).source_verdict
|
||||
? enumLabel((item as any).source_verdict)
|
||||
: t('sourceAssessment'),
|
||||
badgeColor: trust >= 70 ? '#22c55e' : trust >= 40 ? '#eab308' : trust >= 20 ? '#f97316' : '#ef4444',
|
||||
badgeIcon: trust >= 70 ? ShieldCheck : trust >= 40 ? ShieldAlert : AlertOctagon,
|
||||
};
|
||||
}
|
||||
if (single === 'ai_tampered' || single === 'ai-tampered') {
|
||||
const score = item.risk_score ?? 0;
|
||||
return {
|
||||
badgeScore: score,
|
||||
label: t('aiDetection'),
|
||||
badgeColor: score >= 80 ? '#ef4444' : score >= 60 ? '#f97316' : score >= 40 ? '#eab308' : '#22c55e',
|
||||
badgeIcon: score >= 60 ? AlertTriangle : Cpu,
|
||||
};
|
||||
}
|
||||
if (single === 'techniques') {
|
||||
const score = item.risk_score ?? 0;
|
||||
return {
|
||||
badgeScore: score,
|
||||
label: t('manipulationTechniques'),
|
||||
badgeColor: score >= 70 ? '#ef4444' : score >= 40 ? '#f97316' : score >= 20 ? '#eab308' : '#22c55e',
|
||||
badgeIcon: score >= 70 ? AlertOctagon : score >= 40 ? AlertTriangle : ShieldCheck,
|
||||
};
|
||||
}
|
||||
if (single === 'claims') {
|
||||
const score = item.risk_score ?? 0;
|
||||
return {
|
||||
badgeScore: score,
|
||||
label: t('claimsVerification'),
|
||||
badgeColor: ACCENT.violet,
|
||||
badgeIcon: CheckSquare,
|
||||
};
|
||||
}
|
||||
// Pipeline (multi-component or unknown) — use risk_score + risk_category
|
||||
const score = item.risk_score ?? 0;
|
||||
return {
|
||||
badgeScore: score,
|
||||
label: item.risk_category
|
||||
? enumLabel(item.risk_category)
|
||||
: (item.risk_level ? enumLabel(item.risk_level) : t('analysis')),
|
||||
badgeColor: score >= 70 ? '#ef4444'
|
||||
: score >= 50 ? '#f97316'
|
||||
: score >= 30 ? '#eab308'
|
||||
: score > 0 ? '#22c55e'
|
||||
: ACCENT.violet,
|
||||
badgeIcon: score >= 70 ? AlertOctagon : score >= 30 ? AlertTriangle : score > 0 ? ShieldCheck : HelpCircle,
|
||||
};
|
||||
}
|
||||
|
||||
export default function HistoryCard({ item, onPress, onLongPress, progress }: Props) {
|
||||
const { t, language } = useTranslation();
|
||||
const isRo = language === 'ro';
|
||||
const TypeIcon = TYPE_ICON[item.input_type || 'text'] || AlertCircle;
|
||||
const statusDone = item.status === 'completed';
|
||||
const isRunning = item.status === 'running' || item.status === 'pending' || item.status === 'processing';
|
||||
|
||||
const summary = summarizeSession(item, t, isRo);
|
||||
|
||||
const a11yLabel = [
|
||||
item.input_text || item.input_url || t('mediaAnalysis'),
|
||||
item.created_at ? formatRelativeTime(item.created_at) : null,
|
||||
statusDone ? `${summary.label}, ${Math.round(summary.badgeScore)}` : item.status,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('. ');
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={styles.card}
|
||||
onPress={onPress}
|
||||
onLongPress={onLongPress}
|
||||
activeOpacity={0.75}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={a11yLabel}
|
||||
>
|
||||
{/* Type icon */}
|
||||
<View style={styles.typeIconBox}>
|
||||
<TypeIcon size={18} color={COLORS.brand.primaryLight} strokeWidth={2} />
|
||||
</View>
|
||||
|
||||
{/* Content */}
|
||||
<View style={styles.content}>
|
||||
<Text style={styles.inputText} numberOfLines={2}>
|
||||
{item.input_text || item.input_url || t('mediaAnalysis')}
|
||||
</Text>
|
||||
<View style={styles.metaRow}>
|
||||
{item.created_at && (
|
||||
<Text style={styles.time}>{formatRelativeTime(item.created_at)}</Text>
|
||||
)}
|
||||
{isRunning && (
|
||||
<View style={styles.statusBadge}>
|
||||
<Text style={styles.statusText}>
|
||||
{progress ? `${Math.round(progress.progress)}%` : item.status}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{!statusDone && !isRunning && item.status && (
|
||||
<View style={styles.statusBadge}>
|
||||
<Text style={styles.statusText}>{item.status}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
{isRunning && progress && progress.progress > 0 && (
|
||||
<View style={styles.progressBarBg}>
|
||||
<View style={[styles.progressBarFill, { width: `${Math.min(progress.progress, 100)}%` }]} />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Score / state */}
|
||||
<View style={styles.scoreBox}>
|
||||
{statusDone ? (
|
||||
<>
|
||||
<View style={[styles.scoreCircle, { borderColor: summary.badgeColor }]}>
|
||||
<Text
|
||||
style={[styles.scoreNum, { color: summary.badgeColor }]}
|
||||
maxFontSizeMultiplier={1.5}
|
||||
>
|
||||
{Math.round(summary.badgeScore)}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={[styles.catBadge, { backgroundColor: getBadgeBgColor(summary.badgeColor, 0.16) }]}>
|
||||
<summary.badgeIcon size={11} color={summary.badgeColor} strokeWidth={2.4} />
|
||||
<Text
|
||||
style={[styles.catText, { color: summary.badgeColor }]}
|
||||
numberOfLines={1}
|
||||
maxFontSizeMultiplier={1.5}
|
||||
>
|
||||
{summary.label}
|
||||
</Text>
|
||||
</View>
|
||||
</>
|
||||
) : (
|
||||
<ActivityIndicator size="small" color={COLORS.brand.primaryLight} />
|
||||
)}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'rgba(255,255,255,0.025)',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255,255,255,0.06)',
|
||||
borderRadius: RADIUS.xl,
|
||||
padding: 14,
|
||||
marginBottom: SPACING.sm + 2,
|
||||
},
|
||||
typeIconBox: {
|
||||
width: 38,
|
||||
height: 38,
|
||||
borderRadius: RADIUS.lg,
|
||||
backgroundColor: 'rgba(167,139,250,0.10)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: 12,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
marginRight: SPACING.sm,
|
||||
},
|
||||
inputText: {
|
||||
color: COLORS.text.primary,
|
||||
fontSize: FONT_SIZES.sm,
|
||||
lineHeight: 19,
|
||||
marginBottom: 4,
|
||||
},
|
||||
metaRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
time: {
|
||||
color: COLORS.text.muted,
|
||||
fontSize: FONT_SIZES.xs,
|
||||
},
|
||||
statusBadge: {
|
||||
marginLeft: SPACING.sm,
|
||||
paddingHorizontal: SPACING.sm,
|
||||
paddingVertical: 1,
|
||||
borderRadius: RADIUS.full,
|
||||
backgroundColor: 'rgba(255,255,255,0.06)',
|
||||
},
|
||||
statusText: {
|
||||
color: COLORS.status.yellow,
|
||||
fontSize: FONT_SIZES.xs - 1,
|
||||
textTransform: 'capitalize',
|
||||
},
|
||||
progressBarBg: {
|
||||
width: '100%',
|
||||
height: 3,
|
||||
borderRadius: 1.5,
|
||||
backgroundColor: 'rgba(255,255,255,0.08)',
|
||||
overflow: 'hidden',
|
||||
marginTop: SPACING.xs,
|
||||
},
|
||||
progressBarFill: {
|
||||
height: '100%',
|
||||
borderRadius: 1.5,
|
||||
backgroundColor: COLORS.brand.primary,
|
||||
},
|
||||
scoreBox: {
|
||||
alignItems: 'center',
|
||||
minWidth: 64,
|
||||
gap: 4,
|
||||
},
|
||||
scoreCircle: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
borderWidth: 2,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
scoreNum: {
|
||||
fontSize: 16,
|
||||
fontWeight: FONT_WEIGHTS.bold,
|
||||
letterSpacing: -0.4,
|
||||
},
|
||||
catBadge: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 3,
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 6,
|
||||
maxWidth: 90,
|
||||
},
|
||||
catText: {
|
||||
fontSize: 9.5,
|
||||
fontWeight: FONT_WEIGHTS.semibold,
|
||||
letterSpacing: 0.4,
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
});
|
||||
286
android/didi-app-source/src/components/TabSelector.tsx
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
import React, { useRef, useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
View,
|
||||
TouchableOpacity,
|
||||
Text,
|
||||
StyleSheet,
|
||||
Animated,
|
||||
Platform,
|
||||
LayoutChangeEvent,
|
||||
} from 'react-native';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import {
|
||||
COLORS,
|
||||
SPACING,
|
||||
RADIUS,
|
||||
FONT_SIZES,
|
||||
FONT_WEIGHTS,
|
||||
} from '../theme/colors';
|
||||
|
||||
interface Tab {
|
||||
key: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface TabSelectorProps {
|
||||
tabs: Tab[];
|
||||
activeTab: string;
|
||||
onTabChange: (key: string) => void;
|
||||
}
|
||||
|
||||
const ICON_MAP: Record<string, string> = {
|
||||
text: 'T',
|
||||
image: 'I',
|
||||
audio: 'A',
|
||||
video: 'V',
|
||||
url: 'U',
|
||||
};
|
||||
|
||||
const TRACK_PADDING = 3;
|
||||
|
||||
const SPRING_CONFIG = {
|
||||
tension: 180,
|
||||
friction: 22,
|
||||
useNativeDriver: false,
|
||||
};
|
||||
|
||||
export default function TabSelector({
|
||||
tabs,
|
||||
activeTab,
|
||||
onTabChange,
|
||||
}: TabSelectorProps) {
|
||||
const tabLayouts = useRef<{ x: number; width: number }[]>([]);
|
||||
const measuredCount = useRef(0);
|
||||
const [layoutReady, setLayoutReady] = useState(false);
|
||||
const isFirstRender = useRef(true);
|
||||
|
||||
// Animated values for the pill
|
||||
const pillX = useRef(new Animated.Value(0)).current;
|
||||
const pillWidth = useRef(new Animated.Value(0)).current;
|
||||
|
||||
// Per-tab animated values
|
||||
const fadeAnims = useRef<Animated.Value[]>([]);
|
||||
const scaleAnims = useRef<Animated.Value[]>([]);
|
||||
|
||||
// Initialize per-tab animated values when tabs change
|
||||
if (fadeAnims.current.length !== tabs.length) {
|
||||
fadeAnims.current = tabs.map(
|
||||
(tab, i) => new Animated.Value(tab.key === activeTab ? 1 : 0),
|
||||
);
|
||||
scaleAnims.current = tabs.map(
|
||||
(tab, i) => new Animated.Value(tab.key === activeTab ? 1.05 : 1),
|
||||
);
|
||||
}
|
||||
|
||||
const activeIndex = tabs.findIndex((t) => t.key === activeTab);
|
||||
|
||||
const handleLayout = useCallback(
|
||||
(index: number, event: LayoutChangeEvent) => {
|
||||
const { x, width } = event.nativeEvent.layout;
|
||||
const isNew = !tabLayouts.current[index];
|
||||
tabLayouts.current[index] = { x, width };
|
||||
if (isNew) {
|
||||
measuredCount.current += 1;
|
||||
}
|
||||
if (measuredCount.current >= tabs.length && !layoutReady) {
|
||||
setLayoutReady(true);
|
||||
}
|
||||
},
|
||||
[tabs.length, layoutReady],
|
||||
);
|
||||
|
||||
// Set initial pill position once layout is ready
|
||||
useEffect(() => {
|
||||
if (!layoutReady) return;
|
||||
const layout = tabLayouts.current[activeIndex];
|
||||
if (!layout) return;
|
||||
|
||||
if (isFirstRender.current) {
|
||||
isFirstRender.current = false;
|
||||
pillX.setValue(layout.x);
|
||||
pillWidth.setValue(layout.width);
|
||||
return;
|
||||
}
|
||||
|
||||
// Animate pill to new position
|
||||
const animations: Animated.CompositeAnimation[] = [
|
||||
Animated.spring(pillX, { ...SPRING_CONFIG, toValue: layout.x }),
|
||||
Animated.spring(pillWidth, { ...SPRING_CONFIG, toValue: layout.width }),
|
||||
];
|
||||
|
||||
// Animate tab fade and scale
|
||||
tabs.forEach((tab, i) => {
|
||||
const isActive = i === activeIndex;
|
||||
animations.push(
|
||||
Animated.timing(fadeAnims.current[i], {
|
||||
toValue: isActive ? 1 : 0,
|
||||
duration: 200,
|
||||
useNativeDriver: false,
|
||||
}),
|
||||
Animated.spring(scaleAnims.current[i], {
|
||||
...SPRING_CONFIG,
|
||||
toValue: isActive ? 1.05 : 1,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
Animated.parallel(animations).start();
|
||||
}, [activeIndex, layoutReady]);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.track}>
|
||||
{/* Animated gradient pill */}
|
||||
{layoutReady && (
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.pillWrapper,
|
||||
{
|
||||
transform: [{ translateX: pillX }],
|
||||
width: pillWidth,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={['#009198', '#1fb6bd']}
|
||||
start={{ x: 0, y: 1 }}
|
||||
end={{ x: 1, y: 0 }}
|
||||
style={styles.pill}
|
||||
/>
|
||||
</Animated.View>
|
||||
)}
|
||||
|
||||
{/* Tab items */}
|
||||
{tabs.map((tab, index) => {
|
||||
const fadeAnim = fadeAnims.current[index];
|
||||
const scaleAnim = scaleAnims.current[index];
|
||||
const icon = ICON_MAP[tab.key] || tab.label.charAt(0).toUpperCase();
|
||||
|
||||
// Interpolate colors based on fade value
|
||||
const textColor = fadeAnim
|
||||
? fadeAnim.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: [COLORS.text.muted, '#FFFFFF'],
|
||||
})
|
||||
: COLORS.text.muted;
|
||||
|
||||
const badgeBg = fadeAnim
|
||||
? fadeAnim.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: ['rgba(255,255,255,0.06)', 'rgba(255,255,255,0.20)'],
|
||||
})
|
||||
: 'rgba(255,255,255,0.06)';
|
||||
|
||||
const badgeTextColor = fadeAnim
|
||||
? fadeAnim.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: [COLORS.text.darkMuted, '#FFFFFF'],
|
||||
})
|
||||
: COLORS.text.darkMuted;
|
||||
|
||||
const isActive = tab.key === activeTab;
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={tab.key}
|
||||
activeOpacity={0.7}
|
||||
onPress={() => onTabChange(tab.key)}
|
||||
style={styles.tabTouchable}
|
||||
onLayout={(e) => handleLayout(index, e)}
|
||||
accessibilityRole="tab"
|
||||
accessibilityLabel={tab.label}
|
||||
accessibilityState={{ selected: isActive }}
|
||||
>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.tabContent,
|
||||
scaleAnim ? { transform: [{ scale: scaleAnim }] } : undefined,
|
||||
]}
|
||||
>
|
||||
<Animated.View
|
||||
style={[styles.iconBadge, { backgroundColor: badgeBg }]}
|
||||
>
|
||||
<Animated.Text
|
||||
style={[styles.iconText, { color: badgeTextColor }]}
|
||||
maxFontSizeMultiplier={1.5}
|
||||
>
|
||||
{icon}
|
||||
</Animated.Text>
|
||||
</Animated.View>
|
||||
<Animated.Text
|
||||
style={[styles.tabLabel, { color: textColor }]}
|
||||
maxFontSizeMultiplier={1.5}
|
||||
>
|
||||
{tab.label}
|
||||
</Animated.Text>
|
||||
</Animated.View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
paddingVertical: SPACING.sm,
|
||||
},
|
||||
track: {
|
||||
flexDirection: 'row',
|
||||
backgroundColor: COLORS.glass.white5,
|
||||
borderWidth: 1,
|
||||
borderColor: COLORS.glass.purple10,
|
||||
borderRadius: RADIUS.xl,
|
||||
padding: TRACK_PADDING,
|
||||
position: 'relative',
|
||||
},
|
||||
pillWrapper: {
|
||||
position: 'absolute',
|
||||
top: TRACK_PADDING,
|
||||
bottom: TRACK_PADDING,
|
||||
left: 0,
|
||||
zIndex: 0,
|
||||
borderRadius: RADIUS.xl - TRACK_PADDING,
|
||||
...Platform.select({
|
||||
ios: {
|
||||
shadowColor: '#009198',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.45,
|
||||
shadowRadius: 8,
|
||||
},
|
||||
android: {
|
||||
elevation: 6,
|
||||
},
|
||||
}),
|
||||
},
|
||||
pill: {
|
||||
flex: 1,
|
||||
borderRadius: RADIUS.xl - TRACK_PADDING,
|
||||
},
|
||||
tabTouchable: {
|
||||
flex: 1,
|
||||
zIndex: 1,
|
||||
},
|
||||
tabContent: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: SPACING.sm,
|
||||
gap: 4,
|
||||
},
|
||||
iconBadge: {
|
||||
width: 22,
|
||||
height: 22,
|
||||
borderRadius: 6,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
iconText: {
|
||||
fontSize: 11,
|
||||
fontWeight: FONT_WEIGHTS.bold,
|
||||
},
|
||||
tabLabel: {
|
||||
fontSize: FONT_SIZES.xs,
|
||||
fontWeight: FONT_WEIGHTS.bold,
|
||||
},
|
||||
});
|
||||
219
android/didi-app-source/src/components/UploadConsentModal.tsx
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
import React, { useState } from 'react';
|
||||
import { Modal, View, Text, TouchableOpacity, StyleSheet } from 'react-native';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import {
|
||||
COLORS,
|
||||
GRADIENTS,
|
||||
SPACING,
|
||||
RADIUS,
|
||||
FONT_SIZES,
|
||||
FONT_WEIGHTS,
|
||||
LAYOUT,
|
||||
SHADOWS,
|
||||
} from '../theme/colors';
|
||||
import { useTranslation } from '../i18n';
|
||||
|
||||
interface UploadConsentModalProps {
|
||||
visible: boolean;
|
||||
onAccept: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Explicit consent dialog shown before the first media file upload.
|
||||
* The user must tick the checkbox before the confirm button is enabled.
|
||||
*/
|
||||
export default function UploadConsentModal({
|
||||
visible,
|
||||
onAccept,
|
||||
onCancel,
|
||||
}: UploadConsentModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [checked, setChecked] = useState(false);
|
||||
|
||||
const handleAccept = () => {
|
||||
if (!checked) return;
|
||||
setChecked(false);
|
||||
onAccept();
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setChecked(false);
|
||||
onCancel();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={handleCancel}
|
||||
>
|
||||
<View style={styles.overlay}>
|
||||
<View style={styles.dialog}>
|
||||
<Text style={styles.title} maxFontSizeMultiplier={1.5}>
|
||||
{t('uploadConsentTitle')}
|
||||
</Text>
|
||||
<Text style={styles.body}>{t('uploadConsentBody')}</Text>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.checkboxRow}
|
||||
activeOpacity={0.7}
|
||||
onPress={() => setChecked((c) => !c)}
|
||||
accessibilityRole="checkbox"
|
||||
accessibilityState={{ checked }}
|
||||
accessibilityLabel={t('uploadConsentCheckbox')}
|
||||
>
|
||||
<View style={[styles.checkbox, checked && styles.checkboxChecked]}>
|
||||
{checked && <Text style={styles.checkmark}>✓</Text>}
|
||||
</View>
|
||||
<Text style={styles.checkboxLabel}>{t('uploadConsentCheckbox')}</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={styles.buttonsRow}>
|
||||
<TouchableOpacity
|
||||
style={styles.cancelButton}
|
||||
activeOpacity={0.8}
|
||||
onPress={handleCancel}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t('cancel')}
|
||||
>
|
||||
<Text style={styles.cancelText} maxFontSizeMultiplier={1.5}>
|
||||
{t('cancel')}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={{ flex: 1 }}
|
||||
activeOpacity={0.8}
|
||||
onPress={handleAccept}
|
||||
disabled={!checked}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t('uploadConsentAccept')}
|
||||
accessibilityState={{ disabled: !checked }}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={checked
|
||||
? [...GRADIENTS.button.colors] as [string, string]
|
||||
: [COLORS.glass.white10, COLORS.glass.white10]}
|
||||
start={GRADIENTS.button.start}
|
||||
end={GRADIENTS.button.end}
|
||||
style={[styles.acceptButton, !checked && styles.acceptDisabled]}
|
||||
>
|
||||
<Text
|
||||
style={[styles.acceptText, !checked && styles.acceptTextDisabled]}
|
||||
maxFontSizeMultiplier={1.5}
|
||||
>
|
||||
{t('uploadConsentAccept')}
|
||||
</Text>
|
||||
</LinearGradient>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
overlay: {
|
||||
flex: 1,
|
||||
backgroundColor: COLORS.glass.black50,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: SPACING.lg,
|
||||
},
|
||||
dialog: {
|
||||
width: '100%',
|
||||
maxWidth: 420,
|
||||
backgroundColor: COLORS.bg.dialog,
|
||||
borderWidth: 1,
|
||||
borderColor: COLORS.border.accent,
|
||||
borderRadius: RADIUS.xxl,
|
||||
padding: LAYOUT.CARD_PADDING,
|
||||
...SHADOWS.dialog,
|
||||
},
|
||||
title: {
|
||||
color: COLORS.text.primary,
|
||||
fontSize: FONT_SIZES.lg,
|
||||
fontWeight: FONT_WEIGHTS.bold,
|
||||
marginBottom: SPACING.sm,
|
||||
},
|
||||
body: {
|
||||
color: COLORS.text.secondary,
|
||||
fontSize: FONT_SIZES.sm,
|
||||
lineHeight: 20,
|
||||
marginBottom: SPACING.md,
|
||||
},
|
||||
checkboxRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
backgroundColor: COLORS.glass.white5,
|
||||
borderRadius: RADIUS.lg,
|
||||
padding: SPACING.md,
|
||||
marginBottom: SPACING.lg,
|
||||
},
|
||||
checkbox: {
|
||||
width: 22,
|
||||
height: 22,
|
||||
borderRadius: 6,
|
||||
borderWidth: 2,
|
||||
borderColor: COLORS.border.secondary,
|
||||
backgroundColor: COLORS.glass.white10,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: SPACING.sm + 4,
|
||||
marginTop: 1,
|
||||
},
|
||||
checkboxChecked: {
|
||||
borderColor: COLORS.brand.primary,
|
||||
backgroundColor: COLORS.brand.primary,
|
||||
},
|
||||
checkmark: {
|
||||
color: COLORS.text.primary,
|
||||
fontSize: 13,
|
||||
fontWeight: FONT_WEIGHTS.bold,
|
||||
},
|
||||
checkboxLabel: {
|
||||
flex: 1,
|
||||
color: COLORS.text.secondary,
|
||||
fontSize: FONT_SIZES.xs,
|
||||
lineHeight: 18,
|
||||
},
|
||||
buttonsRow: {
|
||||
flexDirection: 'row',
|
||||
gap: SPACING.md,
|
||||
},
|
||||
cancelButton: {
|
||||
flex: 1,
|
||||
backgroundColor: COLORS.glass.white5,
|
||||
borderWidth: 1,
|
||||
borderColor: COLORS.border.secondary,
|
||||
borderRadius: RADIUS.xl,
|
||||
paddingVertical: SPACING.sm + 4,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
cancelText: {
|
||||
color: COLORS.text.secondary,
|
||||
fontSize: FONT_SIZES.sm,
|
||||
fontWeight: FONT_WEIGHTS.bold,
|
||||
},
|
||||
acceptButton: {
|
||||
borderRadius: RADIUS.xl,
|
||||
paddingVertical: SPACING.sm + 4,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
acceptDisabled: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
acceptText: {
|
||||
color: COLORS.text.primary,
|
||||
fontSize: FONT_SIZES.sm,
|
||||
fontWeight: FONT_WEIGHTS.bold,
|
||||
},
|
||||
acceptTextDisabled: {
|
||||
color: COLORS.text.muted,
|
||||
},
|
||||
});
|
||||
398
android/didi-app-source/src/components/UserProfileWidget.tsx
Normal file
|
|
@ -0,0 +1,398 @@
|
|||
import React, { useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
Animated,
|
||||
LayoutAnimation,
|
||||
Platform,
|
||||
UIManager,
|
||||
} from 'react-native';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import {
|
||||
COLORS,
|
||||
GRADIENTS,
|
||||
SPACING,
|
||||
RADIUS,
|
||||
FONT_SIZES,
|
||||
FONT_WEIGHTS,
|
||||
LAYOUT,
|
||||
getBadgeBgColor,
|
||||
} from '../theme/colors';
|
||||
import { UserProfile } from '../types/auth';
|
||||
import { useTranslation } from '../i18n';
|
||||
|
||||
if (Platform.OS === 'android' && UIManager.setLayoutAnimationEnabledExperimental) {
|
||||
UIManager.setLayoutAnimationEnabledExperimental(true);
|
||||
}
|
||||
|
||||
interface UserProfileWidgetProps {
|
||||
profile: UserProfile | null;
|
||||
usageStats: any;
|
||||
creditsData?: { credits_remained: number; credits_spent: number } | null;
|
||||
}
|
||||
|
||||
export default function UserProfileWidget({ profile, usageStats, creditsData }: UserProfileWidgetProps) {
|
||||
const { t } = useTranslation();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const navigation = useNavigation<NativeStackNavigationProp<any>>();
|
||||
|
||||
const initials = profile
|
||||
? `${profile.firstName?.[0] || ''}${profile.lastName?.[0] || ''}`.toUpperCase()
|
||||
: '?';
|
||||
|
||||
const plan = usageStats?.plan as any;
|
||||
const sub = usageStats?.subscription as any;
|
||||
|
||||
const planName = plan?.name || 'Free';
|
||||
|
||||
// Credits from auth/credits endpoint (authoritative source)
|
||||
const credits = creditsData?.credits_remained ?? profile?.creditsRemained ?? 0;
|
||||
const creditSpent = creditsData?.credits_spent ?? profile?.creditsSpent ?? 0;
|
||||
const creditTotal = credits + creditSpent;
|
||||
|
||||
const toggleExpand = () => {
|
||||
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);
|
||||
setExpanded(!expanded);
|
||||
};
|
||||
|
||||
// Credit color based on remaining
|
||||
const creditColor =
|
||||
credits < 5 ? COLORS.status.red :
|
||||
credits <= 10 ? COLORS.status.yellow :
|
||||
COLORS.status.green;
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* Collapsed Bar */}
|
||||
<TouchableOpacity activeOpacity={0.8} onPress={toggleExpand} style={styles.triggerBar}>
|
||||
<LinearGradient
|
||||
colors={[...GRADIENTS.avatar.colors] as [string, string, ...string[]]}
|
||||
start={GRADIENTS.avatar.start}
|
||||
end={GRADIENTS.avatar.end}
|
||||
style={styles.avatarSmall}
|
||||
>
|
||||
<Text style={styles.avatarSmallText}>{initials}</Text>
|
||||
</LinearGradient>
|
||||
|
||||
<Text style={styles.triggerName} numberOfLines={1}>
|
||||
{profile ? profile.firstName : '...'}
|
||||
</Text>
|
||||
|
||||
<Text style={[styles.triggerCredits, { color: creditColor }]}>{t('creditsLabel', { count: credits })}</Text>
|
||||
|
||||
<View style={styles.planBadge}>
|
||||
<Text style={styles.planBadgeText}>{planName.toUpperCase()}</Text>
|
||||
</View>
|
||||
|
||||
<Text style={styles.arrow}>{expanded ? '▴' : '▾'}</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Expanded Dropdown */}
|
||||
{expanded && (
|
||||
<View style={styles.dropdown}>
|
||||
{/* Section 1: User Info */}
|
||||
<View style={styles.userInfoSection}>
|
||||
<LinearGradient
|
||||
colors={[...GRADIENTS.avatar.colors] as [string, string, ...string[]]}
|
||||
start={GRADIENTS.avatar.start}
|
||||
end={GRADIENTS.avatar.end}
|
||||
style={styles.avatarLarge}
|
||||
>
|
||||
<Text style={styles.avatarLargeText}>{initials}</Text>
|
||||
</LinearGradient>
|
||||
<View style={styles.userInfoText}>
|
||||
<Text style={styles.userName}>
|
||||
{profile ? `${profile.firstName} ${profile.lastName}` : t('loading')}
|
||||
</Text>
|
||||
<Text style={styles.userEmail}>{profile?.email || ''}</Text>
|
||||
<View style={styles.planRow}>
|
||||
<View style={styles.planBadgeLarge}>
|
||||
<Text style={styles.planBadgeLargeText}>{planName}</Text>
|
||||
</View>
|
||||
{usageStats && (
|
||||
<Text style={styles.planPrice}>
|
||||
${plan?.priceAmount ?? plan?.price_amount ?? 0}{t('perMonth')}
|
||||
</Text>
|
||||
)}
|
||||
<View style={[
|
||||
styles.statusDot,
|
||||
{ backgroundColor: (sub?.isActive ?? sub?.is_active) ? COLORS.status.green : COLORS.status.red }
|
||||
]} />
|
||||
<Text style={styles.statusText}>
|
||||
{(sub?.isActive ?? sub?.is_active) ? t('active') : t('inactive')}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.divider} />
|
||||
|
||||
{/* Section 2: Credits + Limits */}
|
||||
<View style={styles.columnsRow}>
|
||||
{/* Credits Column */}
|
||||
<View style={styles.column}>
|
||||
<Text style={styles.columnTitle}>{t('credits')}</Text>
|
||||
<View style={styles.statRow}>
|
||||
<Text style={styles.statLabel}>{t('remaining')}</Text>
|
||||
<Text style={[styles.statValue, { color: creditColor }]}>{credits}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Limits Column */}
|
||||
<View style={styles.column}>
|
||||
<Text style={styles.columnTitle}>{t('planLimits')}</Text>
|
||||
<View style={styles.statRow}>
|
||||
<Text style={styles.statLabel}>{t('creditsCycle')}</Text>
|
||||
<Text style={styles.statValue}>{creditTotal || '—'}</Text>
|
||||
</View>
|
||||
<View style={styles.statRow}>
|
||||
<Text style={styles.statLabel}>{t('maxImages')}</Text>
|
||||
<Text style={styles.statValue}>{plan?.maxImages ?? plan?.max_images ?? '—'}</Text>
|
||||
</View>
|
||||
<View style={styles.statRow}>
|
||||
<Text style={styles.statLabel}>{t('videoMinutes')}</Text>
|
||||
<Text style={styles.statValue}>{plan?.maxVideoMinutes ?? plan?.max_video_minutes ?? '—'}</Text>
|
||||
</View>
|
||||
<View style={styles.statRow}>
|
||||
<Text style={styles.statLabel}>{t('storage')}</Text>
|
||||
<Text style={styles.statValue}>{plan?.storageLimitGb ?? plan?.storage_limit_gb ?? '—'} GB</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.divider} />
|
||||
|
||||
{/* Section 3: Credit Costs + Settings */}
|
||||
<View style={styles.footerRow}>
|
||||
<View style={styles.costsRow}>
|
||||
<Text style={styles.costItem}>Text: {usageStats?.costs?.text ?? 1}cr</Text>
|
||||
<Text style={styles.costDivider}>|</Text>
|
||||
<Text style={styles.costItem}>URL: {usageStats?.costs?.url ?? 1}cr</Text>
|
||||
<Text style={styles.costDivider}>|</Text>
|
||||
<Text style={styles.costItem}>Img: {usageStats?.costs?.image ?? 2}cr</Text>
|
||||
<Text style={styles.costDivider}>|</Text>
|
||||
<Text style={styles.costItem}>Audio: {usageStats?.costs?.audio ?? 3}cr</Text>
|
||||
<Text style={styles.costDivider}>|</Text>
|
||||
<Text style={styles.costItem}>Video: {usageStats?.costs?.video ?? 5}cr</Text>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
onPress={() => navigation.navigate('Settings')}
|
||||
style={styles.settingsButton}
|
||||
>
|
||||
<Text style={styles.settingsText}>{t('settings')}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
marginBottom: SPACING.lg,
|
||||
},
|
||||
// Trigger Bar
|
||||
triggerBar: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: COLORS.bg.card,
|
||||
borderWidth: 1,
|
||||
borderColor: COLORS.border.accent,
|
||||
borderRadius: RADIUS.xxl,
|
||||
paddingVertical: SPACING.sm + 4,
|
||||
paddingHorizontal: SPACING.md,
|
||||
minHeight: 52,
|
||||
},
|
||||
avatarSmall: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: RADIUS.full,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: SPACING.sm,
|
||||
},
|
||||
avatarSmallText: {
|
||||
color: COLORS.text.primary,
|
||||
fontSize: FONT_SIZES.xs,
|
||||
fontWeight: FONT_WEIGHTS.bold,
|
||||
},
|
||||
triggerName: {
|
||||
color: COLORS.text.primary,
|
||||
fontSize: FONT_SIZES.sm,
|
||||
fontWeight: FONT_WEIGHTS.bold,
|
||||
marginRight: SPACING.sm,
|
||||
flexShrink: 1,
|
||||
},
|
||||
triggerCredits: {
|
||||
color: COLORS.brand.accentCyan,
|
||||
fontSize: FONT_SIZES.xs,
|
||||
fontWeight: FONT_WEIGHTS.bold,
|
||||
marginRight: SPACING.sm,
|
||||
},
|
||||
planBadge: {
|
||||
backgroundColor: COLORS.glass.cyan10,
|
||||
borderWidth: 1,
|
||||
borderColor: COLORS.glass.cyan20,
|
||||
borderRadius: RADIUS.lg,
|
||||
paddingHorizontal: SPACING.sm,
|
||||
paddingVertical: 2,
|
||||
marginRight: SPACING.sm,
|
||||
},
|
||||
planBadgeText: {
|
||||
color: COLORS.brand.accentCyan,
|
||||
fontSize: 10,
|
||||
fontWeight: FONT_WEIGHTS.bold,
|
||||
},
|
||||
arrow: {
|
||||
color: COLORS.text.hint,
|
||||
fontSize: FONT_SIZES.sm,
|
||||
marginLeft: 'auto',
|
||||
},
|
||||
// Dropdown
|
||||
dropdown: {
|
||||
backgroundColor: COLORS.bg.card,
|
||||
borderWidth: 1,
|
||||
borderColor: COLORS.border.accent,
|
||||
borderRadius: RADIUS.xxl,
|
||||
padding: LAYOUT.CARD_PADDING,
|
||||
marginTop: SPACING.sm,
|
||||
},
|
||||
// Section 1
|
||||
userInfoSection: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
avatarLarge: {
|
||||
width: LAYOUT.AVATAR_SIZE_LARGE,
|
||||
height: LAYOUT.AVATAR_SIZE_LARGE,
|
||||
borderRadius: RADIUS.full,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: SPACING.md,
|
||||
},
|
||||
avatarLargeText: {
|
||||
color: COLORS.text.primary,
|
||||
fontSize: FONT_SIZES.xl,
|
||||
fontWeight: FONT_WEIGHTS.bold,
|
||||
},
|
||||
userInfoText: {
|
||||
flex: 1,
|
||||
},
|
||||
userName: {
|
||||
color: COLORS.text.primary,
|
||||
fontSize: FONT_SIZES.base,
|
||||
fontWeight: FONT_WEIGHTS.bold,
|
||||
},
|
||||
userEmail: {
|
||||
color: COLORS.text.secondary,
|
||||
fontSize: FONT_SIZES.sm,
|
||||
marginTop: 2,
|
||||
},
|
||||
planRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginTop: SPACING.sm,
|
||||
flexWrap: 'wrap',
|
||||
gap: SPACING.sm,
|
||||
},
|
||||
planBadgeLarge: {
|
||||
backgroundColor: COLORS.glass.cyan10,
|
||||
borderWidth: 1,
|
||||
borderColor: COLORS.glass.cyan20,
|
||||
borderRadius: RADIUS.lg,
|
||||
paddingHorizontal: SPACING.sm + 4,
|
||||
paddingVertical: SPACING.xs,
|
||||
},
|
||||
planBadgeLargeText: {
|
||||
color: COLORS.brand.accentCyan,
|
||||
fontSize: FONT_SIZES.xs,
|
||||
fontWeight: FONT_WEIGHTS.bold,
|
||||
},
|
||||
planPrice: {
|
||||
color: COLORS.text.secondary,
|
||||
fontSize: FONT_SIZES.xs,
|
||||
},
|
||||
statusDot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: RADIUS.full,
|
||||
},
|
||||
statusText: {
|
||||
color: COLORS.text.secondary,
|
||||
fontSize: FONT_SIZES.xs,
|
||||
},
|
||||
divider: {
|
||||
height: 1,
|
||||
backgroundColor: COLORS.border.accent,
|
||||
marginVertical: SPACING.md,
|
||||
},
|
||||
// Section 2
|
||||
columnsRow: {
|
||||
flexDirection: 'row',
|
||||
gap: SPACING.md,
|
||||
},
|
||||
column: {
|
||||
flex: 1,
|
||||
},
|
||||
columnTitle: {
|
||||
color: COLORS.text.primary,
|
||||
fontSize: FONT_SIZES.sm,
|
||||
fontWeight: FONT_WEIGHTS.bold,
|
||||
marginBottom: SPACING.sm,
|
||||
},
|
||||
statRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: SPACING.xs + 2,
|
||||
},
|
||||
statLabel: {
|
||||
color: COLORS.text.hint,
|
||||
fontSize: FONT_SIZES.xs,
|
||||
},
|
||||
statValue: {
|
||||
color: COLORS.text.primary,
|
||||
fontSize: FONT_SIZES.xs,
|
||||
fontWeight: FONT_WEIGHTS.bold,
|
||||
},
|
||||
// Section 3
|
||||
footerRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
minHeight: 40,
|
||||
},
|
||||
costsRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
flex: 1,
|
||||
gap: 4,
|
||||
},
|
||||
costItem: {
|
||||
color: COLORS.text.hint,
|
||||
fontSize: 10,
|
||||
},
|
||||
costDivider: {
|
||||
color: COLORS.text.muted,
|
||||
fontSize: 10,
|
||||
},
|
||||
settingsButton: {
|
||||
backgroundColor: COLORS.glass.purple10,
|
||||
borderRadius: RADIUS.lg,
|
||||
paddingHorizontal: SPACING.md,
|
||||
paddingVertical: SPACING.sm,
|
||||
marginLeft: SPACING.sm,
|
||||
},
|
||||
settingsText: {
|
||||
color: COLORS.brand.primary,
|
||||
fontSize: FONT_SIZES.xs,
|
||||
fontWeight: FONT_WEIGHTS.bold,
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
import React from 'react';
|
||||
import { View, Text, StyleSheet } from 'react-native';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import type { LucideIcon } from 'lucide-react-native';
|
||||
import { COLORS, SPACING, RADIUS, FONT_WEIGHTS } from '../../theme/colors';
|
||||
|
||||
interface Props {
|
||||
accent: string;
|
||||
icon: LucideIcon;
|
||||
/** Tiny uppercase tag (e.g. "DO NOT SHARE", "READ CRITICALLY"). */
|
||||
eyebrow: string;
|
||||
/** Bold one-liner action prompt. */
|
||||
headline: string;
|
||||
/** Optional descriptive paragraph. */
|
||||
body?: string;
|
||||
}
|
||||
|
||||
function withAlpha(hex: string, alpha: number) {
|
||||
const cleaned = hex.replace('#', '');
|
||||
const r = parseInt(cleaned.slice(0, 2), 16);
|
||||
const g = parseInt(cleaned.slice(2, 4), 16);
|
||||
const b = parseInt(cleaned.slice(4, 6), 16);
|
||||
return `rgba(${r},${g},${b},${alpha})`;
|
||||
}
|
||||
|
||||
export default function ActionCallout({ accent, icon: Icon, eyebrow, headline, body }: Props) {
|
||||
return (
|
||||
<View style={[styles.card, { borderColor: withAlpha(accent, 0.45) }]}>
|
||||
<LinearGradient
|
||||
colors={[withAlpha(accent, 0.28), withAlpha(accent, 0.10)] as [string, string]}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={StyleSheet.absoluteFill}
|
||||
/>
|
||||
<View style={styles.row}>
|
||||
<View style={[styles.iconBox, { backgroundColor: withAlpha(accent, 0.32) }]}>
|
||||
<Icon size={24} color={accent} strokeWidth={2} />
|
||||
</View>
|
||||
<View style={styles.body}>
|
||||
<Text style={[styles.eyebrow, { color: accent }]}>{eyebrow}</Text>
|
||||
<Text style={styles.headline}>{headline}</Text>
|
||||
{body ? <Text style={styles.text}>{body}</Text> : null}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
borderWidth: 1,
|
||||
borderRadius: RADIUS.xxl,
|
||||
padding: 20,
|
||||
overflow: 'hidden',
|
||||
marginBottom: SPACING.md,
|
||||
},
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
gap: 16,
|
||||
alignItems: 'flex-start',
|
||||
},
|
||||
iconBox: {
|
||||
width: 42,
|
||||
height: 42,
|
||||
borderRadius: 12,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
flexShrink: 0,
|
||||
},
|
||||
body: {
|
||||
flex: 1,
|
||||
gap: 4,
|
||||
},
|
||||
eyebrow: {
|
||||
fontSize: 11,
|
||||
fontWeight: FONT_WEIGHTS.bold,
|
||||
letterSpacing: 1.3,
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
headline: {
|
||||
fontSize: 18,
|
||||
fontWeight: FONT_WEIGHTS.semibold,
|
||||
color: '#fff',
|
||||
letterSpacing: -0.4,
|
||||
lineHeight: 24,
|
||||
},
|
||||
text: {
|
||||
marginTop: 4,
|
||||
fontSize: 14,
|
||||
lineHeight: 21,
|
||||
color: 'rgba(255,255,255,0.75)',
|
||||
},
|
||||
});
|
||||
62
android/didi-app-source/src/components/editorial/Chip.tsx
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import React from 'react';
|
||||
import { View, Text, StyleSheet } from 'react-native';
|
||||
import { ACCENT, type FindingAccent, FONT_WEIGHTS } from '../../theme/colors';
|
||||
|
||||
interface ChipProps {
|
||||
variant?: FindingAccent | 'default';
|
||||
children: React.ReactNode;
|
||||
/** Optional leading icon node (e.g. a Lucide <Search size={12} />). */
|
||||
leading?: React.ReactNode;
|
||||
}
|
||||
|
||||
const VARIANT_FG: Record<string, string> = {
|
||||
...ACCENT,
|
||||
default: ACCENT.violet,
|
||||
};
|
||||
|
||||
function withAlpha(hex: string, alpha: number) {
|
||||
const cleaned = hex.replace('#', '');
|
||||
const r = parseInt(cleaned.slice(0, 2), 16);
|
||||
const g = parseInt(cleaned.slice(2, 4), 16);
|
||||
const b = parseInt(cleaned.slice(4, 6), 16);
|
||||
return `rgba(${r},${g},${b},${alpha})`;
|
||||
}
|
||||
|
||||
export function Chip({ variant = 'default', children, leading }: ChipProps) {
|
||||
const fg = VARIANT_FG[variant];
|
||||
return (
|
||||
<View style={[styles.chip, { backgroundColor: withAlpha(fg, 0.14) }]}>
|
||||
{leading ? <View style={{ marginRight: 4 }}>{leading}</View> : null}
|
||||
<Text style={[styles.text, { color: fg }]}>{children}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
interface RowProps {
|
||||
children: React.ReactNode;
|
||||
style?: any;
|
||||
}
|
||||
|
||||
export function ChipsRow({ children, style }: RowProps) {
|
||||
return <View style={[styles.row, style]}>{children}</View>;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
chip: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 5,
|
||||
paddingHorizontal: 12,
|
||||
borderRadius: 999,
|
||||
},
|
||||
text: {
|
||||
fontSize: 12,
|
||||
fontWeight: FONT_WEIGHTS.medium,
|
||||
letterSpacing: -0.05,
|
||||
},
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 6,
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
import React from 'react';
|
||||
import { TouchableOpacity, View, Text, StyleSheet } from 'react-native';
|
||||
import { ChevronDown } from 'lucide-react-native';
|
||||
import { COLORS, SPACING, RADIUS, FONT_WEIGHTS } from '../../theme/colors';
|
||||
|
||||
interface Props {
|
||||
onPress: () => void;
|
||||
expanded: boolean;
|
||||
label: string;
|
||||
/** Optional leading icon node (defaults to none). */
|
||||
leading?: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function CollapseButton({ onPress, expanded, label, leading }: Props) {
|
||||
return (
|
||||
<TouchableOpacity onPress={onPress} activeOpacity={0.7} style={styles.btn}>
|
||||
<View style={styles.left}>
|
||||
{leading}
|
||||
<Text style={styles.label}>{label}</Text>
|
||||
</View>
|
||||
<View style={[styles.chevron, expanded && styles.chevronExpanded]}>
|
||||
<ChevronDown size={14} color="rgba(255,255,255,0.6)" strokeWidth={2.5} />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
btn: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 18,
|
||||
backgroundColor: 'rgba(255,255,255,0.022)',
|
||||
borderRadius: RADIUS.xl,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255,255,255,0.05)',
|
||||
marginBottom: SPACING.sm,
|
||||
},
|
||||
left: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
flex: 1,
|
||||
},
|
||||
label: {
|
||||
fontSize: 13,
|
||||
fontWeight: FONT_WEIGHTS.medium,
|
||||
color: 'rgba(255,255,255,0.75)',
|
||||
letterSpacing: -0.05,
|
||||
},
|
||||
chevron: {
|
||||
transform: [{ rotate: '0deg' }],
|
||||
},
|
||||
chevronExpanded: {
|
||||
transform: [{ rotate: '180deg' }],
|
||||
},
|
||||
});
|
||||
123
android/didi-app-source/src/components/editorial/FindingCard.tsx
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
import React from 'react';
|
||||
import { View, Text, StyleSheet } from 'react-native';
|
||||
import type { LucideIcon } from 'lucide-react-native';
|
||||
import { COLORS, SPACING, RADIUS, EDITORIAL, ACCENT, type FindingAccent } from '../../theme/colors';
|
||||
|
||||
interface Props {
|
||||
icon: LucideIcon;
|
||||
accent?: FindingAccent;
|
||||
/** When true, render a slightly larger emphasized variant. */
|
||||
dominant?: boolean;
|
||||
eyebrow?: React.ReactNode;
|
||||
headline: React.ReactNode;
|
||||
/** Optional pull-quote (Merriweather italic with left rule). */
|
||||
quote?: React.ReactNode;
|
||||
/** Optional source row (icons + links + separators). */
|
||||
source?: React.ReactNode;
|
||||
}
|
||||
|
||||
function withAlpha(hex: string, alpha: number) {
|
||||
const cleaned = hex.replace('#', '');
|
||||
const r = parseInt(cleaned.slice(0, 2), 16);
|
||||
const g = parseInt(cleaned.slice(2, 4), 16);
|
||||
const b = parseInt(cleaned.slice(4, 6), 16);
|
||||
return `rgba(${r},${g},${b},${alpha})`;
|
||||
}
|
||||
|
||||
export default function FindingCard({
|
||||
icon: Icon, accent = 'critical', dominant, eyebrow, headline, quote, source,
|
||||
}: Props) {
|
||||
const hex = ACCENT[accent];
|
||||
const iconSize = dominant ? 20 : 18;
|
||||
const boxSize = dominant ? 38 : 34;
|
||||
const boxRadius = dominant ? 11 : 10;
|
||||
const borderColor = dominant
|
||||
? withAlpha(hex, 0.22)
|
||||
: 'rgba(255,255,255,0.08)';
|
||||
|
||||
return (
|
||||
<View style={[styles.card, dominant && styles.dominant, { borderColor }]}>
|
||||
<View style={[
|
||||
styles.iconBox,
|
||||
{
|
||||
width: boxSize,
|
||||
height: boxSize,
|
||||
borderRadius: boxRadius,
|
||||
backgroundColor: withAlpha(hex, 0.16),
|
||||
},
|
||||
]}>
|
||||
<Icon size={iconSize} color={hex} strokeWidth={2} />
|
||||
</View>
|
||||
<View style={styles.body}>
|
||||
{eyebrow ? (
|
||||
<Text style={styles.eyebrow}>{eyebrow}</Text>
|
||||
) : null}
|
||||
<Text style={dominant ? styles.headlineDominant : styles.headline}>
|
||||
{headline}
|
||||
</Text>
|
||||
{quote ? (
|
||||
<View style={styles.quoteWrap}>
|
||||
<View style={styles.quoteRule} />
|
||||
<Text style={styles.quote}>{quote}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
{source ? <View style={styles.sourceRow}>{source}</View> : null}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
flexDirection: 'row',
|
||||
gap: 14,
|
||||
paddingVertical: 18,
|
||||
paddingHorizontal: 18,
|
||||
borderWidth: 1,
|
||||
borderRadius: RADIUS.xxl,
|
||||
backgroundColor: 'rgba(255,255,255,0.025)',
|
||||
marginBottom: SPACING.sm + 4,
|
||||
},
|
||||
dominant: {
|
||||
paddingVertical: 20,
|
||||
paddingHorizontal: 20,
|
||||
},
|
||||
iconBox: {
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
flexShrink: 0,
|
||||
},
|
||||
body: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
gap: 8,
|
||||
},
|
||||
eyebrow: {
|
||||
fontSize: 10.5,
|
||||
letterSpacing: 1.3,
|
||||
fontWeight: '600',
|
||||
color: 'rgba(255,255,255,0.45)',
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
headline: EDITORIAL.findingHeadline,
|
||||
headlineDominant: EDITORIAL.findingHeadlineDominant,
|
||||
quoteWrap: {
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
},
|
||||
quoteRule: {
|
||||
width: 2,
|
||||
backgroundColor: 'rgba(255,255,255,0.14)',
|
||||
borderRadius: 1,
|
||||
},
|
||||
quote: {
|
||||
...EDITORIAL.quote,
|
||||
flex: 1,
|
||||
},
|
||||
sourceRow: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,246 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import { View, Text, StyleSheet } from 'react-native';
|
||||
import Svg, { Circle } from 'react-native-svg';
|
||||
import Animated, {
|
||||
useSharedValue,
|
||||
useAnimatedProps,
|
||||
withDelay,
|
||||
withTiming,
|
||||
Easing,
|
||||
} from 'react-native-reanimated';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import type { LucideIcon } from 'lucide-react-native';
|
||||
import { COLORS, SPACING, RADIUS, FONT_SIZES, FONT_WEIGHTS, EDITORIAL } from '../../theme/colors';
|
||||
|
||||
const AnimatedCircle = Animated.createAnimatedComponent(Circle);
|
||||
|
||||
const GAUGE_SIZE = 132;
|
||||
const STROKE_WIDTH = 7;
|
||||
const RADIUS_PX = (GAUGE_SIZE - STROKE_WIDTH) / 2;
|
||||
const CIRCUMFERENCE = 2 * Math.PI * RADIUS_PX;
|
||||
|
||||
interface Props {
|
||||
/** Hex color used for accent on icon-box, gauge stroke and gradient bg. */
|
||||
accent: string;
|
||||
/** Score 0–100 displayed inside the gauge. */
|
||||
score: number;
|
||||
/** Optional secondary line for gauge center, e.g. "/ 100 RISK". */
|
||||
scoreLabel?: string;
|
||||
/** Small uppercase label above the category title. */
|
||||
eyebrow: string;
|
||||
/** Lucide icon rendered in the small accent square. */
|
||||
icon: LucideIcon;
|
||||
/** Big category name. */
|
||||
category: string;
|
||||
/** Inline descriptor next to category (already composed with separators). */
|
||||
descriptor?: React.ReactNode;
|
||||
/** Optional TL;DR paragraph (Merriweather italic). */
|
||||
tldr?: React.ReactNode;
|
||||
}
|
||||
|
||||
function withAlpha(hex: string, alpha: number): string {
|
||||
const cleaned = hex.replace('#', '');
|
||||
const r = parseInt(cleaned.slice(0, 2), 16);
|
||||
const g = parseInt(cleaned.slice(2, 4), 16);
|
||||
const b = parseInt(cleaned.slice(4, 6), 16);
|
||||
return `rgba(${r},${g},${b},${alpha})`;
|
||||
}
|
||||
|
||||
export default function HeadlineCard({
|
||||
accent, score, scoreLabel, eyebrow, icon: Icon, category, descriptor, tldr,
|
||||
}: Props) {
|
||||
const offset = useSharedValue(CIRCUMFERENCE);
|
||||
const animatedScore = useSharedValue(0);
|
||||
|
||||
useEffect(() => {
|
||||
const target = CIRCUMFERENCE * (1 - Math.min(1, Math.max(0, score / 100)));
|
||||
offset.value = withDelay(200, withTiming(target, {
|
||||
duration: 1100,
|
||||
easing: Easing.out(Easing.cubic),
|
||||
}));
|
||||
animatedScore.value = withDelay(200, withTiming(score, {
|
||||
duration: 1100,
|
||||
easing: Easing.out(Easing.cubic),
|
||||
}));
|
||||
}, [score, offset, animatedScore]);
|
||||
|
||||
const animatedCircleProps = useAnimatedProps(() => ({
|
||||
strokeDashoffset: offset.value,
|
||||
}));
|
||||
|
||||
const [displayScore, setDisplayScore] = React.useState(0);
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setDisplayScore(Math.round(animatedScore.value));
|
||||
}, 16);
|
||||
return () => clearInterval(interval);
|
||||
}, [animatedScore]);
|
||||
|
||||
return (
|
||||
<View style={[styles.card, { borderColor: withAlpha(accent, 0.18) }]}>
|
||||
<LinearGradient
|
||||
colors={[withAlpha(accent, 0.10), 'rgba(255,255,255,0.025)'] as [string, string]}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 0, y: 1 }}
|
||||
style={StyleSheet.absoluteFill}
|
||||
pointerEvents="none"
|
||||
/>
|
||||
<View style={styles.glow} pointerEvents="none">
|
||||
<LinearGradient
|
||||
colors={[withAlpha(accent, 0.18), 'transparent'] as [string, string]}
|
||||
style={{ flex: 1, borderRadius: 999 }}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* GAUGE — centered on top */}
|
||||
<View style={styles.gaugeWrap}>
|
||||
<Svg width={GAUGE_SIZE} height={GAUGE_SIZE} style={{ transform: [{ rotate: '-90deg' }] }}>
|
||||
<Circle
|
||||
cx={GAUGE_SIZE / 2}
|
||||
cy={GAUGE_SIZE / 2}
|
||||
r={RADIUS_PX}
|
||||
stroke={accent}
|
||||
strokeOpacity={0.12}
|
||||
strokeWidth={STROKE_WIDTH}
|
||||
fill="none"
|
||||
/>
|
||||
<AnimatedCircle
|
||||
cx={GAUGE_SIZE / 2}
|
||||
cy={GAUGE_SIZE / 2}
|
||||
r={RADIUS_PX}
|
||||
stroke={accent}
|
||||
strokeWidth={STROKE_WIDTH}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={`${CIRCUMFERENCE},${CIRCUMFERENCE}`}
|
||||
animatedProps={animatedCircleProps}
|
||||
fill="none"
|
||||
/>
|
||||
</Svg>
|
||||
<View style={styles.gaugeCenter}>
|
||||
<Text style={styles.gaugeScore}>{displayScore}</Text>
|
||||
{scoreLabel ? <Text style={styles.gaugeLabel}>{scoreLabel}</Text> : null}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* CONTENT — full-width below */}
|
||||
<View style={styles.content}>
|
||||
<View style={styles.eyebrowRow}>
|
||||
<View style={styles.eyebrowRule} />
|
||||
<Text style={styles.eyebrow} numberOfLines={1}>{eyebrow}</Text>
|
||||
<View style={styles.eyebrowRule} />
|
||||
</View>
|
||||
<View style={styles.categoryRow}>
|
||||
<View style={[styles.iconBox, { backgroundColor: withAlpha(accent, 0.18) }]}>
|
||||
<Icon size={18} color={accent} strokeWidth={2.2} />
|
||||
</View>
|
||||
<Text style={styles.category} numberOfLines={2} adjustsFontSizeToFit>
|
||||
{category}
|
||||
</Text>
|
||||
</View>
|
||||
{descriptor ? <Text style={styles.descriptor}>{descriptor}</Text> : null}
|
||||
{tldr ? <Text style={styles.tldr}>{tldr}</Text> : null}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
borderWidth: 1,
|
||||
borderRadius: RADIUS.xxl,
|
||||
paddingVertical: 28,
|
||||
paddingHorizontal: 24,
|
||||
overflow: 'hidden',
|
||||
backgroundColor: 'rgba(255,255,255,0.025)',
|
||||
marginBottom: SPACING.md,
|
||||
alignItems: 'center',
|
||||
},
|
||||
glow: {
|
||||
position: 'absolute',
|
||||
top: -120,
|
||||
right: -120,
|
||||
width: 280,
|
||||
height: 280,
|
||||
borderRadius: 999,
|
||||
opacity: 0.65,
|
||||
},
|
||||
gaugeWrap: {
|
||||
width: GAUGE_SIZE,
|
||||
height: GAUGE_SIZE,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginBottom: 22,
|
||||
},
|
||||
gaugeCenter: {
|
||||
position: 'absolute',
|
||||
top: 0, left: 0, right: 0, bottom: 0,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
gaugeScore: {
|
||||
fontSize: 44,
|
||||
fontWeight: FONT_WEIGHTS.semibold,
|
||||
color: '#fff',
|
||||
lineHeight: 46,
|
||||
letterSpacing: -1.2,
|
||||
},
|
||||
gaugeLabel: {
|
||||
marginTop: 4,
|
||||
fontSize: 9.5,
|
||||
letterSpacing: 1.2,
|
||||
color: 'rgba(255,255,255,0.32)',
|
||||
fontWeight: FONT_WEIGHTS.medium,
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
content: {
|
||||
width: '100%',
|
||||
alignItems: 'center',
|
||||
},
|
||||
eyebrowRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 12,
|
||||
gap: 10,
|
||||
width: '100%',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
eyebrowRule: {
|
||||
width: 18,
|
||||
height: 1,
|
||||
backgroundColor: 'rgba(255,255,255,0.28)',
|
||||
},
|
||||
eyebrow: {
|
||||
...EDITORIAL.eyebrow,
|
||||
flexShrink: 1,
|
||||
textAlign: 'center',
|
||||
},
|
||||
categoryRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
gap: 10,
|
||||
marginBottom: 8,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
iconBox: {
|
||||
width: 30,
|
||||
height: 30,
|
||||
borderRadius: 9,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
category: {
|
||||
...EDITORIAL.category,
|
||||
flexShrink: 1,
|
||||
textAlign: 'center',
|
||||
},
|
||||
descriptor: {
|
||||
...EDITORIAL.descriptor,
|
||||
textAlign: 'center',
|
||||
},
|
||||
tldr: {
|
||||
...EDITORIAL.tldr,
|
||||
marginTop: 14,
|
||||
textAlign: 'center',
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
import React from 'react';
|
||||
import { View, Text, StyleSheet, ScrollView } from 'react-native';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { SPACING, RADIUS, FONT_WEIGHTS } from '../../theme/colors';
|
||||
|
||||
interface Props {
|
||||
inputType: string;
|
||||
text: string | null | undefined;
|
||||
}
|
||||
|
||||
function toParagraphs(raw: string): string[] {
|
||||
return raw
|
||||
.split(/\n{2,}/)
|
||||
.map(p => p.replace(/\s*\n\s*/g, ' ').trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export default function InputPreview({ inputType, text }: Props) {
|
||||
const { t } = useTranslation();
|
||||
if (!text) return null;
|
||||
const paragraphs = toParagraphs(text);
|
||||
return (
|
||||
<View style={styles.block}>
|
||||
<Text style={styles.label}>{t('inputLabel', { type: inputType })}</Text>
|
||||
<ScrollView style={styles.scroll} nestedScrollEnabled>
|
||||
{paragraphs.map((p, i) => (
|
||||
<Text key={i} style={[styles.body, i > 0 && { marginTop: 10 }]}>{p}</Text>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
block: {
|
||||
paddingVertical: 14,
|
||||
paddingHorizontal: 18,
|
||||
backgroundColor: 'rgba(255,255,255,0.018)',
|
||||
borderRadius: RADIUS.xl,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255,255,255,0.04)',
|
||||
marginBottom: SPACING.md,
|
||||
},
|
||||
label: {
|
||||
fontSize: 10.5,
|
||||
letterSpacing: 1.6,
|
||||
fontWeight: FONT_WEIGHTS.semibold,
|
||||
color: 'rgba(255,255,255,0.45)',
|
||||
textTransform: 'uppercase',
|
||||
marginBottom: 6,
|
||||
},
|
||||
scroll: {
|
||||
maxHeight: 220,
|
||||
},
|
||||
body: {
|
||||
fontSize: 13,
|
||||
lineHeight: 21,
|
||||
color: 'rgba(255,255,255,0.7)',
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import { View, Text, StyleSheet } from 'react-native';
|
||||
import Animated, { useSharedValue, useAnimatedStyle, withDelay, withTiming, Easing } from 'react-native-reanimated';
|
||||
import { FONT_WEIGHTS } from '../../theme/colors';
|
||||
|
||||
interface Props {
|
||||
name: string;
|
||||
score: number;
|
||||
/** Hex color for the bar fill. */
|
||||
color: string;
|
||||
/** Stagger delay (ms) for the bar animation. */
|
||||
delay?: number;
|
||||
}
|
||||
|
||||
export default function ScoreLine({ name, score, color, delay = 0 }: Props) {
|
||||
const width = useSharedValue(0);
|
||||
|
||||
useEffect(() => {
|
||||
width.value = withDelay(delay, withTiming(Math.min(score, 100), {
|
||||
duration: 900,
|
||||
easing: Easing.out(Easing.cubic),
|
||||
}));
|
||||
}, [score, delay, width]);
|
||||
|
||||
const fillStyle = useAnimatedStyle(() => ({
|
||||
width: `${width.value}%`,
|
||||
}));
|
||||
|
||||
return (
|
||||
<View style={styles.row}>
|
||||
<Text style={styles.name}>{name}</Text>
|
||||
<View style={styles.barBg}>
|
||||
<Animated.View style={[styles.barFill, { backgroundColor: color }, fillStyle]} />
|
||||
</View>
|
||||
<Text style={styles.value}>{score}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
interface BlockProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function ScoresBlock({ children }: BlockProps) {
|
||||
return <View style={styles.block}>{children}</View>;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
block: {
|
||||
gap: 10,
|
||||
},
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
},
|
||||
name: {
|
||||
fontSize: 13,
|
||||
color: 'rgba(255,255,255,0.7)',
|
||||
width: 100,
|
||||
fontWeight: FONT_WEIGHTS.regular,
|
||||
letterSpacing: -0.05,
|
||||
},
|
||||
barBg: {
|
||||
flex: 1,
|
||||
height: 4,
|
||||
backgroundColor: 'rgba(255,255,255,0.06)',
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
barFill: {
|
||||
height: '100%',
|
||||
borderRadius: 2,
|
||||
},
|
||||
value: {
|
||||
fontSize: 13,
|
||||
fontWeight: FONT_WEIGHTS.medium,
|
||||
color: 'rgba(255,255,255,0.85)',
|
||||
width: 40,
|
||||
textAlign: 'right',
|
||||
letterSpacing: -0.1,
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
import React from 'react';
|
||||
import { View, Text, StyleSheet } from 'react-native';
|
||||
import { EDITORIAL, SPACING } from '../../theme/colors';
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
count?: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function SectionDivider({ label, count }: Props) {
|
||||
return (
|
||||
<View style={styles.row}>
|
||||
<Text style={styles.label}>{label}</Text>
|
||||
<View style={styles.line} />
|
||||
{count != null ? <Text style={styles.count}>{count}</Text> : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 4,
|
||||
marginTop: SPACING.sm,
|
||||
},
|
||||
label: EDITORIAL.dividerLabel,
|
||||
line: {
|
||||
flex: 1,
|
||||
height: 1,
|
||||
backgroundColor: 'rgba(255,255,255,0.08)',
|
||||
},
|
||||
count: EDITORIAL.dividerCount,
|
||||
});
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
import React from 'react';
|
||||
import { View, StyleSheet } from 'react-native';
|
||||
import { SPACING, RADIUS } from '../../theme/colors';
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/** A subtle container holding chips and/or scores, used right under the HeadlineCard. */
|
||||
export default function StatBlock({ children }: Props) {
|
||||
return <View style={styles.block}>{children}</View>;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
block: {
|
||||
paddingVertical: 18,
|
||||
paddingHorizontal: 20,
|
||||
backgroundColor: 'rgba(255,255,255,0.018)',
|
||||
borderRadius: RADIUS.xl,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255,255,255,0.04)',
|
||||
gap: 14,
|
||||
marginBottom: SPACING.sm + 4,
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
export { default as HeadlineCard } from './HeadlineCard';
|
||||
export { default as FindingCard } from './FindingCard';
|
||||
export { default as SectionDivider } from './SectionDivider';
|
||||
export { default as ActionCallout } from './ActionCallout';
|
||||
export { default as InputPreview } from './InputPreview';
|
||||
export { default as CollapseButton } from './CollapseButton';
|
||||
export { default as StatBlock } from './StatBlock';
|
||||
export { default as ScoreLine, ScoresBlock } from './ScoreLine';
|
||||
export { Chip, ChipsRow } from './Chip';
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { View, Text, StyleSheet, Animated } from 'react-native';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import {
|
||||
COLORS,
|
||||
SPACING,
|
||||
RADIUS,
|
||||
FONT_SIZES,
|
||||
FONT_WEIGHTS,
|
||||
LAYOUT,
|
||||
} from '../../theme/colors';
|
||||
import type { TechniqueDefinition } from '../../types/analysis';
|
||||
import { formatTechniqueName } from '../../utils/formatTechniqueName';
|
||||
|
||||
interface Props {
|
||||
definitions: TechniqueDefinition[];
|
||||
}
|
||||
|
||||
const CYCLE_INTERVAL = 7000;
|
||||
|
||||
export default function DidYouKnowCard({ definitions }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const [index, setIndex] = useState(() =>
|
||||
definitions.length > 0 ? Math.floor(Math.random() * definitions.length) : 0
|
||||
);
|
||||
const fadeAnim = useRef(new Animated.Value(1)).current;
|
||||
|
||||
useEffect(() => {
|
||||
if (definitions.length <= 1) return;
|
||||
|
||||
const timer = setInterval(() => {
|
||||
// Fade out
|
||||
Animated.timing(fadeAnim, {
|
||||
toValue: 0,
|
||||
duration: 300,
|
||||
useNativeDriver: true,
|
||||
}).start(() => {
|
||||
// Pick a new random index (different from current)
|
||||
setIndex((prev) => {
|
||||
let next = Math.floor(Math.random() * definitions.length);
|
||||
if (next === prev && definitions.length > 1) {
|
||||
next = (next + 1) % definitions.length;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
// Fade in
|
||||
Animated.timing(fadeAnim, {
|
||||
toValue: 1,
|
||||
duration: 300,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
});
|
||||
}, CYCLE_INTERVAL);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, [definitions.length, fadeAnim]);
|
||||
|
||||
if (definitions.length === 0) return null;
|
||||
|
||||
const def = definitions[index];
|
||||
if (!def) return null;
|
||||
|
||||
return (
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.title}>{t('didYouKnow')}</Text>
|
||||
<Animated.View style={{ opacity: fadeAnim }}>
|
||||
<Text style={styles.techniqueName}>
|
||||
{formatTechniqueName(def.technique_name)}
|
||||
</Text>
|
||||
<Text style={styles.dimensionText}>
|
||||
{def.dimension} — {def.subdimension}
|
||||
</Text>
|
||||
<Text style={styles.description} numberOfLines={4}>
|
||||
{def.description_en}
|
||||
</Text>
|
||||
</Animated.View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
backgroundColor: COLORS.glass.purple10,
|
||||
borderWidth: 1,
|
||||
borderColor: COLORS.glass.purple25,
|
||||
borderRadius: RADIUS.xxl,
|
||||
padding: LAYOUT.CARD_PADDING,
|
||||
marginBottom: SPACING.md,
|
||||
},
|
||||
title: {
|
||||
color: COLORS.brand.primaryLight,
|
||||
fontSize: FONT_SIZES.sm,
|
||||
fontWeight: FONT_WEIGHTS.bold,
|
||||
marginBottom: SPACING.sm,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 1,
|
||||
},
|
||||
techniqueName: {
|
||||
color: COLORS.text.primary,
|
||||
fontSize: FONT_SIZES.base,
|
||||
fontWeight: FONT_WEIGHTS.bold,
|
||||
marginBottom: SPACING.xs,
|
||||
},
|
||||
dimensionText: {
|
||||
color: COLORS.brand.primaryLight,
|
||||
fontSize: FONT_SIZES.xs,
|
||||
marginBottom: SPACING.sm,
|
||||
},
|
||||
description: {
|
||||
color: COLORS.text.secondary,
|
||||
fontSize: FONT_SIZES.sm,
|
||||
lineHeight: 20,
|
||||
},
|
||||
});
|
||||
280
android/didi-app-source/src/components/sections/AiResults.tsx
Normal file
|
|
@ -0,0 +1,280 @@
|
|||
import React, { useState } from 'react';
|
||||
import { View } from 'react-native';
|
||||
import {
|
||||
Cpu, ShieldCheck, ShieldAlert, AlertOctagon, AlertTriangle, AlertCircle,
|
||||
FileText, Image as ImageIcon, Sparkles, Plus,
|
||||
} from 'lucide-react-native';
|
||||
import type { LucideIcon } from 'lucide-react-native';
|
||||
import {
|
||||
HeadlineCard, FindingCard, SectionDivider, StatBlock, ChipsRow, Chip, CollapseButton,
|
||||
} from '../editorial';
|
||||
import type { FindingAccent } from '../../theme/colors';
|
||||
import type { V3AiTamperedResult, V3AiIndicator } from '../../types/analysis';
|
||||
import { localized, useTranslation, enumLabel } from '../../i18n';
|
||||
|
||||
const DEFAULT_VISIBLE_INDICATORS = 5;
|
||||
|
||||
const probAccent = (p: number) =>
|
||||
p >= 80 ? '#ef4444' : p >= 60 ? '#f97316' : p >= 40 ? '#eab308' : '#22c55e';
|
||||
|
||||
const probIcon = (p: number): LucideIcon => {
|
||||
if (p >= 80) return AlertOctagon;
|
||||
if (p >= 60) return AlertTriangle;
|
||||
if (p >= 40) return Cpu;
|
||||
return ShieldCheck;
|
||||
};
|
||||
|
||||
const indicatorAccent = (confidence: number): FindingAccent => {
|
||||
if (confidence >= 80) return 'critical';
|
||||
if (confidence >= 60) return 'warning';
|
||||
if (confidence >= 40) return 'info';
|
||||
return 'neutral';
|
||||
};
|
||||
|
||||
const VERDICT_LABELS: Record<string, { ro: string; en: string }> = {
|
||||
LIKELY_AI: { ro: 'Probabil AI', en: 'Likely AI' },
|
||||
POSSIBLY_AI: { ro: 'Posibil AI', en: 'Possibly AI' },
|
||||
UNLIKELY_AI: { ro: 'Improbabil AI', en: 'Unlikely AI' },
|
||||
LIKELY_HUMAN: { ro: 'Probabil uman', en: 'Likely human' },
|
||||
HUMAN: { ro: 'Uman', en: 'Human' },
|
||||
MIXED: { ro: 'Mixt', en: 'Mixed' },
|
||||
};
|
||||
|
||||
const CATEGORY_LABELS: Record<string, { ro: string; en: string }> = {
|
||||
T1: { ro: 'Stil de scriere', en: 'Writing style' },
|
||||
T2: { ro: 'Tipare conținut', en: 'Content patterns' },
|
||||
T3: { ro: 'Analiză structurală', en: 'Structural analysis' },
|
||||
T4: { ro: 'Markeri statistici', en: 'Statistical markers' },
|
||||
T5: { ro: 'Semnale explicite', en: 'Explicit signals' },
|
||||
};
|
||||
|
||||
export interface AiDisplayResult {
|
||||
verdict: string;
|
||||
ai_probability: number;
|
||||
disclosure_detected?: boolean;
|
||||
disclosure_explicit?: boolean;
|
||||
disclosure_text?: string | null;
|
||||
indicators_found?: string[];
|
||||
indicators_detected?: V3AiIndicator[];
|
||||
categories_affected?: string[];
|
||||
coupling_context?: V3AiTamperedResult['coupling_context'];
|
||||
image_indicators?: string[];
|
||||
image_evidence?: string;
|
||||
model_used?: string;
|
||||
transcript?: string;
|
||||
}
|
||||
|
||||
export function toAiDisplayResult(
|
||||
r: V3AiTamperedResult,
|
||||
ctx: { inputType?: string; inputText?: string | null } = {},
|
||||
): AiDisplayResult {
|
||||
const out: AiDisplayResult = {
|
||||
verdict: r.verdict,
|
||||
ai_probability: r.ai_probability,
|
||||
disclosure_detected: r.disclosure_detected,
|
||||
disclosure_explicit: r.disclosure_explicit,
|
||||
disclosure_text: r.disclosure_text,
|
||||
};
|
||||
if (r.indicators_detected && r.indicators_detected.length > 0) {
|
||||
out.indicators_detected = r.indicators_detected;
|
||||
out.categories_affected = r.categories_affected;
|
||||
out.coupling_context = r.coupling_context;
|
||||
}
|
||||
const img = r.image_analysis as any;
|
||||
if (img?.indicators && img.indicators.length > 0) {
|
||||
out.image_indicators = img.indicators;
|
||||
out.image_evidence = img.evidence;
|
||||
out.model_used = img.model_used;
|
||||
}
|
||||
if (ctx.inputText && (ctx.inputType === 'audio' || ctx.inputType === 'video')) {
|
||||
out.transcript = ctx.inputText;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
result: AiDisplayResult;
|
||||
}
|
||||
|
||||
export default function AiResults({ result }: Props) {
|
||||
const { t, language } = useTranslation();
|
||||
const isRo = language === 'ro';
|
||||
const [indicatorsExpand, setIndicatorsExpand] = useState(false);
|
||||
|
||||
const probability = result.ai_probability || 0;
|
||||
const verdictKey = (result.verdict || '').toUpperCase();
|
||||
const verdictText = VERDICT_LABELS[verdictKey]?.[isRo ? 'ro' : 'en']
|
||||
|| enumLabel(result.verdict || '');
|
||||
const accent = probAccent(probability);
|
||||
const Icon = probIcon(probability);
|
||||
|
||||
const indCount = result.indicators_detected?.length
|
||||
|| result.indicators_found?.length
|
||||
|| result.image_indicators?.length
|
||||
|| 0;
|
||||
const disclosureNote = result.disclosure_detected === true
|
||||
? (isRo ? ' cu declarație AI' : ' with AI disclosure')
|
||||
: result.disclosure_detected === false
|
||||
? (isRo ? ' fără declarație AI' : ' without AI disclosure')
|
||||
: '';
|
||||
const tldr = isRo
|
||||
? `Probabilitate ${probability}% de generare AI${disclosureNote}${indCount > 0 ? ` · ${indCount} ${indCount === 1 ? 'indicator detectat' : 'indicatori detectați'}` : ''}.`
|
||||
: `${probability}% likelihood of AI generation${disclosureNote}${indCount > 0 ? ` · ${indCount} ${indCount === 1 ? 'indicator detected' : 'indicators detected'}` : ''}.`;
|
||||
|
||||
const totalIndicators = result.indicators_detected?.length ?? 0;
|
||||
const visibleCount = indicatorsExpand ? totalIndicators : Math.min(DEFAULT_VISIBLE_INDICATORS, totalIndicators);
|
||||
const visibleIndicators = result.indicators_detected?.slice(0, visibleCount) ?? [];
|
||||
const remainingIndicators = totalIndicators - visibleCount;
|
||||
|
||||
const descriptorBits: string[] = [];
|
||||
const confidence = (result.coupling_context as any)?.for_verdict?.confidence_level;
|
||||
if (confidence) descriptorBits.push(`${isRo ? 'încredere' : 'confidence'} ${enumLabel(confidence).toLowerCase()}`);
|
||||
if (result.disclosure_detected === true) descriptorBits.push(isRo ? 'cu declarație AI' : 'with AI disclosure');
|
||||
if (result.disclosure_detected === false) descriptorBits.push(isRo ? 'fără declarație AI' : 'no AI disclosure');
|
||||
|
||||
return (
|
||||
<View>
|
||||
<HeadlineCard
|
||||
accent={accent}
|
||||
score={probability}
|
||||
scoreLabel="% AI"
|
||||
eyebrow={isRo ? 'Detectare AI' : 'AI detection'}
|
||||
icon={Icon}
|
||||
category={verdictText}
|
||||
descriptor={descriptorBits.length ? descriptorBits.join(' · ') : undefined}
|
||||
tldr={tldr}
|
||||
/>
|
||||
|
||||
{(result.coupling_context || (result.categories_affected && result.categories_affected.length > 0) || result.model_used) ? (
|
||||
<StatBlock>
|
||||
<ChipsRow>
|
||||
{(result.coupling_context as any)?.for_verdict?.undisclosed_ai && (
|
||||
<Chip variant="warning">{isRo ? 'Conținut AI nedeclarat' : 'Undisclosed AI'}</Chip>
|
||||
)}
|
||||
{(result.coupling_context as any)?.for_verdict?.needs_manual_review && (
|
||||
<Chip variant="warning">{isRo ? 'Necesită verificare manuală' : 'Needs manual review'}</Chip>
|
||||
)}
|
||||
{(result.categories_affected ?? []).map(cat => {
|
||||
const cl = CATEGORY_LABELS[cat]?.[isRo ? 'ro' : 'en'] || cat;
|
||||
return <Chip key={cat} variant="violet">{cat} · {cl}</Chip>;
|
||||
})}
|
||||
{result.model_used ? <Chip variant="neutral">{result.model_used}</Chip> : null}
|
||||
</ChipsRow>
|
||||
</StatBlock>
|
||||
) : null}
|
||||
|
||||
{result.disclosure_detected !== undefined && (
|
||||
<FindingCard
|
||||
icon={result.disclosure_detected ? ShieldCheck : ShieldAlert}
|
||||
accent={result.disclosure_detected ? 'success' : 'neutral'}
|
||||
eyebrow={isRo ? 'Declarație AI' : 'AI disclosure'}
|
||||
headline={
|
||||
result.disclosure_detected
|
||||
? (isRo
|
||||
? (result.disclosure_explicit ? 'Declarație AI explicită' : 'Declarație AI implicită')
|
||||
: (result.disclosure_explicit ? 'Explicit AI disclosure' : 'Implicit AI disclosure'))
|
||||
: (isRo ? 'Nicio declarație de generare AI detectată' : 'No AI disclosure detected')
|
||||
}
|
||||
quote={result.disclosure_text || undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
{result.image_evidence && (
|
||||
<FindingCard
|
||||
icon={ImageIcon}
|
||||
accent="info"
|
||||
eyebrow={isRo ? 'Analiză imagine' : 'Image analysis'}
|
||||
headline={isRo ? 'Sumar analiză' : 'Analysis summary'}
|
||||
quote={result.image_evidence}
|
||||
/>
|
||||
)}
|
||||
|
||||
{result.transcript && (
|
||||
<FindingCard
|
||||
icon={FileText}
|
||||
accent="info"
|
||||
eyebrow={isRo ? 'Transcript' : 'Transcript'}
|
||||
headline={isRo ? 'Conținut transcris' : 'Transcribed content'}
|
||||
quote={result.transcript.length > 320 ? result.transcript.slice(0, 320) + '…' : result.transcript}
|
||||
/>
|
||||
)}
|
||||
|
||||
{result.indicators_found && result.indicators_found.length > 0 && (
|
||||
<>
|
||||
<SectionDivider
|
||||
label={isRo ? 'Semnale detectate' : 'Signals detected'}
|
||||
count={result.indicators_found.length}
|
||||
/>
|
||||
{result.indicators_found.map((ind, i) => (
|
||||
<FindingCard key={i} icon={AlertCircle} accent="info" headline={ind} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{result.image_indicators && result.image_indicators.length > 0 && (
|
||||
<>
|
||||
<SectionDivider
|
||||
label={isRo ? 'Indicatori imagine' : 'Image indicators'}
|
||||
count={result.image_indicators.length}
|
||||
/>
|
||||
{result.image_indicators.map((ind, i) => (
|
||||
<FindingCard key={i} icon={Sparkles} accent="warning" headline={ind} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{totalIndicators > 0 && (
|
||||
<>
|
||||
<SectionDivider
|
||||
label={isRo ? 'Indicatori detectați' : 'Indicators detected'}
|
||||
count={`${totalIndicators} ${isRo ? 'detectați' : 'detected'}${result.categories_affected?.length ? ` · ${result.categories_affected.join(' · ')}` : ''}`}
|
||||
/>
|
||||
{visibleIndicators.map(ind => {
|
||||
const catLabel = CATEGORY_LABELS[ind.category]?.[isRo ? 'ro' : 'en'] || ind.category;
|
||||
const eyebrowParts = [ind.category, catLabel, `${isRo ? 'încredere' : 'confidence'} ${ind.confidence}%`];
|
||||
return (
|
||||
<FindingCard
|
||||
key={String(ind.id)}
|
||||
icon={AlertCircle}
|
||||
accent={indicatorAccent(ind.confidence)}
|
||||
eyebrow={eyebrowParts.join(' · ')}
|
||||
headline={localized(ind, 'indicator_name', 'name')}
|
||||
quote={ind.evidence}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{remainingIndicators > 0 && (
|
||||
<CollapseButton
|
||||
onPress={() => setIndicatorsExpand(true)}
|
||||
expanded={false}
|
||||
leading={<Plus size={16} color="rgba(255,255,255,0.6)" strokeWidth={2} />}
|
||||
label={
|
||||
isRo
|
||||
? `Vezi celelalți ${remainingIndicators} ${remainingIndicators === 1 ? 'indicator' : 'indicatori'}`
|
||||
: `Show ${remainingIndicators} more ${remainingIndicators === 1 ? 'indicator' : 'indicators'}`
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{indicatorsExpand && totalIndicators > DEFAULT_VISIBLE_INDICATORS && (
|
||||
<CollapseButton
|
||||
onPress={() => setIndicatorsExpand(false)}
|
||||
expanded={true}
|
||||
label={isRo ? 'Ascunde' : 'Hide'}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{(result.indicators_found?.length === 0
|
||||
&& !result.indicators_detected
|
||||
&& !result.image_indicators?.length) && (
|
||||
<FindingCard
|
||||
icon={ShieldCheck}
|
||||
accent="success"
|
||||
eyebrow={isRo ? 'Conținut curat' : 'Clean content'}
|
||||
headline={isRo ? 'Niciun semnal de generare AI detectat' : 'No AI signals detected'}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,219 @@
|
|||
import React, { useState } from 'react';
|
||||
import { View, Text, StyleSheet, Linking, TouchableOpacity } from 'react-native';
|
||||
import {
|
||||
CheckSquare, XCircle, AlertTriangle, HelpCircle, Search, Timer, CheckCircle2, Plus,
|
||||
} from 'lucide-react-native';
|
||||
import type { LucideIcon } from 'lucide-react-native';
|
||||
import {
|
||||
HeadlineCard, FindingCard, SectionDivider, StatBlock, ChipsRow, Chip, CollapseButton,
|
||||
} from '../editorial';
|
||||
import type { FindingAccent } from '../../theme/colors';
|
||||
import type { V3ClaimsResult, V3Claim } from '../../types/analysis';
|
||||
import { localized, useTranslation, enumLabel, smoothEnums } from '../../i18n';
|
||||
|
||||
const DEFAULT_VISIBLE = 4;
|
||||
|
||||
const credAccent = (s: number) =>
|
||||
s >= 60 ? '#22c55e' : s >= 40 ? '#eab308' : s >= 20 ? '#f97316' : '#ef4444';
|
||||
|
||||
const credIcon = (s: number, total: number): LucideIcon => {
|
||||
if (total === 0) return HelpCircle;
|
||||
if (s >= 60) return CheckSquare;
|
||||
if (s >= 40) return HelpCircle;
|
||||
if (s >= 20) return AlertTriangle;
|
||||
return XCircle;
|
||||
};
|
||||
|
||||
const claimIcon = (status: string, color?: string): LucideIcon => {
|
||||
if (status === 'verified_false' || color === 'red') return XCircle;
|
||||
if (status === 'verified_true' || color === 'green') return CheckCircle2;
|
||||
return HelpCircle;
|
||||
};
|
||||
|
||||
const claimAccent = (status: string, color?: string): FindingAccent => {
|
||||
if (status === 'verified_false' || color === 'red') return 'critical';
|
||||
if (status === 'verified_true' || color === 'green') return 'success';
|
||||
return 'neutral';
|
||||
};
|
||||
|
||||
const stanceFromString = (s: string): 'contradicts' | 'supports' | 'neutral' => {
|
||||
const u = (s || '').toUpperCase();
|
||||
if (u === 'CONTRADICTS') return 'contradicts';
|
||||
if (u === 'SUPPORTS') return 'supports';
|
||||
return 'neutral';
|
||||
};
|
||||
|
||||
const sourceHostname = (url: string): string =>
|
||||
(url || '').replace(/^https?:\/\/(www\.)?/, '').split('/')[0];
|
||||
|
||||
const buildTldr = (r: V3ClaimsResult, percent: number, isRo: boolean): string => {
|
||||
const total = r.total_claims || r.claims_verified?.length || 0;
|
||||
if (total === 0) {
|
||||
return isRo
|
||||
? 'Nicio afirmație verificabilă în acest conținut.'
|
||||
: 'No verifiable claims found in this content.';
|
||||
}
|
||||
const parts: string[] = [];
|
||||
if (r.verified_true > 0) parts.push(`${r.verified_true} ${isRo ? 'adevărate' : 'true'}`);
|
||||
if (r.verified_false > 0) parts.push(`${r.verified_false} ${isRo ? 'false' : 'false'}`);
|
||||
if (r.unverified > 0) parts.push(`${r.unverified} ${isRo ? 'neverificate' : 'unverified'}`);
|
||||
if (r.opinions > 0) parts.push(`${r.opinions} ${isRo ? 'opinii' : 'opinions'}`);
|
||||
return isRo
|
||||
? `${total} ${total === 1 ? 'afirmație' : 'afirmații'} analizate (${parts.join(', ')}). Scor: ${percent}%.`
|
||||
: `${total} ${total === 1 ? 'claim' : 'claims'} analyzed (${parts.join(', ')}). Score: ${percent}%.`;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
result: V3ClaimsResult;
|
||||
}
|
||||
|
||||
export default function ClaimsResults({ result }: Props) {
|
||||
const { language } = useTranslation();
|
||||
const isRo = language === 'ro';
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const rawCred = parseFloat(String(result.credibility_score)) || 0;
|
||||
const credPercent = Math.round(rawCred > 1 ? rawCred : rawCred * 100);
|
||||
const accent = credAccent(credPercent);
|
||||
const Icon = credIcon(credPercent, result.total_claims);
|
||||
|
||||
const claims = result.claims_verified ?? [];
|
||||
const visible = expanded ? claims.length : Math.min(DEFAULT_VISIBLE, claims.length);
|
||||
const shown = claims.slice(0, visible);
|
||||
const remaining = claims.length - visible;
|
||||
|
||||
const summaryParts = [
|
||||
`${result.total_claims} ${isRo ? 'total' : 'total'}`,
|
||||
result.verified_true > 0 ? `${result.verified_true} ${isRo ? 'adevărate' : 'true'}` : null,
|
||||
result.verified_false > 0 ? `${result.verified_false} ${isRo ? 'false' : 'false'}` : null,
|
||||
result.unverified > 0 ? `${result.unverified} ${isRo ? 'neverificate' : 'unverified'}` : null,
|
||||
].filter(Boolean).join(' · ');
|
||||
|
||||
return (
|
||||
<View>
|
||||
<HeadlineCard
|
||||
accent={accent}
|
||||
score={credPercent}
|
||||
scoreLabel={isRo ? '% CREDIBIL' : '% CREDIBLE'}
|
||||
eyebrow={isRo ? 'Verificarea afirmațiilor' : 'Claim verification'}
|
||||
icon={Icon}
|
||||
category={smoothEnums(result.interpretation) || (isRo ? 'Analiză afirmații' : 'Claim analysis')}
|
||||
descriptor={`${result.total_claims} ${isRo ? (result.total_claims === 1 ? 'afirmație' : 'afirmații') : (result.total_claims === 1 ? 'claim' : 'claims')}`}
|
||||
tldr={buildTldr(result, credPercent, isRo)}
|
||||
/>
|
||||
|
||||
{result.total_claims > 0 && (
|
||||
<StatBlock>
|
||||
<ChipsRow>
|
||||
{result.verified_true > 0 && (
|
||||
<Chip variant="success">{result.verified_true} {isRo ? 'adevărate' : 'verified true'}</Chip>
|
||||
)}
|
||||
{result.verified_false > 0 && (
|
||||
<Chip variant="critical">{result.verified_false} {isRo ? 'false' : 'verified false'}</Chip>
|
||||
)}
|
||||
{result.unverified > 0 && (
|
||||
<Chip variant="neutral">{result.unverified} {isRo ? 'neverificate' : 'unverified'}</Chip>
|
||||
)}
|
||||
{result.opinions > 0 && (
|
||||
<Chip variant="info">{result.opinions} {isRo ? 'opinii' : 'opinions'}</Chip>
|
||||
)}
|
||||
{result.web_searches_made > 0 && (
|
||||
<Chip variant="violet" leading={<Search size={12} color="#a78bfa" strokeWidth={2} />}>
|
||||
{result.web_searches_made} {isRo ? 'căutări web' : 'web searches'}
|
||||
</Chip>
|
||||
)}
|
||||
{result.total_duration_ms != null && result.total_duration_ms > 0 && (
|
||||
<Chip variant="neutral" leading={<Timer size={12} color="#94a3b8" strokeWidth={2} />}>
|
||||
{(result.total_duration_ms / 1000).toFixed(1)}s
|
||||
</Chip>
|
||||
)}
|
||||
</ChipsRow>
|
||||
</StatBlock>
|
||||
)}
|
||||
|
||||
{claims.length > 0 && (
|
||||
<>
|
||||
<SectionDivider
|
||||
label={isRo ? 'Verificarea afirmațiilor' : 'Claim verification'}
|
||||
count={summaryParts}
|
||||
/>
|
||||
{shown.map((claim: V3Claim, i) => {
|
||||
const Icon2 = claimIcon(claim.status, claim.status_color);
|
||||
const accent2 = claimAccent(claim.status, claim.status_color);
|
||||
const statusName = localized(claim, 'status_name') || enumLabel(claim.status || '');
|
||||
const typeName = (claim as any).type_name || enumLabel(claim.type || '');
|
||||
const eyebrowParts = [
|
||||
statusName,
|
||||
typeName,
|
||||
(claim as any).priority ? `${isRo ? 'prioritate' : 'priority'} ${(claim as any).priority}` : null,
|
||||
].filter(Boolean) as string[];
|
||||
const sources = (claim.sources ?? []).slice(0, 6);
|
||||
return (
|
||||
<FindingCard
|
||||
key={(claim as any).id || i}
|
||||
icon={Icon2}
|
||||
accent={accent2}
|
||||
eyebrow={eyebrowParts.join(' · ')}
|
||||
headline={smoothEnums(claim.text)}
|
||||
quote={smoothEnums(claim.reasoning) || undefined}
|
||||
source={
|
||||
sources.length > 0 ? (
|
||||
<View style={s.sourceRow}>
|
||||
{(() => {
|
||||
const dom = stanceFromString(sources[0]?.stance);
|
||||
const stanceText = isRo
|
||||
? (dom === 'contradicts' ? 'Contrazice' : dom === 'supports' ? 'Susține' : 'Neutru')
|
||||
: (dom === 'contradicts' ? 'Contradicts' : dom === 'supports' ? 'Supports' : 'Neutral');
|
||||
const stanceColor = dom === 'contradicts' ? '#ef4444' : dom === 'supports' ? '#22c55e' : '#94a3b8';
|
||||
return (
|
||||
<View style={[s.stancePill, { backgroundColor: `${stanceColor}22` }]}>
|
||||
<Text style={[s.stanceText, { color: stanceColor }]}>{stanceText}</Text>
|
||||
</View>
|
||||
);
|
||||
})()}
|
||||
{sources.map((src, idx) => (
|
||||
<React.Fragment key={idx}>
|
||||
{idx > 0 && <Text style={s.sep}>·</Text>}
|
||||
<TouchableOpacity onPress={() => Linking.openURL(src.url)}>
|
||||
<Text style={s.link}>{sourceHostname(src.url)}</Text>
|
||||
</TouchableOpacity>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</View>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{remaining > 0 && (
|
||||
<CollapseButton
|
||||
onPress={() => setExpanded(true)}
|
||||
expanded={false}
|
||||
leading={<Plus size={16} color="rgba(255,255,255,0.6)" strokeWidth={2} />}
|
||||
label={
|
||||
isRo
|
||||
? `Vezi celelalte ${remaining} ${remaining === 1 ? 'afirmație' : 'afirmații'}`
|
||||
: `Show ${remaining} more ${remaining === 1 ? 'claim' : 'claims'}`
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{expanded && claims.length > DEFAULT_VISIBLE && (
|
||||
<CollapseButton
|
||||
onPress={() => setExpanded(false)}
|
||||
expanded={true}
|
||||
label={isRo ? 'Ascunde' : 'Hide'}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const s = StyleSheet.create({
|
||||
sourceRow: { flexDirection: 'row', flexWrap: 'wrap', alignItems: 'center', gap: 6 },
|
||||
stancePill: { paddingHorizontal: 7, paddingVertical: 2, borderRadius: 4 },
|
||||
stanceText: { fontSize: 9.5, fontWeight: '700', letterSpacing: 0.5, textTransform: 'uppercase' },
|
||||
sep: { color: 'rgba(255,255,255,0.25)', fontSize: 12 },
|
||||
link: { color: '#a78bfa', fontSize: 12.5, fontWeight: '500' },
|
||||
});
|
||||
|
|
@ -0,0 +1,158 @@
|
|||
import React from 'react';
|
||||
import { View, Text, StyleSheet } from 'react-native';
|
||||
import {
|
||||
ShieldCheck, ShieldAlert, AlertTriangle, AlertOctagon, AlertCircle,
|
||||
} from 'lucide-react-native';
|
||||
import type { LucideIcon } from 'lucide-react-native';
|
||||
import {
|
||||
HeadlineCard, SectionDivider, FindingCard,
|
||||
ScoreLine, ScoresBlock,
|
||||
} from '../editorial';
|
||||
import type { V3SourceAssessmentResult } from '../../types/analysis';
|
||||
import { useTranslation, enumLabel } from '../../i18n';
|
||||
import { ACCENT, FONT_WEIGHTS } from '../../theme/colors';
|
||||
|
||||
const trustAccent = (s: number) =>
|
||||
s >= 70 ? '#22c55e' : s >= 40 ? '#eab308' : s >= 20 ? '#f97316' : '#ef4444';
|
||||
|
||||
const trustIcon = (s: number): LucideIcon => {
|
||||
if (s >= 70) return ShieldCheck;
|
||||
if (s >= 40) return ShieldAlert;
|
||||
if (s >= 20) return AlertTriangle;
|
||||
return AlertOctagon;
|
||||
};
|
||||
|
||||
const barColor = (score: number) =>
|
||||
score >= 70 ? '#22c55e' : score >= 40 ? '#f97316' : '#ef4444';
|
||||
|
||||
const buildTldr = (r: V3SourceAssessmentResult, isRo: boolean): string => {
|
||||
const verdictTxt = enumLabel(r.verdict || '').toLowerCase();
|
||||
const riskTxt = enumLabel(r.risk_level || '').toLowerCase();
|
||||
if (isRo) {
|
||||
return `Sursă ${verdictTxt} cu scor de încredere ${r.trust_score}/100${riskTxt ? `, risc ${riskTxt}` : ''}.`;
|
||||
}
|
||||
return `${verdictTxt.charAt(0).toUpperCase() + verdictTxt.slice(1)} source with trust score ${r.trust_score}/100${riskTxt ? ` · ${riskTxt} risk` : ''}.`;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
result: V3SourceAssessmentResult;
|
||||
}
|
||||
|
||||
export default function SourceResults({ result: r }: Props) {
|
||||
const { language } = useTranslation();
|
||||
const isRo = language === 'ro';
|
||||
const accent = trustAccent(r.trust_score);
|
||||
const Icon = trustIcon(r.trust_score);
|
||||
|
||||
const verdictLabel = enumLabel(r.verdict || '');
|
||||
const pub = r.publication || ({} as any);
|
||||
const auth = r.author || ({} as any);
|
||||
const plat = r.platform || ({} as any);
|
||||
const dom = r.domain || ({} as any);
|
||||
|
||||
const axes = [
|
||||
{ name: isRo ? 'Publicație' : 'Publication', score: pub.score ?? 0 },
|
||||
{ name: isRo ? 'Autor' : 'Author', score: auth.score ?? 0 },
|
||||
{ name: isRo ? 'Platformă' : 'Platform', score: plat.score ?? 0 },
|
||||
{ name: isRo ? 'Domeniu' : 'Domain', score: dom.score ?? 0 },
|
||||
];
|
||||
|
||||
const flags: string[] = [];
|
||||
if (!pub.confirmed) flags.push(isRo ? 'Publicație neverificată' : 'Publication unverified');
|
||||
if (!auth.confirmed) flags.push(isRo ? 'Autor neidentificat' : 'Author unidentified');
|
||||
if (!dom.name || dom.name === 'N/A') flags.push(isRo ? 'Fără domeniu' : 'No domain');
|
||||
(r.red_flags ?? []).forEach(f => flags.push(f.replace(/_/g, ' ').toLowerCase()));
|
||||
|
||||
return (
|
||||
<View>
|
||||
<HeadlineCard
|
||||
accent={accent}
|
||||
score={r.trust_score}
|
||||
scoreLabel={isRo ? '/ 100 ÎNCREDERE' : '/ 100 TRUST'}
|
||||
eyebrow={isRo ? 'Evaluarea sursei' : 'Source assessment'}
|
||||
icon={Icon}
|
||||
category={verdictLabel}
|
||||
descriptor={
|
||||
[
|
||||
r.risk_level && `${isRo ? 'Risc' : 'Risk'} ${enumLabel(r.risk_level).toLowerCase()}`,
|
||||
pub.name ? pub.name : null,
|
||||
].filter(Boolean).join(' · ')
|
||||
}
|
||||
tldr={buildTldr(r, isRo)}
|
||||
/>
|
||||
|
||||
<SectionDivider
|
||||
label={isRo ? 'Evaluarea sursei' : 'Source assessment'}
|
||||
count={`${r.trust_score} / 100 · ${verdictLabel.toLowerCase()}`}
|
||||
/>
|
||||
|
||||
{/* Single big finding card with the 4 axes inside */}
|
||||
<FindingCard
|
||||
icon={Icon as any}
|
||||
accent={r.trust_score >= 70 ? 'success' : r.trust_score >= 40 ? 'warning' : 'critical'}
|
||||
eyebrow={verdictLabel}
|
||||
headline={
|
||||
isRo
|
||||
? `${verdictLabel} — încredere ${r.trust_score}/100${r.risk_level ? ` · risc ${enumLabel(r.risk_level).toLowerCase()}` : ''}`
|
||||
: `${verdictLabel} — trust ${r.trust_score}/100${r.risk_level ? ` · ${enumLabel(r.risk_level).toLowerCase()} risk` : ''}`
|
||||
}
|
||||
/>
|
||||
|
||||
<View style={s.scoresWrap}>
|
||||
<ScoresBlock>
|
||||
{axes.map((a, i) => (
|
||||
<ScoreLine
|
||||
key={a.name}
|
||||
name={a.name}
|
||||
score={a.score}
|
||||
color={barColor(a.score)}
|
||||
delay={400 + i * 60}
|
||||
/>
|
||||
))}
|
||||
</ScoresBlock>
|
||||
|
||||
{flags.length > 0 && (
|
||||
<View style={s.flagsRow}>
|
||||
<AlertCircle size={13} color="rgba(255,255,255,0.55)" strokeWidth={2} />
|
||||
{flags.map((f, idx) => (
|
||||
<React.Fragment key={idx}>
|
||||
{idx > 0 && <Text style={s.sep}>·</Text>}
|
||||
<Text style={s.flagText}>{f}</Text>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const s = StyleSheet.create({
|
||||
scoresWrap: {
|
||||
paddingHorizontal: 18,
|
||||
paddingVertical: 14,
|
||||
backgroundColor: 'rgba(255,255,255,0.018)',
|
||||
borderRadius: 16,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255,255,255,0.04)',
|
||||
gap: 14,
|
||||
marginTop: -4,
|
||||
marginBottom: 12,
|
||||
},
|
||||
flagsRow: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
marginTop: 6,
|
||||
},
|
||||
flagText: {
|
||||
fontSize: 12.5,
|
||||
color: 'rgba(255,255,255,0.55)',
|
||||
letterSpacing: -0.05,
|
||||
},
|
||||
sep: {
|
||||
color: 'rgba(255,255,255,0.25)',
|
||||
fontSize: 12,
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,183 @@
|
|||
import React, { useState } from 'react';
|
||||
import { View } from 'react-native';
|
||||
import {
|
||||
AlertOctagon, AlertTriangle, Info, ShieldCheck, AlertCircle, Cpu, Layers, Target, Plus,
|
||||
} from 'lucide-react-native';
|
||||
import type { LucideIcon } from 'lucide-react-native';
|
||||
import {
|
||||
HeadlineCard, FindingCard, SectionDivider, StatBlock, ChipsRow, Chip, CollapseButton,
|
||||
} from '../editorial';
|
||||
import type { FindingAccent } from '../../theme/colors';
|
||||
import type { V3TechniquesResult, TechniqueDefinition } from '../../types/analysis';
|
||||
import { formatTechniqueName } from '../../utils/formatTechniqueName';
|
||||
import { localized, useTranslation, smoothEnums } from '../../i18n';
|
||||
|
||||
const DEFAULT_VISIBLE = 5;
|
||||
|
||||
const scoreAccent = (s: number) =>
|
||||
s >= 70 ? '#ef4444' : s >= 40 ? '#f97316' : s >= 20 ? '#eab308' : '#22c55e';
|
||||
|
||||
const scoreIcon = (s: number): LucideIcon => {
|
||||
if (s >= 70) return AlertOctagon;
|
||||
if (s >= 40) return AlertTriangle;
|
||||
if (s >= 20) return Info;
|
||||
return ShieldCheck;
|
||||
};
|
||||
|
||||
const scoreCategory = (s: number, isRo: boolean): string => {
|
||||
if (s >= 70) return isRo ? 'Critic' : 'Critical';
|
||||
if (s >= 40) return isRo ? 'Ridicat' : 'High';
|
||||
if (s >= 20) return isRo ? 'Mediu' : 'Medium';
|
||||
return isRo ? 'Scăzut' : 'Low';
|
||||
};
|
||||
|
||||
const techIcon = (name: string): LucideIcon => {
|
||||
const dim = (name?.split('.')[0] || '').toUpperCase();
|
||||
if (dim === 'D3') return Cpu;
|
||||
if (dim === 'D5') return Layers;
|
||||
if (dim === 'D8') return Target;
|
||||
return AlertCircle;
|
||||
};
|
||||
|
||||
const techAccent = (severity: number): FindingAccent => {
|
||||
if (severity >= 70) return 'critical';
|
||||
if (severity >= 40) return 'warning';
|
||||
return 'info';
|
||||
};
|
||||
|
||||
const buildTldr = (r: V3TechniquesResult, isRo: boolean): string => {
|
||||
const techCount = r.techniques_detected?.length ?? 0;
|
||||
const dimCount = r.dimensions_affected?.length ?? 0;
|
||||
const dims = (r.dimensions_affected ?? []).join(' · ');
|
||||
if (techCount === 0) {
|
||||
return isRo
|
||||
? 'Nicio tehnică de manipulare detectată în acest conținut.'
|
||||
: 'No manipulation techniques detected in this content.';
|
||||
}
|
||||
return isRo
|
||||
? `${techCount} ${techCount === 1 ? 'tehnică' : 'tehnici'} de manipulare detectate în ${dimCount} ${dimCount === 1 ? 'dimensiune' : 'dimensiuni'}${dims ? ` (${dims})` : ''}.`
|
||||
: `${techCount} manipulation ${techCount === 1 ? 'technique' : 'techniques'} detected across ${dimCount} ${dimCount === 1 ? 'dimension' : 'dimensions'}${dims ? ` (${dims})` : ''}.`;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
result: V3TechniquesResult;
|
||||
defMap?: Map<string, TechniqueDefinition>;
|
||||
}
|
||||
|
||||
export default function TechniquesResults({ result, defMap }: Props) {
|
||||
const { language } = useTranslation();
|
||||
const isRo = language === 'ro';
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const score = result.manipulation_score ?? 0;
|
||||
const accent = scoreAccent(score);
|
||||
const Icon = scoreIcon(score);
|
||||
const techCount = result.techniques_detected?.length ?? 0;
|
||||
const dimCount = result.dimensions_affected?.length ?? 0;
|
||||
const techs = result.techniques_detected ?? [];
|
||||
const visible = expanded ? techs.length : Math.min(DEFAULT_VISIBLE, techs.length);
|
||||
const shown = techs.slice(0, visible);
|
||||
const remaining = techs.length - visible;
|
||||
const warningFlags: string[] = (result.coupling_context as any)?.for_claims?.warning_flags ?? [];
|
||||
|
||||
return (
|
||||
<View>
|
||||
<HeadlineCard
|
||||
accent={accent}
|
||||
score={score}
|
||||
scoreLabel={isRo ? '/ 100 SCOR' : '/ 100 SCORE'}
|
||||
eyebrow={isRo ? 'Tehnici de manipulare' : 'Manipulation techniques'}
|
||||
icon={Icon}
|
||||
category={scoreCategory(score, isRo)}
|
||||
descriptor={
|
||||
dimCount > 0
|
||||
? `${techCount} ${isRo ? (techCount === 1 ? 'tehnică' : 'tehnici') : (techCount === 1 ? 'technique' : 'techniques')} · ${dimCount} ${isRo ? (dimCount === 1 ? 'dimensiune' : 'dimensiuni') : (dimCount === 1 ? 'dimension' : 'dimensions')}`
|
||||
: `${techCount} ${isRo ? (techCount === 1 ? 'tehnică' : 'tehnici') : (techCount === 1 ? 'technique' : 'techniques')}`
|
||||
}
|
||||
tldr={buildTldr(result, isRo)}
|
||||
/>
|
||||
|
||||
{(score > 0 || dimCount > 0) && (
|
||||
<StatBlock>
|
||||
<ChipsRow>
|
||||
{(result.dimensions_affected ?? []).map(dim => (
|
||||
<Chip key={dim} variant="info">{dim}</Chip>
|
||||
))}
|
||||
{techCount > 0 && (
|
||||
<Chip variant="violet">{techCount} {isRo ? 'tehnici detectate' : 'techniques detected'}</Chip>
|
||||
)}
|
||||
</ChipsRow>
|
||||
</StatBlock>
|
||||
)}
|
||||
|
||||
{techCount > 0 && (
|
||||
<>
|
||||
<SectionDivider
|
||||
label={isRo ? 'Tehnici de manipulare' : 'Manipulation techniques'}
|
||||
count={`${techCount} ${isRo ? 'detectate' : 'detected'}${dimCount > 0 ? ` · ${(result.dimensions_affected ?? []).join(' · ')}` : ''}`}
|
||||
/>
|
||||
{shown.map((tech, i) => {
|
||||
const sev = tech.severity ?? (tech.intensity * 33);
|
||||
const rawName = localized(tech, 'name') || `Technique #${tech.technique_id}`;
|
||||
const def = defMap?.get(rawName);
|
||||
const dimName = localized(tech, 'dimension_name', 'dimension');
|
||||
const subdimName = localized(tech, 'subdimension_name', 'subdimension');
|
||||
const eyebrowParts = [
|
||||
(tech.name?.split('.')[0] || '').toUpperCase() || undefined,
|
||||
dimName ? (subdimName ? `${dimName} · ${subdimName}` : dimName) : undefined,
|
||||
tech.severity != null ? `${isRo ? 'severitate' : 'severity'} ${tech.severity}` : undefined,
|
||||
].filter(Boolean) as string[];
|
||||
return (
|
||||
<FindingCard
|
||||
key={i}
|
||||
icon={techIcon(tech.name || '')}
|
||||
accent={techAccent(sev)}
|
||||
eyebrow={eyebrowParts.join(' · ')}
|
||||
headline={formatTechniqueName(rawName)}
|
||||
quote={smoothEnums(tech.evidence) || smoothEnums(def ? localized(def, 'description') : '') || undefined}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{remaining > 0 && (
|
||||
<CollapseButton
|
||||
onPress={() => setExpanded(true)}
|
||||
expanded={false}
|
||||
leading={<Plus size={16} color="rgba(255,255,255,0.6)" strokeWidth={2} />}
|
||||
label={
|
||||
isRo
|
||||
? `Vezi celelalte ${remaining} ${remaining === 1 ? 'tehnică' : 'tehnici'}`
|
||||
: `Show ${remaining} more ${remaining === 1 ? 'technique' : 'techniques'}`
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{expanded && techs.length > DEFAULT_VISIBLE && (
|
||||
<CollapseButton
|
||||
onPress={() => setExpanded(false)}
|
||||
expanded={true}
|
||||
label={isRo ? 'Ascunde' : 'Hide'}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{techCount === 0 && (
|
||||
<FindingCard
|
||||
icon={ShieldCheck}
|
||||
accent="success"
|
||||
eyebrow={isRo ? 'Conținut curat' : 'Clean content'}
|
||||
headline={isRo ? 'Nicio tehnică de manipulare detectată' : 'No manipulation techniques detected'}
|
||||
/>
|
||||
)}
|
||||
|
||||
{warningFlags.length > 0 && (
|
||||
<FindingCard
|
||||
icon={AlertTriangle}
|
||||
accent="warning"
|
||||
eyebrow={isRo ? 'Semnale adiționale' : 'Additional signals'}
|
||||
headline={isRo ? 'Indicatori de manipulare la screening' : 'Manipulation signals at screening'}
|
||||
quote={warningFlags.map(f => f.replace(/_/g, ' ')).join(' · ')}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
384
android/didi-app-source/src/components/sections/Verdict.tsx
Normal file
|
|
@ -0,0 +1,384 @@
|
|||
import React, { useState } from 'react';
|
||||
import { View, Text, StyleSheet, Linking, TouchableOpacity } from 'react-native';
|
||||
import {
|
||||
AlertOctagon, AlertTriangle, Info, ShieldCheck, OctagonX, BarChart3, Layers,
|
||||
AlertCircle, Cpu, ExternalLink, ChevronDown, Quote, Users, Zap, UserX, ShieldQuestion, TrendingUp, Timer, XCircle, Tag,
|
||||
} from 'lucide-react-native';
|
||||
import type { LucideIcon } from 'lucide-react-native';
|
||||
import {
|
||||
HeadlineCard, FindingCard, SectionDivider, ActionCallout, StatBlock, ChipsRow, Chip,
|
||||
CollapseButton, ScoreLine, ScoresBlock,
|
||||
} from '../editorial';
|
||||
import type { FindingAccent } from '../../theme/colors';
|
||||
import { COLORS, ACCENT, getRiskColor as getRiskColorByName } from '../../theme/colors';
|
||||
import type { V3Verdict, AnalysisSession } from '../../types/analysis';
|
||||
import { useTranslation, enumLabel, localized, smoothEnums } from '../../i18n';
|
||||
import TechniquesResults from './TechniquesResults';
|
||||
import AiResults, { toAiDisplayResult } from './AiResults';
|
||||
import ClaimsResults from './ClaimsResults';
|
||||
import SourceResults from './SourceResults';
|
||||
import type { TechniqueDefinition } from '../../types/analysis';
|
||||
|
||||
const SEVERITY_RANK: Record<string, number> = { critical: 0, warning: 1, info: 2 };
|
||||
|
||||
/** Hide debug strings the backend leaks into evidence_ref.quote
|
||||
* (e.g. `ai_probability=85/100, verdict="LIKELY_AI"`, `Claims: [SKIPPED]`, `data_quality=full`). */
|
||||
function cleanQuote(q: string | null | undefined): string | undefined {
|
||||
if (!q) return undefined;
|
||||
if (/\[SKIPPED\]/i.test(q)) return undefined;
|
||||
if (/^\s*\w+\s*=\s*[\d"']/.test(q)) return undefined;
|
||||
if (/data_quality\s*=/i.test(q)) return undefined;
|
||||
return q;
|
||||
}
|
||||
|
||||
interface KeyFinding {
|
||||
type: string;
|
||||
severity: string;
|
||||
ro: string;
|
||||
en: string;
|
||||
evidence_ref?: { quote?: string; source_url?: string };
|
||||
}
|
||||
|
||||
const TYPE_ICONS: Record<string, LucideIcon> = {
|
||||
false_claim: BarChart3,
|
||||
fabricated_quote: Quote,
|
||||
manipulation: AlertCircle,
|
||||
urgency: Zap,
|
||||
imposter_content: UserX,
|
||||
conspiracy: ShieldQuestion,
|
||||
demographic: Users,
|
||||
};
|
||||
|
||||
function findingIconAccent(f: KeyFinding): { icon: LucideIcon; accent: FindingAccent } {
|
||||
const accent: FindingAccent =
|
||||
f.severity === 'critical' ? 'critical'
|
||||
: f.severity === 'warning' ? 'warning'
|
||||
: 'info';
|
||||
return { icon: TYPE_ICONS[f.type] || AlertCircle, accent };
|
||||
}
|
||||
|
||||
function categoryIcon(color: string): LucideIcon {
|
||||
if (color === 'red' || color === 'darkred') return AlertOctagon;
|
||||
if (color === 'orange') return AlertTriangle;
|
||||
if (color === 'yellow') return Info;
|
||||
if (color === 'green' || color === 'lightgreen') return ShieldCheck;
|
||||
return Info;
|
||||
}
|
||||
|
||||
function actionEyebrow(severity: string, isRo: boolean): string {
|
||||
if (severity === 'critical') return isRo ? 'NU DISTRIBUI' : 'DO NOT SHARE';
|
||||
if (severity === 'warning') return isRo ? 'CITEȘTE CRITIC' : 'READ CRITICALLY';
|
||||
return isRo ? 'DE REȚINUT' : 'NOTE';
|
||||
}
|
||||
|
||||
function safeHostname(url: string): string {
|
||||
try {
|
||||
return new URL(url).hostname;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
interface Props {
|
||||
verdict: V3Verdict;
|
||||
fullResult: Partial<AnalysisSession> | null;
|
||||
techniqueDefMap?: Map<string, TechniqueDefinition>;
|
||||
totalDuration?: number | null;
|
||||
}
|
||||
|
||||
export default function Verdict({ verdict, fullResult, techniqueDefMap, totalDuration }: Props) {
|
||||
const { language } = useTranslation();
|
||||
const isRo = language === 'ro';
|
||||
const [secondaryOpen, setSecondaryOpen] = useState(false);
|
||||
const [detailsOpen, setDetailsOpen] = useState(false);
|
||||
|
||||
const summary = (verdict.context_summary as any)?.verdict_summary;
|
||||
const accentName = verdict.risk_category_color || 'red';
|
||||
const accent = accentName.startsWith('#') ? accentName : getRiskColorByName(accentName);
|
||||
|
||||
const sortedFindings: KeyFinding[] = React.useMemo(() => {
|
||||
if (!summary?.key_findings) return [];
|
||||
return [...summary.key_findings].sort(
|
||||
(a: KeyFinding, b: KeyFinding) => (SEVERITY_RANK[a.severity] ?? 3) - (SEVERITY_RANK[b.severity] ?? 3),
|
||||
);
|
||||
}, [summary]);
|
||||
|
||||
const dominant = sortedFindings[0];
|
||||
const rest = sortedFindings.slice(1);
|
||||
|
||||
const CatIcon = categoryIcon(verdict.risk_category_color || '');
|
||||
|
||||
const techniquesCount = (verdict.context_summary as any)?.techniques_detected ?? 0;
|
||||
const falseClaims = (verdict.context_summary as any)?.claims_false ?? 0;
|
||||
const elapsedSec = totalDuration ? `${(totalDuration / 1000).toFixed(1)}s` : null;
|
||||
const viralityScore = verdict.virality_score;
|
||||
const viralityLevel = verdict.virality_level;
|
||||
|
||||
const scoreEntries = [
|
||||
{ name: isRo ? 'Manipulare' : 'Manipulation', value: verdict.score_manipulation ?? null },
|
||||
{ name: 'Claims', value: verdict.score_claims ?? null },
|
||||
{ name: isRo ? 'AI generation' : 'AI generation', value: verdict.score_ai ?? null },
|
||||
{ name: isRo ? 'Sursă' : 'Source', value: verdict.score_source ?? null },
|
||||
];
|
||||
|
||||
return (
|
||||
<View>
|
||||
{summary ? (
|
||||
<>
|
||||
{/* TIER 1 — HEADLINE */}
|
||||
<HeadlineCard
|
||||
accent={accent}
|
||||
score={verdict.risk_score}
|
||||
scoreLabel={`/ 100 ${isRo ? 'RISC' : 'RISK'}`}
|
||||
eyebrow={isRo ? 'Risc analizat' : 'Risk analyzed'}
|
||||
icon={CatIcon}
|
||||
category={enumLabel(verdict.risk_category || '')}
|
||||
descriptor={[
|
||||
verdict.risk_level ? enumLabel(verdict.risk_level) : null,
|
||||
verdict.confidence != null ? `${isRo ? 'Certitudine' : 'Confidence'} ${verdict.confidence}%` : null,
|
||||
].filter(Boolean).join(' · ')}
|
||||
tldr={smoothEnums(isRo ? summary.tl_dr_ro : summary.tl_dr_en)}
|
||||
/>
|
||||
|
||||
{/* TIER 2 — ACTION CALLOUT */}
|
||||
{(summary.what_to_do_ro || summary.what_to_do_en) && (
|
||||
<ActionCallout
|
||||
accent={accent}
|
||||
icon={OctagonX}
|
||||
eyebrow={actionEyebrow(dominant?.severity || 'info', isRo)}
|
||||
headline={
|
||||
isRo
|
||||
? (dominant?.severity === 'critical' ? 'Nu distribui acest articol' : 'Citește cu atenție')
|
||||
: (dominant?.severity === 'critical' ? 'Do not share this article' : 'Read carefully')
|
||||
}
|
||||
body={smoothEnums(isRo ? summary.what_to_do_ro : summary.what_to_do_en)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* TIER 3 — DOMINANT FINDING */}
|
||||
{dominant && (() => {
|
||||
const { icon, accent: a } = findingIconAccent(dominant);
|
||||
return (
|
||||
<>
|
||||
<SectionDivider label={isRo ? 'Cea mai gravă problemă' : 'Top issue'} />
|
||||
<FindingCard
|
||||
dominant
|
||||
icon={icon}
|
||||
accent={a}
|
||||
headline={smoothEnums(isRo ? dominant.ro : dominant.en)}
|
||||
quote={smoothEnums(cleanQuote(dominant.evidence_ref?.quote)) || undefined}
|
||||
source={
|
||||
dominant.evidence_ref?.source_url ? (
|
||||
<View style={s.sourceRow}>
|
||||
<ExternalLink size={13} color="rgba(255,255,255,0.45)" strokeWidth={2} />
|
||||
<Text style={s.sourceText}>{isRo ? 'Verificat de' : 'Verified by'}</Text>
|
||||
<TouchableOpacity onPress={() => Linking.openURL(dominant.evidence_ref!.source_url!)}>
|
||||
<Text style={s.link}>{safeHostname(dominant.evidence_ref!.source_url!)}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
{rest.length > 0 && (
|
||||
<>
|
||||
<CollapseButton
|
||||
onPress={() => setSecondaryOpen(o => !o)}
|
||||
expanded={secondaryOpen}
|
||||
leading={<Layers size={16} color="rgba(255,255,255,0.6)" strokeWidth={2} />}
|
||||
label={
|
||||
secondaryOpen
|
||||
? (isRo ? 'Ascunde celelalte semne' : 'Hide other signals')
|
||||
: (isRo
|
||||
? `Vezi ${rest.length} ${rest.length === 1 ? 'alt semn' : 'alte semne'}`
|
||||
: `Show ${rest.length} more ${rest.length === 1 ? 'signal' : 'signals'}`)
|
||||
}
|
||||
/>
|
||||
{secondaryOpen && (
|
||||
<>
|
||||
<SectionDivider
|
||||
label={isRo ? 'Alte semne' : 'Other signals'}
|
||||
count={rest.length}
|
||||
/>
|
||||
{rest.map((f, idx) => {
|
||||
const { icon: ic, accent: ac } = findingIconAccent(f);
|
||||
return (
|
||||
<FindingCard
|
||||
key={idx}
|
||||
icon={ic}
|
||||
accent={ac}
|
||||
headline={smoothEnums(isRo ? f.ro : f.en)}
|
||||
quote={smoothEnums(cleanQuote(f.evidence_ref?.quote)) || undefined}
|
||||
source={
|
||||
f.evidence_ref?.source_url ? (
|
||||
<View style={s.sourceRow}>
|
||||
<ExternalLink size={13} color="rgba(255,255,255,0.45)" strokeWidth={2} />
|
||||
<TouchableOpacity onPress={() => Linking.openURL(f.evidence_ref!.source_url!)}>
|
||||
<Text style={s.link}>{safeHostname(f.evidence_ref!.source_url!)}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
) : (
|
||||
<View style={s.sourceRow}>
|
||||
<Tag size={13} color="rgba(255,255,255,0.45)" strokeWidth={2} />
|
||||
<Text style={s.sourceText}>{f.type.replace(/_/g, ' ')}</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* TIER 4 — TECHNICAL DETAILS */}
|
||||
<CollapseButton
|
||||
onPress={() => setDetailsOpen(o => !o)}
|
||||
expanded={detailsOpen}
|
||||
leading={<BarChart3 size={16} color="rgba(255,255,255,0.6)" strokeWidth={2} />}
|
||||
label={isRo ? 'Detalii tehnice' : 'Technical details'}
|
||||
/>
|
||||
|
||||
{detailsOpen && (
|
||||
<>
|
||||
<StatBlock>
|
||||
<ScoresBlock>
|
||||
{scoreEntries.map((entry, i) => (
|
||||
entry.value != null ? (
|
||||
<ScoreLine
|
||||
key={entry.name}
|
||||
name={entry.name}
|
||||
score={entry.value}
|
||||
color={entry.value >= 70 ? '#ef4444' : entry.value >= 40 ? '#f97316' : '#22c55e'}
|
||||
delay={200 + i * 80}
|
||||
/>
|
||||
) : (
|
||||
<View key={entry.name} style={s.skippedRow}>
|
||||
<Text style={s.skippedName}>{entry.name}</Text>
|
||||
<View style={s.skippedBar} />
|
||||
<Text style={s.skippedDash}>—</Text>
|
||||
</View>
|
||||
)
|
||||
))}
|
||||
</ScoresBlock>
|
||||
<ChipsRow>
|
||||
{viralityScore != null && (
|
||||
<Chip
|
||||
variant={viralityScore >= 50 ? 'critical' : viralityScore >= 25 ? 'warning' : 'success'}
|
||||
leading={<TrendingUp size={12} color={viralityScore >= 50 ? ACCENT.critical : viralityScore >= 25 ? ACCENT.warning : ACCENT.success} strokeWidth={2} />}
|
||||
>
|
||||
Virality {viralityScore}{viralityLevel ? ` ${enumLabel(viralityLevel)}` : ''}
|
||||
</Chip>
|
||||
)}
|
||||
{techniquesCount > 0 && (
|
||||
<Chip variant="violet" leading={<Layers size={12} color={ACCENT.violet} strokeWidth={2} />}>
|
||||
{techniquesCount} {isRo ? 'tehnici' : 'techniques'}
|
||||
</Chip>
|
||||
)}
|
||||
{falseClaims > 0 && (
|
||||
<Chip variant="critical" leading={<XCircle size={12} color={ACCENT.critical} strokeWidth={2} />}>
|
||||
{falseClaims} {isRo ? 'false' : 'false claims'}
|
||||
</Chip>
|
||||
)}
|
||||
{elapsedSec && (
|
||||
<Chip variant="neutral" leading={<Timer size={12} color={ACCENT.neutral} strokeWidth={2} />}>
|
||||
{elapsedSec}
|
||||
</Chip>
|
||||
)}
|
||||
</ChipsRow>
|
||||
</StatBlock>
|
||||
|
||||
{fullResult?.ai_tampered && (
|
||||
<AiResults result={toAiDisplayResult(fullResult.ai_tampered)} />
|
||||
)}
|
||||
{fullResult?.source_assessment && (
|
||||
<SourceResults result={fullResult.source_assessment} />
|
||||
)}
|
||||
{fullResult?.techniques && (
|
||||
<TechniquesResults result={fullResult.techniques} defMap={techniqueDefMap} />
|
||||
)}
|
||||
{fullResult?.claims && <ClaimsResults result={fullResult.claims} />}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
// ── LEGACY FALLBACK ──
|
||||
<>
|
||||
<HeadlineCard
|
||||
accent={accent}
|
||||
score={verdict.risk_score}
|
||||
scoreLabel={`/ 100 ${isRo ? 'RISC' : 'RISK'}`}
|
||||
eyebrow={isRo ? 'Risc analizat' : 'Risk analyzed'}
|
||||
icon={CatIcon}
|
||||
category={enumLabel(verdict.risk_category || '')}
|
||||
descriptor={
|
||||
verdict.confidence != null
|
||||
? `${isRo ? 'Certitudine' : 'Confidence'} ${verdict.confidence}%`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
{(verdict.explanation_en || verdict.explanation_ro) && (
|
||||
<View style={s.legacyExpl}>
|
||||
<Text style={s.legacyText}>{smoothEnums(localized(verdict, 'explanation'))}</Text>
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const s = StyleSheet.create({
|
||||
sourceRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
gap: 6,
|
||||
},
|
||||
sourceText: {
|
||||
color: 'rgba(255,255,255,0.55)',
|
||||
fontSize: 12.5,
|
||||
},
|
||||
link: {
|
||||
color: '#a78bfa',
|
||||
fontSize: 12.5,
|
||||
fontWeight: '500',
|
||||
},
|
||||
skippedRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
},
|
||||
skippedName: {
|
||||
fontSize: 13,
|
||||
color: 'rgba(255,255,255,0.7)',
|
||||
width: 100,
|
||||
},
|
||||
skippedBar: {
|
||||
flex: 1,
|
||||
height: 4,
|
||||
},
|
||||
skippedDash: {
|
||||
fontSize: 13,
|
||||
color: 'rgba(255,255,255,0.28)',
|
||||
width: 40,
|
||||
textAlign: 'right',
|
||||
},
|
||||
legacyExpl: {
|
||||
paddingHorizontal: 18,
|
||||
paddingVertical: 14,
|
||||
backgroundColor: 'rgba(255,255,255,0.025)',
|
||||
borderRadius: 16,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255,255,255,0.06)',
|
||||
},
|
||||
legacyText: {
|
||||
fontFamily: 'Merriweather-Italic',
|
||||
fontSize: 14,
|
||||
lineHeight: 22,
|
||||
color: 'rgba(255,255,255,0.7)',
|
||||
},
|
||||
});
|
||||
6
android/didi-app-source/src/components/sections/index.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export { default as TechniquesResults } from './TechniquesResults';
|
||||
export { default as AiResults, toAiDisplayResult } from './AiResults';
|
||||
export type { AiDisplayResult } from './AiResults';
|
||||
export { default as ClaimsResults } from './ClaimsResults';
|
||||
export { default as SourceResults } from './SourceResults';
|
||||
export { default as Verdict } from './Verdict';
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
import React from 'react';
|
||||
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import {
|
||||
COLORS,
|
||||
GRADIENTS,
|
||||
SPACING,
|
||||
RADIUS,
|
||||
FONT_SIZES,
|
||||
FONT_WEIGHTS,
|
||||
LAYOUT,
|
||||
} from '../../theme/colors';
|
||||
import type { RootStackParamList } from '../../navigation/AppNavigator';
|
||||
import { useTranslation } from '../../i18n';
|
||||
|
||||
export default function ChangePasswordSection() {
|
||||
const { t } = useTranslation();
|
||||
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
|
||||
|
||||
return (
|
||||
<View style={styles.card}>
|
||||
<View style={styles.headerRow}>
|
||||
<View style={styles.iconCircle}>
|
||||
<Text style={styles.iconText}>P</Text>
|
||||
</View>
|
||||
<View style={styles.textContainer}>
|
||||
<Text style={styles.title}>{t('changePassword')}</Text>
|
||||
<Text style={styles.subtitle}>{t('updatePassword')}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.8}
|
||||
onPress={() => navigation.navigate('ChangePassword')}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t('changePassword')}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={[...GRADIENTS.button.colors] as [string, string]}
|
||||
start={GRADIENTS.button.start}
|
||||
end={GRADIENTS.button.end}
|
||||
style={styles.button}
|
||||
>
|
||||
<Text style={styles.buttonText} maxFontSizeMultiplier={1.5}>{t('changePassword')}</Text>
|
||||
</LinearGradient>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
backgroundColor: COLORS.bg.card,
|
||||
borderWidth: 1,
|
||||
borderColor: COLORS.border.accent,
|
||||
borderRadius: RADIUS.xxl,
|
||||
padding: LAYOUT.CARD_PADDING,
|
||||
marginBottom: SPACING.md,
|
||||
},
|
||||
headerRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: SPACING.lg,
|
||||
},
|
||||
iconCircle: {
|
||||
width: LAYOUT.ICON_CIRCLE_SIZE,
|
||||
height: LAYOUT.ICON_CIRCLE_SIZE,
|
||||
borderRadius: RADIUS.full,
|
||||
backgroundColor: COLORS.glass.purple10,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: SPACING.md,
|
||||
},
|
||||
iconText: {
|
||||
color: COLORS.brand.primary,
|
||||
fontSize: FONT_SIZES.xl,
|
||||
fontWeight: FONT_WEIGHTS.bold,
|
||||
},
|
||||
textContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
title: {
|
||||
color: COLORS.text.primary,
|
||||
fontSize: FONT_SIZES.base,
|
||||
fontWeight: FONT_WEIGHTS.bold,
|
||||
},
|
||||
subtitle: {
|
||||
color: COLORS.text.hint,
|
||||
fontSize: FONT_SIZES.xs,
|
||||
marginTop: 2,
|
||||
},
|
||||
button: {
|
||||
borderRadius: RADIUS.xl,
|
||||
paddingVertical: SPACING.sm + 4,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
buttonText: {
|
||||
color: COLORS.text.primary,
|
||||
fontSize: FONT_SIZES.sm,
|
||||
fontWeight: FONT_WEIGHTS.bold,
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
import React from 'react';
|
||||
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
|
||||
import { COLORS, SPACING, RADIUS, FONT_SIZES, FONT_WEIGHTS } from '../../theme/colors';
|
||||
import { useLanguageStore, useTranslation } from '../../i18n';
|
||||
import type { Language } from '../../i18n';
|
||||
|
||||
const LANGUAGES: { key: Language; labelKey: 'english' | 'romanian'; flag: string }[] = [
|
||||
{ key: 'en', labelKey: 'english', flag: '🇬🇧' },
|
||||
{ key: 'ro', labelKey: 'romanian', flag: '🇷🇴' },
|
||||
];
|
||||
|
||||
export default function LanguageSection() {
|
||||
const { t } = useTranslation();
|
||||
const { language, setLanguage } = useLanguageStore();
|
||||
|
||||
return (
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.title}>{t('language')}</Text>
|
||||
<Text style={styles.subtitle}>{t('languageSubtitle')}</Text>
|
||||
|
||||
<View style={styles.optionsRow}>
|
||||
{LANGUAGES.map((lang) => {
|
||||
const selected = language === lang.key;
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={lang.key}
|
||||
style={[styles.option, selected && styles.optionSelected]}
|
||||
onPress={() => setLanguage(lang.key)}
|
||||
activeOpacity={0.7}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t(lang.labelKey)}
|
||||
accessibilityState={{ selected }}
|
||||
>
|
||||
<Text style={styles.flag}>{lang.flag}</Text>
|
||||
<Text style={[styles.optionText, selected && styles.optionTextSelected]}>
|
||||
{t(lang.labelKey)}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
backgroundColor: COLORS.bg.card,
|
||||
borderWidth: 1,
|
||||
borderColor: COLORS.border.accent,
|
||||
borderRadius: RADIUS.xxl,
|
||||
padding: SPACING.lg,
|
||||
marginBottom: SPACING.md,
|
||||
},
|
||||
title: {
|
||||
color: COLORS.text.primary,
|
||||
fontSize: FONT_SIZES.lg,
|
||||
fontWeight: FONT_WEIGHTS.bold,
|
||||
marginBottom: SPACING.xs,
|
||||
},
|
||||
subtitle: {
|
||||
color: COLORS.text.secondary,
|
||||
fontSize: FONT_SIZES.sm,
|
||||
marginBottom: SPACING.md,
|
||||
},
|
||||
optionsRow: {
|
||||
flexDirection: 'row',
|
||||
gap: SPACING.md,
|
||||
},
|
||||
option: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: SPACING.sm,
|
||||
backgroundColor: COLORS.glass.white5,
|
||||
borderWidth: 1,
|
||||
borderColor: COLORS.border.secondary,
|
||||
borderRadius: RADIUS.lg,
|
||||
paddingVertical: SPACING.md,
|
||||
},
|
||||
optionSelected: {
|
||||
borderColor: COLORS.brand.primary,
|
||||
backgroundColor: COLORS.glass.purple10,
|
||||
},
|
||||
flag: {
|
||||
fontSize: FONT_SIZES.xl,
|
||||
color: COLORS.text.primary,
|
||||
},
|
||||
optionText: {
|
||||
color: COLORS.text.secondary,
|
||||
fontSize: FONT_SIZES.base,
|
||||
fontWeight: FONT_WEIGHTS.bold,
|
||||
},
|
||||
optionTextSelected: {
|
||||
color: COLORS.brand.primaryLight,
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,134 @@
|
|||
import React from 'react';
|
||||
import { View, Text, StyleSheet } from 'react-native';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import {
|
||||
COLORS,
|
||||
GRADIENTS,
|
||||
SPACING,
|
||||
RADIUS,
|
||||
FONT_SIZES,
|
||||
FONT_WEIGHTS,
|
||||
LAYOUT,
|
||||
} from '../../theme/colors';
|
||||
import type { UserProfile } from '../../types/auth';
|
||||
import { useTranslation } from '../../i18n';
|
||||
|
||||
interface ProfileSectionProps {
|
||||
profile: UserProfile | null;
|
||||
}
|
||||
|
||||
export default function ProfileSection({ profile }: ProfileSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const initials = profile
|
||||
? `${profile.firstName?.[0] || ''}${profile.lastName?.[0] || ''}`.toUpperCase()
|
||||
: '?';
|
||||
|
||||
return (
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.sectionTitle}>{t('profile')}</Text>
|
||||
|
||||
<View style={styles.avatarRow}>
|
||||
<LinearGradient
|
||||
colors={[...GRADIENTS.avatar.colors] as [string, string, ...string[]]}
|
||||
start={GRADIENTS.avatar.start}
|
||||
end={GRADIENTS.avatar.end}
|
||||
style={styles.avatar}
|
||||
>
|
||||
<Text style={styles.avatarText}>{initials}</Text>
|
||||
</LinearGradient>
|
||||
<View style={styles.avatarInfo}>
|
||||
<Text style={styles.name}>
|
||||
{profile ? `${profile.firstName} ${profile.lastName}` : t('loading')}
|
||||
</Text>
|
||||
<Text style={styles.email}>{profile?.email || ''}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.fieldsContainer}>
|
||||
<View style={styles.fieldRow}>
|
||||
<Text style={styles.fieldLabel}>{t('username')}</Text>
|
||||
<Text style={styles.fieldValue}>{profile?.email || '—'}</Text>
|
||||
</View>
|
||||
<View style={styles.fieldRow}>
|
||||
<Text style={styles.fieldLabel}>{t('firstName')}</Text>
|
||||
<Text style={styles.fieldValue}>{profile?.firstName || '—'}</Text>
|
||||
</View>
|
||||
<View style={styles.fieldRow}>
|
||||
<Text style={styles.fieldLabel}>{t('lastName')}</Text>
|
||||
<Text style={styles.fieldValue}>{profile?.lastName || '—'}</Text>
|
||||
</View>
|
||||
<View style={styles.fieldRow}>
|
||||
<Text style={styles.fieldLabel}>{t('email')}</Text>
|
||||
<Text style={styles.fieldValue}>{profile?.email || '—'}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
backgroundColor: COLORS.bg.card,
|
||||
borderWidth: 1,
|
||||
borderColor: COLORS.border.accent,
|
||||
borderRadius: RADIUS.xxl,
|
||||
padding: LAYOUT.CARD_PADDING,
|
||||
marginBottom: SPACING.md,
|
||||
},
|
||||
sectionTitle: {
|
||||
color: COLORS.text.primary,
|
||||
fontSize: FONT_SIZES.lg,
|
||||
fontWeight: FONT_WEIGHTS.bold,
|
||||
marginBottom: SPACING.md,
|
||||
},
|
||||
avatarRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: SPACING.lg,
|
||||
},
|
||||
avatar: {
|
||||
width: LAYOUT.AVATAR_SIZE_LARGE,
|
||||
height: LAYOUT.AVATAR_SIZE_LARGE,
|
||||
borderRadius: RADIUS.full,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: SPACING.md,
|
||||
},
|
||||
avatarText: {
|
||||
color: COLORS.text.primary,
|
||||
fontSize: FONT_SIZES.xl,
|
||||
fontWeight: FONT_WEIGHTS.bold,
|
||||
},
|
||||
avatarInfo: {
|
||||
flex: 1,
|
||||
},
|
||||
name: {
|
||||
color: COLORS.text.primary,
|
||||
fontSize: FONT_SIZES.base,
|
||||
fontWeight: FONT_WEIGHTS.bold,
|
||||
},
|
||||
email: {
|
||||
color: COLORS.text.secondary,
|
||||
fontSize: FONT_SIZES.sm,
|
||||
marginTop: 2,
|
||||
},
|
||||
fieldsContainer: {
|
||||
gap: SPACING.sm,
|
||||
},
|
||||
fieldRow: {
|
||||
backgroundColor: COLORS.glass.white5,
|
||||
borderRadius: RADIUS.lg,
|
||||
paddingHorizontal: SPACING.md,
|
||||
paddingVertical: SPACING.sm + 4,
|
||||
},
|
||||
fieldLabel: {
|
||||
color: COLORS.text.hint,
|
||||
fontSize: FONT_SIZES.xs,
|
||||
marginBottom: 2,
|
||||
},
|
||||
fieldValue: {
|
||||
color: COLORS.text.primary,
|
||||
fontSize: FONT_SIZES.sm,
|
||||
fontWeight: FONT_WEIGHTS.bold,
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
import React from 'react';
|
||||
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
|
||||
import {
|
||||
COLORS,
|
||||
SPACING,
|
||||
RADIUS,
|
||||
FONT_SIZES,
|
||||
FONT_WEIGHTS,
|
||||
LAYOUT,
|
||||
getBadgeBgColor,
|
||||
} from '../../theme/colors';
|
||||
import { useSignOut } from '../../hooks/useSignOut';
|
||||
import { useTranslation } from '../../i18n';
|
||||
|
||||
export default function SignOutSection() {
|
||||
const { t } = useTranslation();
|
||||
const handleSignOut = useSignOut();
|
||||
|
||||
return (
|
||||
<View style={styles.card}>
|
||||
<View style={styles.headerRow}>
|
||||
<View style={styles.iconCircle}>
|
||||
<Text style={styles.iconText}>✕</Text>
|
||||
</View>
|
||||
<View style={styles.textContainer}>
|
||||
<Text style={styles.title}>{t('signOut')}</Text>
|
||||
<Text style={styles.subtitle}>{t('signOutSubtitle')}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.8}
|
||||
onPress={handleSignOut}
|
||||
style={styles.button}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t('signOut')}
|
||||
>
|
||||
<Text style={styles.buttonText} maxFontSizeMultiplier={1.5}>
|
||||
{t('signOut')}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
backgroundColor: COLORS.bg.card,
|
||||
borderWidth: 1,
|
||||
borderColor: getBadgeBgColor(COLORS.status.red, 0.35),
|
||||
borderRadius: RADIUS.xxl,
|
||||
padding: LAYOUT.CARD_PADDING,
|
||||
marginBottom: SPACING.md,
|
||||
},
|
||||
headerRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: SPACING.lg,
|
||||
},
|
||||
iconCircle: {
|
||||
width: LAYOUT.ICON_CIRCLE_SIZE,
|
||||
height: LAYOUT.ICON_CIRCLE_SIZE,
|
||||
borderRadius: RADIUS.full,
|
||||
backgroundColor: getBadgeBgColor(COLORS.status.red, 0.12),
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: SPACING.md,
|
||||
},
|
||||
iconText: {
|
||||
color: COLORS.status.red,
|
||||
fontSize: FONT_SIZES.xl,
|
||||
fontWeight: FONT_WEIGHTS.bold,
|
||||
},
|
||||
textContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
title: {
|
||||
color: COLORS.text.primary,
|
||||
fontSize: FONT_SIZES.base,
|
||||
fontWeight: FONT_WEIGHTS.bold,
|
||||
},
|
||||
subtitle: {
|
||||
color: COLORS.text.hint,
|
||||
fontSize: FONT_SIZES.xs,
|
||||
marginTop: 2,
|
||||
},
|
||||
button: {
|
||||
borderRadius: RADIUS.xl,
|
||||
paddingVertical: SPACING.sm + 4,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderWidth: 1,
|
||||
borderColor: getBadgeBgColor(COLORS.status.red, 0.5),
|
||||
backgroundColor: getBadgeBgColor(COLORS.status.red, 0.12),
|
||||
},
|
||||
buttonText: {
|
||||
color: COLORS.status.red,
|
||||
fontSize: FONT_SIZES.sm,
|
||||
fontWeight: FONT_WEIGHTS.bold,
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,174 @@
|
|||
import React from 'react';
|
||||
import { View, Text, Switch, StyleSheet } from 'react-native';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import {
|
||||
COLORS,
|
||||
SPACING,
|
||||
RADIUS,
|
||||
FONT_SIZES,
|
||||
FONT_WEIGHTS,
|
||||
LAYOUT,
|
||||
} from '../../theme/colors';
|
||||
import type { SocialToggles } from '../../utils/socialShareStorage';
|
||||
import { useTranslation } from '../../i18n';
|
||||
|
||||
interface SocialShareSectionProps {
|
||||
toggles: SocialToggles;
|
||||
onToggle: (platform: keyof SocialToggles) => void;
|
||||
}
|
||||
|
||||
const SOCIAL_PLATFORMS: {
|
||||
key: keyof SocialToggles;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
colors: [string, string];
|
||||
}[] = [
|
||||
{
|
||||
key: 'facebook',
|
||||
name: 'Facebook',
|
||||
description: 'Auto-analyze shared Facebook links',
|
||||
icon: 'f',
|
||||
colors: [COLORS.social.facebookStart, COLORS.social.facebookEnd],
|
||||
},
|
||||
{
|
||||
key: 'x',
|
||||
name: 'X (Twitter)',
|
||||
description: 'Auto-analyze shared X/Twitter links',
|
||||
icon: 'X',
|
||||
colors: [COLORS.social.twitterStart, COLORS.social.twitterEnd],
|
||||
},
|
||||
{
|
||||
key: 'tiktok',
|
||||
name: 'TikTok',
|
||||
description: 'Auto-analyze shared TikTok links',
|
||||
icon: 'T',
|
||||
colors: [COLORS.social.tiktokStart, COLORS.social.tiktokEnd],
|
||||
},
|
||||
];
|
||||
|
||||
export default function SocialShareSection({ toggles, onToggle }: SocialShareSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const platformLabels: Record<keyof SocialToggles, { name: string; description: string }> = {
|
||||
facebook: { name: t('facebook'), description: t('autoAnalyzeFacebook') },
|
||||
x: { name: t('xTwitter'), description: t('autoAnalyzeX') },
|
||||
tiktok: { name: t('tiktok'), description: t('autoAnalyzeTiktok') },
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.sectionTitle}>{t('socialShare')}</Text>
|
||||
<Text style={styles.sectionSubtitle}>
|
||||
{t('socialShareDesc')}
|
||||
</Text>
|
||||
|
||||
<View style={styles.togglesList}>
|
||||
{SOCIAL_PLATFORMS.map((platform) => (
|
||||
<View key={platform.key} style={styles.toggleRow}>
|
||||
<LinearGradient
|
||||
colors={platform.colors}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={styles.iconCircle}
|
||||
>
|
||||
<Text style={styles.iconText}>{platform.icon}</Text>
|
||||
</LinearGradient>
|
||||
|
||||
<View style={styles.toggleTextContainer}>
|
||||
<Text style={styles.toggleName}>{platformLabels[platform.key].name}</Text>
|
||||
<Text style={styles.toggleDescription}>{platformLabels[platform.key].description}</Text>
|
||||
</View>
|
||||
|
||||
<Switch
|
||||
value={toggles[platform.key]}
|
||||
onValueChange={() => onToggle(platform.key)}
|
||||
accessibilityRole="switch"
|
||||
accessibilityLabel={platformLabels[platform.key].description}
|
||||
accessibilityState={{ checked: toggles[platform.key] }}
|
||||
trackColor={{
|
||||
false: COLORS.glass.white10,
|
||||
true: COLORS.glass.purple35,
|
||||
}}
|
||||
thumbColor={toggles[platform.key] ? COLORS.brand.primary : COLORS.text.hint}
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<View style={styles.noteContainer}>
|
||||
<Text style={styles.noteText}>
|
||||
{t('socialShareNote')}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
backgroundColor: COLORS.bg.card,
|
||||
borderWidth: 1,
|
||||
borderColor: COLORS.border.accent,
|
||||
borderRadius: RADIUS.xxl,
|
||||
padding: LAYOUT.CARD_PADDING,
|
||||
marginBottom: SPACING.md,
|
||||
},
|
||||
sectionTitle: {
|
||||
color: COLORS.text.primary,
|
||||
fontSize: FONT_SIZES.lg,
|
||||
fontWeight: FONT_WEIGHTS.bold,
|
||||
marginBottom: SPACING.xs,
|
||||
},
|
||||
sectionSubtitle: {
|
||||
color: COLORS.text.hint,
|
||||
fontSize: FONT_SIZES.xs,
|
||||
lineHeight: 18,
|
||||
marginBottom: SPACING.lg,
|
||||
},
|
||||
togglesList: {
|
||||
gap: SPACING.md,
|
||||
},
|
||||
toggleRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
iconCircle: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: RADIUS.full,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: SPACING.md,
|
||||
},
|
||||
iconText: {
|
||||
color: COLORS.text.primary,
|
||||
fontSize: FONT_SIZES.lg,
|
||||
fontWeight: FONT_WEIGHTS.bold,
|
||||
},
|
||||
toggleTextContainer: {
|
||||
flex: 1,
|
||||
marginRight: SPACING.sm,
|
||||
},
|
||||
toggleName: {
|
||||
color: COLORS.text.primary,
|
||||
fontSize: FONT_SIZES.sm,
|
||||
fontWeight: FONT_WEIGHTS.bold,
|
||||
},
|
||||
toggleDescription: {
|
||||
color: COLORS.text.hint,
|
||||
fontSize: FONT_SIZES.xs,
|
||||
marginTop: 1,
|
||||
},
|
||||
noteContainer: {
|
||||
backgroundColor: COLORS.glass.white5,
|
||||
borderRadius: RADIUS.lg,
|
||||
padding: SPACING.md,
|
||||
marginTop: SPACING.lg,
|
||||
},
|
||||
noteText: {
|
||||
color: COLORS.text.muted,
|
||||
fontSize: FONT_SIZES.xs,
|
||||
lineHeight: 18,
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,247 @@
|
|||
import React from 'react';
|
||||
import { View, Text, StyleSheet } from 'react-native';
|
||||
import {
|
||||
COLORS,
|
||||
SPACING,
|
||||
RADIUS,
|
||||
FONT_SIZES,
|
||||
FONT_WEIGHTS,
|
||||
LAYOUT,
|
||||
} from '../../theme/colors';
|
||||
import type { UserProfile } from '../../types/auth';
|
||||
import { useTranslation } from '../../i18n';
|
||||
|
||||
interface SubscriptionSectionProps {
|
||||
profile: UserProfile | null;
|
||||
usageStats: any;
|
||||
creditsData: { credits_remained: number; credits_spent: number } | null;
|
||||
}
|
||||
|
||||
export default function SubscriptionSection({ profile, usageStats, creditsData }: SubscriptionSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const plan = usageStats?.plan as any;
|
||||
const sub = usageStats?.subscription as any;
|
||||
|
||||
const planName = plan?.name || 'Free';
|
||||
const isActive = sub?.isActive ?? sub?.is_active ?? false;
|
||||
const price = plan?.priceAmount ?? plan?.price_amount ?? 0;
|
||||
|
||||
// Credits from auth/credits endpoint (authoritative source)
|
||||
const creditsRemaining = creditsData?.credits_remained ?? profile?.creditsRemained ?? 0;
|
||||
const creditsSpent = creditsData?.credits_spent ?? profile?.creditsSpent ?? 0;
|
||||
const creditsTotal = creditsRemaining + creditsSpent;
|
||||
const creditPercent = creditsTotal > 0 ? (creditsSpent / creditsTotal) * 100 : 0;
|
||||
|
||||
const creditsPerCycle = plan?.creditsPerCycle ?? plan?.credits_per_cycle ?? creditsTotal;
|
||||
const maxImages = plan?.maxImages ?? plan?.max_images ?? null;
|
||||
const maxVideoMinutes = plan?.maxVideoMinutes ?? plan?.max_video_minutes ?? null;
|
||||
const storageLimitGb = plan?.storageLimitGb ?? plan?.storage_limit_gb ?? null;
|
||||
|
||||
const creditBarColor =
|
||||
creditPercent > 90 ? COLORS.status.red :
|
||||
creditPercent > 75 ? COLORS.status.yellow :
|
||||
COLORS.brand.accentCyan;
|
||||
|
||||
return (
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.sectionTitle}>{t('subscriptionCredits')}</Text>
|
||||
|
||||
{/* Plan info row */}
|
||||
<View style={styles.planRow}>
|
||||
<View style={styles.planBadge}>
|
||||
<Text style={styles.planBadgeText}>{planName}</Text>
|
||||
</View>
|
||||
<View style={[styles.statusDot, { backgroundColor: isActive ? COLORS.status.green : COLORS.status.red }]} />
|
||||
<Text style={styles.statusText}>{isActive ? t('active') : t('inactive')}</Text>
|
||||
<Text style={styles.price}>${price}/mo</Text>
|
||||
</View>
|
||||
|
||||
{/* Credits */}
|
||||
<View style={styles.creditsContainer}>
|
||||
<View style={styles.creditsHeader}>
|
||||
<Text style={styles.creditsLabel}>{t('credits')}</Text>
|
||||
<Text style={styles.creditsValue}>
|
||||
{creditsRemaining} / {creditsTotal}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.progressBg}>
|
||||
<View
|
||||
style={[
|
||||
styles.progressFill,
|
||||
{
|
||||
width: `${Math.min(creditPercent, 100)}%`,
|
||||
backgroundColor: creditBarColor,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.creditsDetails}>
|
||||
<View style={styles.detailItem}>
|
||||
<Text style={styles.detailLabel}>{t('remaining')}</Text>
|
||||
<Text style={styles.detailValue}>{creditsRemaining}</Text>
|
||||
</View>
|
||||
<View style={styles.detailItem}>
|
||||
<Text style={styles.detailLabel}>{t('total')}</Text>
|
||||
<Text style={styles.detailValue}>{creditsTotal}</Text>
|
||||
</View>
|
||||
<View style={styles.detailItem}>
|
||||
<Text style={styles.detailLabel}>{t('spent')}</Text>
|
||||
<Text style={styles.detailValue}>{creditsSpent}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Plan limits */}
|
||||
<View style={styles.limitsContainer}>
|
||||
<Text style={styles.limitsTitle}>{t('planLimits')}</Text>
|
||||
<View style={styles.limitsGrid}>
|
||||
<View style={styles.limitItem}>
|
||||
<Text style={styles.limitLabel}>{t('creditsCycle')}</Text>
|
||||
<Text style={styles.limitValue}>{creditsPerCycle || '—'}</Text>
|
||||
</View>
|
||||
<View style={styles.limitItem}>
|
||||
<Text style={styles.limitLabel}>{t('maxImages')}</Text>
|
||||
<Text style={styles.limitValue}>{maxImages ?? '—'}</Text>
|
||||
</View>
|
||||
<View style={styles.limitItem}>
|
||||
<Text style={styles.limitLabel}>{t('videoMinutes')}</Text>
|
||||
<Text style={styles.limitValue}>{maxVideoMinutes ?? '—'}</Text>
|
||||
</View>
|
||||
<View style={styles.limitItem}>
|
||||
<Text style={styles.limitLabel}>{t('storage')}</Text>
|
||||
<Text style={styles.limitValue}>{storageLimitGb ?? '—'} GB</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
backgroundColor: COLORS.bg.card,
|
||||
borderWidth: 1,
|
||||
borderColor: COLORS.border.accent,
|
||||
borderRadius: RADIUS.xxl,
|
||||
padding: LAYOUT.CARD_PADDING,
|
||||
marginBottom: SPACING.md,
|
||||
},
|
||||
sectionTitle: {
|
||||
color: COLORS.text.primary,
|
||||
fontSize: FONT_SIZES.lg,
|
||||
fontWeight: FONT_WEIGHTS.bold,
|
||||
marginBottom: SPACING.md,
|
||||
},
|
||||
planRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: SPACING.sm,
|
||||
marginBottom: SPACING.lg,
|
||||
},
|
||||
planBadge: {
|
||||
backgroundColor: COLORS.glass.cyan10,
|
||||
borderWidth: 1,
|
||||
borderColor: COLORS.glass.cyan20,
|
||||
borderRadius: RADIUS.lg,
|
||||
paddingHorizontal: SPACING.sm + 4,
|
||||
paddingVertical: SPACING.xs,
|
||||
},
|
||||
planBadgeText: {
|
||||
color: COLORS.brand.accentCyan,
|
||||
fontSize: FONT_SIZES.xs,
|
||||
fontWeight: FONT_WEIGHTS.bold,
|
||||
},
|
||||
statusDot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: RADIUS.full,
|
||||
},
|
||||
statusText: {
|
||||
color: COLORS.text.secondary,
|
||||
fontSize: FONT_SIZES.sm,
|
||||
},
|
||||
price: {
|
||||
color: COLORS.text.hint,
|
||||
fontSize: FONT_SIZES.sm,
|
||||
marginLeft: 'auto',
|
||||
},
|
||||
creditsContainer: {
|
||||
marginBottom: SPACING.lg,
|
||||
},
|
||||
creditsHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: SPACING.sm,
|
||||
},
|
||||
creditsLabel: {
|
||||
color: COLORS.text.primary,
|
||||
fontSize: FONT_SIZES.sm,
|
||||
fontWeight: FONT_WEIGHTS.bold,
|
||||
},
|
||||
creditsValue: {
|
||||
color: COLORS.brand.accentCyan,
|
||||
fontSize: FONT_SIZES.sm,
|
||||
fontWeight: FONT_WEIGHTS.bold,
|
||||
},
|
||||
progressBg: {
|
||||
height: 8,
|
||||
backgroundColor: COLORS.glass.white10,
|
||||
borderRadius: RADIUS.sm,
|
||||
overflow: 'hidden',
|
||||
marginBottom: SPACING.sm,
|
||||
},
|
||||
progressFill: {
|
||||
height: '100%',
|
||||
borderRadius: RADIUS.sm,
|
||||
},
|
||||
creditsDetails: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
detailItem: {
|
||||
alignItems: 'center',
|
||||
},
|
||||
detailLabel: {
|
||||
color: COLORS.text.hint,
|
||||
fontSize: FONT_SIZES.xs,
|
||||
},
|
||||
detailValue: {
|
||||
color: COLORS.text.primary,
|
||||
fontSize: FONT_SIZES.sm,
|
||||
fontWeight: FONT_WEIGHTS.bold,
|
||||
marginTop: 2,
|
||||
},
|
||||
limitsContainer: {
|
||||
backgroundColor: COLORS.glass.white5,
|
||||
borderRadius: RADIUS.lg,
|
||||
padding: SPACING.md,
|
||||
},
|
||||
limitsTitle: {
|
||||
color: COLORS.text.primary,
|
||||
fontSize: FONT_SIZES.sm,
|
||||
fontWeight: FONT_WEIGHTS.bold,
|
||||
marginBottom: SPACING.sm,
|
||||
},
|
||||
limitsGrid: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: SPACING.sm,
|
||||
},
|
||||
limitItem: {
|
||||
width: '47%',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
limitLabel: {
|
||||
color: COLORS.text.hint,
|
||||
fontSize: FONT_SIZES.xs,
|
||||
},
|
||||
limitValue: {
|
||||
color: COLORS.text.primary,
|
||||
fontSize: FONT_SIZES.xs,
|
||||
fontWeight: FONT_WEIGHTS.bold,
|
||||
},
|
||||
});
|
||||
24
android/didi-app-source/src/hooks/useShareIntent.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { useShareIntentContext } from 'expo-share-intent';
|
||||
import { resolveShareIntent, type SharedPayload } from '../utils/shareIntent';
|
||||
|
||||
/**
|
||||
* Returns the normalized shared content (URL, plain text, or image)
|
||||
* received via the OS share sheet, or null when nothing was shared.
|
||||
*/
|
||||
export function useSharedContent() {
|
||||
const { hasShareIntent, shareIntent, resetShareIntent, isReady } =
|
||||
useShareIntentContext();
|
||||
|
||||
let sharedPayload: SharedPayload | null = null;
|
||||
|
||||
if (hasShareIntent && shareIntent) {
|
||||
sharedPayload = resolveShareIntent(shareIntent);
|
||||
}
|
||||
|
||||
return {
|
||||
isReady,
|
||||
hasShareIntent,
|
||||
sharedPayload,
|
||||
resetShareIntent,
|
||||
};
|
||||
}
|
||||
41
android/didi-app-source/src/hooks/useSignOut.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { useCallback } from 'react';
|
||||
import { Alert, Platform } from 'react-native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useTranslation } from '../i18n';
|
||||
import type { RootStackParamList } from '../navigation/AppNavigator';
|
||||
|
||||
/**
|
||||
* Shared sign-out flow: confirmation prompt, server-side token revocation +
|
||||
* local token clearing (clearTokens), then navigation reset to Main.
|
||||
* Used by DashboardScreen and SettingsScreen.
|
||||
*/
|
||||
export function useSignOut() {
|
||||
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
|
||||
const { clearTokens } = useAuthStore();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useCallback(() => {
|
||||
const doSignOut = async () => {
|
||||
await clearTokens();
|
||||
navigation.reset({ index: 0, routes: [{ name: 'Main' }] });
|
||||
};
|
||||
|
||||
if (Platform.OS === 'web') {
|
||||
if (window.confirm(t('signOutConfirm'))) {
|
||||
doSignOut();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
Alert.alert(t('signOut'), t('signOutConfirm'), [
|
||||
{ text: t('cancel'), style: 'cancel' },
|
||||
{
|
||||
text: t('signOut'),
|
||||
style: 'destructive',
|
||||
onPress: doSignOut,
|
||||
},
|
||||
]);
|
||||
}, [clearTokens, navigation, t]);
|
||||
}
|
||||
25
android/didi-app-source/src/hooks/useTechniqueDefinitions.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import type { TechniqueDefinition } from '../types/analysis';
|
||||
import { getTechniqueDefinitions } from '../api/techniqueDefinitions';
|
||||
|
||||
/** Fetch technique definitions (cached 5 min). Returns map by technique_name and full array. */
|
||||
export function useTechniqueDefinitions() {
|
||||
const [definitions, setDefinitions] = useState<TechniqueDefinition[]>([]);
|
||||
const [defMap, setDefMap] = useState<Map<string, TechniqueDefinition>>(new Map());
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
getTechniqueDefinitions().then((defs) => {
|
||||
if (cancelled) return;
|
||||
setDefinitions(defs);
|
||||
const map = new Map<string, TechniqueDefinition>();
|
||||
for (const d of defs) {
|
||||
map.set(d.technique_name, d);
|
||||
}
|
||||
setDefMap(map);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
return { definitions, defMap };
|
||||
}
|
||||
343
android/didi-app-source/src/i18n/en.ts
Normal file
|
|
@ -0,0 +1,343 @@
|
|||
const en = {
|
||||
// Common
|
||||
back: 'Back',
|
||||
cancel: 'Cancel',
|
||||
delete: 'Delete',
|
||||
error: 'Error',
|
||||
loading: 'Loading...',
|
||||
goBack: 'Go Back',
|
||||
settings: 'Settings',
|
||||
signOut: 'Sign Out',
|
||||
signIn: 'Sign In',
|
||||
createAccount: 'Create Account',
|
||||
analyze: 'Analyze',
|
||||
confirm: 'Confirm',
|
||||
|
||||
// Main / Auth
|
||||
appName: 'didi',
|
||||
appSubtitle: 'Content Analysis Tool',
|
||||
authenticating: 'Authenticating...',
|
||||
redirectingToLogin: 'Redirecting to login...',
|
||||
authError: 'Authentication Error',
|
||||
authDenied: 'Authorization was denied. Please try again.',
|
||||
securityError: 'Security Error',
|
||||
invalidAuthResponse: 'Invalid authentication response. Please try again.',
|
||||
noAuthCode: 'No authorization code received',
|
||||
authFailed: 'Authentication Failed',
|
||||
authFailedMessage: 'Could not complete login. Please try again.',
|
||||
changePassword: 'Change Password',
|
||||
redirecting: 'Redirecting...',
|
||||
|
||||
// Dashboard
|
||||
analysis: 'Analysis',
|
||||
fullPipelineAnalysis: 'Full pipeline analysis',
|
||||
skills: 'Skills',
|
||||
individualComponents: 'Individual analysis components',
|
||||
history: 'History',
|
||||
pastAnalyses: 'Past analyses',
|
||||
accountPreferences: 'Account & preferences',
|
||||
signOutConfirm: 'Are you sure you want to sign out?',
|
||||
signOutSubtitle: 'Sign out of your account on this device',
|
||||
|
||||
// Pipeline
|
||||
fullAnalysis: 'Full Analysis',
|
||||
runAllComponents: 'Run all components at once',
|
||||
textToAnalyze: 'Text to Analyze',
|
||||
enterTextHint: 'Enter text, article, or paste content',
|
||||
pasteTextPlaceholder: 'Paste text content here...',
|
||||
characters: 'characters',
|
||||
urlToAnalyze: 'URL to Analyze',
|
||||
enterUrlHint: 'Enter a webpage URL',
|
||||
urlPlaceholder: 'https://example.com/article',
|
||||
imageToAnalyze: 'Image to Analyze',
|
||||
takePhotoOrSelect: 'Take a photo or select from gallery',
|
||||
gallery: 'Gallery',
|
||||
camera: 'Camera',
|
||||
audioToAnalyze: 'Audio to Analyze',
|
||||
selectAudioHint: 'Select an audio file for analysis',
|
||||
selectAudio: 'Select Audio',
|
||||
videoToAnalyze: 'Video to Analyze',
|
||||
selectVideoHint: 'Select a video file for analysis',
|
||||
selectVideo: 'Select Video',
|
||||
runFullAnalysis: 'Run Full Analysis',
|
||||
submitting: 'Submitting...',
|
||||
uploadingMedia: 'Uploading media...',
|
||||
startingAnalysis: 'Starting analysis...',
|
||||
invalidFile: 'Invalid File',
|
||||
permissionRequired: 'Permission required',
|
||||
cameraPermission: 'Camera access is needed to take photos.',
|
||||
analysisError: 'Analysis Error',
|
||||
failedToStartAnalysis: 'Failed to start analysis.',
|
||||
contentUnavailable: 'Content Unavailable',
|
||||
contentNotExtracted: 'Content could not be extracted from this URL.',
|
||||
uploadManually: 'Upload Manually',
|
||||
|
||||
// Tabs
|
||||
tabText: 'Text',
|
||||
tabImage: 'Image',
|
||||
tabAudio: 'Audio',
|
||||
tabVideo: 'Video',
|
||||
tabUrl: 'URL',
|
||||
|
||||
// Pipeline Results
|
||||
analysisResults: 'Analysis Results',
|
||||
processingMedia: 'Processing media...',
|
||||
generatingVerdict: 'Generating verdict...',
|
||||
processingProgress: 'Processing ({done}/{total})...',
|
||||
percentComplete: '{percent}% complete',
|
||||
analyzing: 'Analyzing...',
|
||||
noResults: 'No results available',
|
||||
analysisTimedOut: 'Analysis timed out. Please try again.',
|
||||
connectionLost: 'Connection lost. Please check your network and try again.',
|
||||
serverError: 'Analysis server error. Please try again later.',
|
||||
sessionNotFound: 'Analysis session not found.',
|
||||
|
||||
// Component Labels
|
||||
manipulationTechniques: 'Manipulation Techniques',
|
||||
claimsVerification: 'Claims Verification',
|
||||
aiDetection: 'AI Detection',
|
||||
sourceAssessment: 'Source Assessment',
|
||||
contextAnalysis: 'Context Analysis',
|
||||
|
||||
// Skills Screen
|
||||
analyzeIndividualAspects: 'Analyze individual aspects',
|
||||
detectManipulation: 'Detect persuasion and manipulation tactics',
|
||||
evaluateSource: 'Evaluate source credibility and trustworthiness',
|
||||
aiTamperDetection: 'AI Tamper Detection',
|
||||
detectAiContent: 'Detect AI-generated or manipulated content',
|
||||
claimsAnalysis: 'Claims Analysis',
|
||||
verifyClaims: 'Verify claims and check credibility',
|
||||
enterTextToAnalyze: 'Enter text content to analyze',
|
||||
|
||||
// History
|
||||
noAnalysesYet: 'No analyses yet',
|
||||
runFirstAnalysis: 'Run your first analysis to see it here',
|
||||
justNow: 'Just now',
|
||||
minutesAgo: '{min}m ago',
|
||||
hoursAgo: '{hr}h ago',
|
||||
daysAgo: '{day}d ago',
|
||||
mediaAnalysis: '(media analysis)',
|
||||
deleteAnalysis: 'Delete Analysis',
|
||||
deleteConfirm: 'Are you sure you want to delete this analysis?',
|
||||
failedToLoadHistory: 'Failed to load history',
|
||||
failedToLoadDetails: 'Failed to load details',
|
||||
failedToDelete: 'Failed to delete',
|
||||
|
||||
// Verdict Card
|
||||
verdict: 'Verdict',
|
||||
riskScore: 'Risk Score',
|
||||
confidence: 'Confidence',
|
||||
recommendedAction: 'Recommended Action',
|
||||
|
||||
// Component Scores
|
||||
componentScores: 'Component Scores',
|
||||
scoreManipulation: 'Manipulation',
|
||||
scoreClaims: 'Claims',
|
||||
scoreAiGenerated: 'AI Generated',
|
||||
scoreSource: 'Source',
|
||||
scoreContext: 'Context',
|
||||
|
||||
// Claims Card
|
||||
credibilityScore: 'Credibility Score',
|
||||
total: 'Total',
|
||||
trueLabel: 'True',
|
||||
falseLabel: 'False',
|
||||
unverified: 'Unverified',
|
||||
sources: 'Sources',
|
||||
stance: 'Stance',
|
||||
|
||||
// Claim Status Labels
|
||||
claimVerifiedTrue: 'Verified True',
|
||||
claimVerifiedFalse: 'Verified False',
|
||||
claimLikelyFalse: 'Likely False',
|
||||
claimLikelyTrue: 'Likely True',
|
||||
claimPartiallyVerified: 'Partially Verified',
|
||||
claimUnverified: 'Unverified',
|
||||
claimNotVerifiable: 'Not Verifiable',
|
||||
claimOpinion: 'Opinion',
|
||||
|
||||
// Claim Type Labels
|
||||
claimFactual: 'Factual',
|
||||
claimCausal: 'Causal',
|
||||
claimPredictive: 'Predictive',
|
||||
claimQuote: 'Quote',
|
||||
claimStatistical: 'Statistical',
|
||||
claimOpinionType: 'Opinion',
|
||||
claimOpinionAsFact: 'Opinion as Fact',
|
||||
claimHistorical: 'Historical',
|
||||
claimAttribution: 'Attribution',
|
||||
claimRE: 'RE',
|
||||
|
||||
// AI Detection Card
|
||||
aiProbability: 'AI Probability',
|
||||
verdictLikelyAi: 'LIKELY AI',
|
||||
verdictPossiblyAi: 'POSSIBLY AI',
|
||||
verdictMixed: 'MIXED',
|
||||
verdictLikelyHuman: 'LIKELY HUMAN',
|
||||
verdictUnknown: 'UNKNOWN',
|
||||
aiDisclosureFound: 'AI Disclosure Found',
|
||||
indicators: 'Indicators',
|
||||
|
||||
// Domain Card
|
||||
domainAnalysis: 'Domain Analysis',
|
||||
trustScore: 'Trust Score',
|
||||
domainAge: 'Domain Age',
|
||||
days: 'days',
|
||||
ssl: 'SSL',
|
||||
sslValid: 'Valid',
|
||||
sslInvalid: 'Invalid',
|
||||
blacklisted: 'Blacklisted',
|
||||
yes: 'Yes',
|
||||
no: 'No',
|
||||
registrar: 'Registrar',
|
||||
organization: 'Organization',
|
||||
country: 'Country',
|
||||
redFlags: 'Red Flags',
|
||||
warnings: 'Warnings',
|
||||
|
||||
// Source Assessment Card
|
||||
risk: 'Risk',
|
||||
publication: 'Publication',
|
||||
author: 'Author',
|
||||
platform: 'Platform',
|
||||
domain: 'Domain',
|
||||
confirmed: 'Confirmed',
|
||||
unconfirmed: 'Unconfirmed',
|
||||
scoreBreakdown: 'Score Breakdown',
|
||||
|
||||
// Techniques Card
|
||||
manipulationScore: 'Manipulation Score',
|
||||
techniquesDetected: '{count} techniques detected',
|
||||
noTechniquesDetected: 'No manipulation techniques detected',
|
||||
tapForDetails: 'Tap for details',
|
||||
tapToCollapse: 'Tap to collapse',
|
||||
severity: 'Severity',
|
||||
description: 'Description',
|
||||
|
||||
// Virality Card
|
||||
viralityPotential: 'Virality Potential',
|
||||
showFactors: 'Show {count} factors',
|
||||
hideFactors: 'Hide factors',
|
||||
|
||||
// Did You Know Card
|
||||
didYouKnow: 'Did you know?',
|
||||
|
||||
// Settings
|
||||
profile: 'Profile',
|
||||
username: 'Username',
|
||||
firstName: 'First Name',
|
||||
lastName: 'Last Name',
|
||||
email: 'Email',
|
||||
updatePassword: 'Update your account password',
|
||||
subscriptionCredits: 'Subscription & Credits',
|
||||
active: 'Active',
|
||||
inactive: 'Inactive',
|
||||
credits: 'Credits',
|
||||
remaining: 'Remaining',
|
||||
spent: 'Spent',
|
||||
planLimits: 'Plan Limits',
|
||||
creditsCycle: 'Credits/cycle',
|
||||
maxImages: 'Max images',
|
||||
videoMinutes: 'Video minutes',
|
||||
storage: 'Storage',
|
||||
language: 'Language',
|
||||
languageSubtitle: 'Choose your preferred language',
|
||||
english: 'English',
|
||||
romanian: 'Română',
|
||||
|
||||
// Social Share
|
||||
socialShare: 'Social Share',
|
||||
socialShareDesc: 'When enabled, URLs shared from these platforms will be automatically analyzed. When disabled, the URL will be pre-filled but you must tap Analyze manually.',
|
||||
facebook: 'Facebook',
|
||||
autoAnalyzeFacebook: 'Auto-analyze shared Facebook links',
|
||||
xTwitter: 'X (Twitter)',
|
||||
autoAnalyzeX: 'Auto-analyze shared X/Twitter links',
|
||||
tiktok: 'TikTok',
|
||||
autoAnalyzeTiktok: 'Auto-analyze shared TikTok links',
|
||||
socialShareNote: 'Share a URL from any social app and didi will appear in your device\'s share menu. Requires an EAS build — not available in Expo Go.',
|
||||
|
||||
// User Profile Widget
|
||||
creditsLabel: '{count} credits',
|
||||
perMonth: '/mo',
|
||||
|
||||
// Notifications
|
||||
analysisComplete: 'Analysis Complete',
|
||||
notifRiskScore: 'Risk: {score}/100 ({category})',
|
||||
tapToViewResults: 'Tap to view results',
|
||||
|
||||
// Validation
|
||||
valMinChars: 'Minimum 20 characters ({len}/20)',
|
||||
valMaxChars: 'Maximum 50,000 characters exceeded',
|
||||
valUrlTooLong: 'URL too long',
|
||||
valUrlMustStart: 'URL must start with http://, https://, or www.',
|
||||
valInvalidUrl: 'Invalid URL format',
|
||||
valUrlHostname: 'URL must contain a hostname',
|
||||
valUrlNotAllowed: 'URL not allowed',
|
||||
valUrlValidDomain: 'URL must contain a valid domain',
|
||||
valUrlTld: 'URL must have a valid top-level domain',
|
||||
valUrlInvalidHost: 'URL contains an invalid hostname segment',
|
||||
valInvalidDomain: 'Invalid domain format',
|
||||
valFileEmpty: 'File is empty',
|
||||
valFileTooLarge: 'File too large ({size}). Maximum: {maxSize}',
|
||||
valFileTypeNotAllowed: 'File type .{ext} is not allowed',
|
||||
valAcceptedFormats: 'Accepted {category} formats: {formats}',
|
||||
valAudioTooLong: 'Audio too long ({duration}s). Maximum: {max}s',
|
||||
valVideoTooLong: 'Video too long ({duration}s). Maximum: {max}s',
|
||||
|
||||
// Share Action
|
||||
analyzeUrl: 'Analyze URL',
|
||||
analyzeText: 'Analyze Text',
|
||||
analyzeImage: 'Analyze Image',
|
||||
chooseAnalysisType: 'Choose analysis type',
|
||||
url: 'URL',
|
||||
sharedTextLabel: 'Shared text',
|
||||
sharedImageLabel: 'Shared image',
|
||||
domainAnalysisSkill: 'Domain Analysis',
|
||||
checkDomainReputation: 'Check website trust and domain reputation',
|
||||
|
||||
// Upload consent
|
||||
uploadConsentTitle: 'Consent to media upload',
|
||||
uploadConsentBody: 'To analyze this file, it will be uploaded to the DiDi analysis servers. The file is used exclusively to run the requested analysis, is processed confidentially and is not shared with third parties. You can delete the analysis (and the associated content) at any time from History.',
|
||||
uploadConsentCheckbox: 'I agree that my file will be uploaded and processed for analysis, as described in the privacy note above.',
|
||||
uploadConsentAccept: 'I agree, continue',
|
||||
uploadConsentReminder: 'By submitting, your file is uploaded for analysis according to your previously given consent.',
|
||||
|
||||
// Dimension Names
|
||||
dimContentPackaging: 'Content & Packaging',
|
||||
dimSourceAttribution: 'Source & Attribution',
|
||||
dimMediaSynthetic: 'Media & Synthetic',
|
||||
dimEmotionalPsych: 'Emotional & Psychological',
|
||||
dimSocialNetwork: 'Social & Network',
|
||||
dimPlatformTech: 'Platform & Technical',
|
||||
dimTemporalContext: 'Temporal & Context',
|
||||
dimTargetingVuln: 'Targeting & Vulnerability',
|
||||
|
||||
// Editorial layout
|
||||
inputLabel: 'Content ({type})',
|
||||
inputAnalyzed: 'Analyzed input',
|
||||
riskAnalyzed: 'Risk analyzed',
|
||||
topIssue: 'Top issue',
|
||||
otherSignals: 'Other signals',
|
||||
technicalDetails: 'Technical details',
|
||||
doNotShare: 'Do not share',
|
||||
readCritically: 'Read critically',
|
||||
doNotShareHeadline: 'Do not share this article',
|
||||
readCarefullyHeadline: 'Read carefully',
|
||||
noteHeadline: 'Note',
|
||||
showMore: 'Show more',
|
||||
hide: 'Hide',
|
||||
showOtherSignals: 'Show {count} more {plural}',
|
||||
hideOtherSignals: 'Hide other signals',
|
||||
cleanContent: 'Clean content',
|
||||
noManipulationDetected: 'No manipulation techniques detected',
|
||||
noClaimsToVerify: 'No verifiable claims found',
|
||||
trustedSourceLabel: 'Trusted',
|
||||
neutralSourceLabel: 'Neutral',
|
||||
suspiciousSourceLabel: 'Suspicious',
|
||||
untrustedSourceLabel: 'Untrusted',
|
||||
withAiDisclosure: 'with AI disclosure',
|
||||
withoutAiDisclosure: 'without AI disclosure',
|
||||
};
|
||||
|
||||
export type TranslationKeys = keyof typeof en;
|
||||
export default en;
|
||||
117
android/didi-app-source/src/i18n/enums.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
/** Human-readable English labels for backend enum codes (Strat 3) */
|
||||
export const ENUM_EN: Record<string, string> = {
|
||||
// Risk categories
|
||||
DISINFORMATION: 'Disinformation',
|
||||
UNRELIABLE: 'Unreliable',
|
||||
QUESTIONABLE: 'Questionable',
|
||||
UNCERTAIN: 'Uncertain',
|
||||
MIXED: 'Mixed',
|
||||
MOSTLY_TRUSTWORTHY: 'Mostly Trustworthy',
|
||||
MOSTLY_RELIABLE: 'Mostly Reliable',
|
||||
TRUSTWORTHY: 'Trustworthy',
|
||||
RELIABLE: 'Reliable',
|
||||
INCONCLUSIVE: 'Inconclusive',
|
||||
|
||||
// Severity / confidence levels
|
||||
CRITICAL: 'Critical',
|
||||
VERY_HIGH: 'Very High',
|
||||
HIGH: 'High',
|
||||
MODERATE: 'Moderate',
|
||||
MEDIUM: 'Medium',
|
||||
LOW: 'Low',
|
||||
VERY_LOW: 'Very Low',
|
||||
|
||||
// AI detection verdicts
|
||||
LIKELY_AI: 'Likely AI',
|
||||
POSSIBLY_AI: 'Possibly AI',
|
||||
UNLIKELY_AI: 'Unlikely AI',
|
||||
POSSIBLY_HUMAN: 'Possibly Human',
|
||||
LIKELY_HUMAN: 'Likely Human',
|
||||
HUMAN: 'Human',
|
||||
|
||||
// Source assessment verdicts
|
||||
TRUSTED: 'Trusted',
|
||||
NEUTRAL: 'Neutral',
|
||||
SUSPICIOUS: 'Suspicious',
|
||||
UNTRUSTED: 'Untrusted',
|
||||
|
||||
// Claim source stance
|
||||
SUPPORTS: 'Supports',
|
||||
CONTRADICTS: 'Contradicts',
|
||||
|
||||
// Source reliability
|
||||
official: 'Official',
|
||||
news: 'News',
|
||||
blog: 'Blog',
|
||||
unknown: 'Unknown',
|
||||
|
||||
// Recommended actions
|
||||
NONE: 'None',
|
||||
CAUTION: 'Caution',
|
||||
WARNING: 'Warning',
|
||||
URGENT: 'Urgent',
|
||||
ESCALATE: 'Escalate',
|
||||
REVIEW: 'Review',
|
||||
|
||||
// Virality
|
||||
VIRAL: 'Viral',
|
||||
};
|
||||
|
||||
/** Romanian translations for fixed backend enum values (Strat 3) */
|
||||
export const ENUM_RO: Record<string, string> = {
|
||||
// Risk categories
|
||||
DISINFORMATION: 'Dezinformare',
|
||||
UNRELIABLE: 'Nedemn de încredere',
|
||||
QUESTIONABLE: 'Îndoielnic',
|
||||
UNCERTAIN: 'Incert',
|
||||
MIXED: 'Mixt',
|
||||
MOSTLY_TRUSTWORTHY: 'Preponderent credibil',
|
||||
MOSTLY_RELIABLE: 'Preponderent credibil',
|
||||
TRUSTWORTHY: 'Demn de încredere',
|
||||
RELIABLE: 'De încredere',
|
||||
INCONCLUSIVE: 'Neconcludent',
|
||||
|
||||
// Severity / confidence levels
|
||||
CRITICAL: 'Critic',
|
||||
VERY_HIGH: 'Foarte ridicat',
|
||||
HIGH: 'Ridicat',
|
||||
MODERATE: 'Moderat',
|
||||
MEDIUM: 'Mediu',
|
||||
LOW: 'Scăzut',
|
||||
VERY_LOW: 'Foarte scăzut',
|
||||
|
||||
// AI detection verdicts
|
||||
LIKELY_AI: 'Probabil AI',
|
||||
POSSIBLY_AI: 'Posibil AI',
|
||||
UNLIKELY_AI: 'Improbabil AI',
|
||||
POSSIBLY_HUMAN: 'Posibil uman',
|
||||
LIKELY_HUMAN: 'Probabil uman',
|
||||
HUMAN: 'Uman',
|
||||
|
||||
// Source assessment verdicts
|
||||
TRUSTED: 'De încredere',
|
||||
NEUTRAL: 'Neutru',
|
||||
SUSPICIOUS: 'Suspect',
|
||||
UNTRUSTED: 'Nedemn de încredere',
|
||||
|
||||
// Claim source stance
|
||||
SUPPORTS: 'Susține',
|
||||
CONTRADICTS: 'Contrazice',
|
||||
|
||||
// Source reliability
|
||||
official: 'Oficial',
|
||||
news: 'Știri',
|
||||
blog: 'Blog',
|
||||
unknown: 'Necunoscut',
|
||||
|
||||
// Recommended actions
|
||||
NONE: 'Niciuna',
|
||||
CAUTION: 'Precauție',
|
||||
WARNING: 'Avertizare',
|
||||
URGENT: 'Urgent',
|
||||
ESCALATE: 'Escaladează',
|
||||
REVIEW: 'Revizuire',
|
||||
|
||||
// Virality
|
||||
VIRAL: 'Viral',
|
||||
};
|
||||