mobile download
@@ -23,3 +23,5 @@
|
|||||||
|
|
||||||
# Deploy / local
|
# Deploy / local
|
||||||
*.tar.gz
|
*.tar.gz
|
||||||
|
data/mobile-releases/*.apk
|
||||||
|
scripts/deploy-target.local.env
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
VITE_API_BASE_URL=http://localhost:8080/api
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# GoldenChart Mobile App
|
||||||
|
|
||||||
|
Capacitor + React hybrid app for virtual trading, strategy editor, notifications, and settings.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# From repo root
|
||||||
|
npm install
|
||||||
|
npm run dev:app
|
||||||
|
```
|
||||||
|
|
||||||
|
## Native build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build:app
|
||||||
|
npm run cap:sync
|
||||||
|
npm run cap:android # or cap:ios
|
||||||
|
```
|
||||||
|
|
||||||
|
## exdev.co.kr APK 배포
|
||||||
|
|
||||||
|
소스는 `git push origin main` (Jenkins). APK만 로컬 빌드 후 업로드:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/upload-android-apk.sh --build --version 1.0.0
|
||||||
|
```
|
||||||
|
|
||||||
|
`app/.env` 예: `VITE_API_BASE_URL=http://exdev.co.kr/api`
|
||||||
|
|
||||||
|
자세한 내용: [`data/mobile-releases/README.md`](../data/mobile-releases/README.md)
|
||||||
|
|
||||||
|
## Environment
|
||||||
|
|
||||||
|
Copy `.env.example` to `.env` and set `VITE_API_BASE_URL`.
|
||||||
|
|
||||||
|
## FCM (Firebase)
|
||||||
|
|
||||||
|
- Android: `android/app/google-services.json`
|
||||||
|
- iOS: `ios/App/GoogleService-Info.plist` + Push Notifications capability
|
||||||
|
- Backend: `FIREBASE_ENABLED=true` + service account JSON
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
|
||||||
|
- `src/screens/` — mobile UI (virtual, strategy, notifications, settings)
|
||||||
|
- `../packages/shared/` — API client + Capacitor storage
|
||||||
|
- `../frontend/src/` — shared business logic (hooks, utils) via `@frontend` alias
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
# Using Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore
|
||||||
|
|
||||||
|
# Built application files
|
||||||
|
*.apk
|
||||||
|
*.aar
|
||||||
|
*.ap_
|
||||||
|
*.aab
|
||||||
|
|
||||||
|
# Files for the ART/Dalvik VM
|
||||||
|
*.dex
|
||||||
|
|
||||||
|
# Java class files
|
||||||
|
*.class
|
||||||
|
|
||||||
|
# Generated files
|
||||||
|
bin/
|
||||||
|
gen/
|
||||||
|
out/
|
||||||
|
# Uncomment the following line in case you need and you don't have the release build type files in your app
|
||||||
|
# release/
|
||||||
|
|
||||||
|
# Gradle files
|
||||||
|
.gradle/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# Local configuration file (sdk path, etc)
|
||||||
|
local.properties
|
||||||
|
|
||||||
|
# Proguard folder generated by Eclipse
|
||||||
|
proguard/
|
||||||
|
|
||||||
|
# Log Files
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Android Studio Navigation editor temp files
|
||||||
|
.navigation/
|
||||||
|
|
||||||
|
# Android Studio captures folder
|
||||||
|
captures/
|
||||||
|
|
||||||
|
# IntelliJ
|
||||||
|
*.iml
|
||||||
|
.idea/workspace.xml
|
||||||
|
.idea/tasks.xml
|
||||||
|
.idea/gradle.xml
|
||||||
|
.idea/assetWizardSettings.xml
|
||||||
|
.idea/dictionaries
|
||||||
|
.idea/libraries
|
||||||
|
# Android Studio 3 in .gitignore file.
|
||||||
|
.idea/caches
|
||||||
|
.idea/modules.xml
|
||||||
|
# Comment next line if keeping position of elements in Navigation Editor is relevant for you
|
||||||
|
.idea/navEditor.xml
|
||||||
|
|
||||||
|
# Keystore files
|
||||||
|
# Uncomment the following lines if you do not want to check your keystore files in.
|
||||||
|
#*.jks
|
||||||
|
#*.keystore
|
||||||
|
|
||||||
|
# External native build folder generated in Android Studio 2.2 and later
|
||||||
|
.externalNativeBuild
|
||||||
|
.cxx/
|
||||||
|
|
||||||
|
# Google Services (e.g. APIs or Firebase)
|
||||||
|
# google-services.json
|
||||||
|
|
||||||
|
# Freeline
|
||||||
|
freeline.py
|
||||||
|
freeline/
|
||||||
|
freeline_project_description.json
|
||||||
|
|
||||||
|
# fastlane
|
||||||
|
fastlane/report.xml
|
||||||
|
fastlane/Preview.html
|
||||||
|
fastlane/screenshots
|
||||||
|
fastlane/test_output
|
||||||
|
fastlane/readme.md
|
||||||
|
|
||||||
|
# Version control
|
||||||
|
vcs.xml
|
||||||
|
|
||||||
|
# lint
|
||||||
|
lint/intermediates/
|
||||||
|
lint/generated/
|
||||||
|
lint/outputs/
|
||||||
|
lint/tmp/
|
||||||
|
# lint/reports/
|
||||||
|
|
||||||
|
# Android Profiling
|
||||||
|
*.hprof
|
||||||
|
|
||||||
|
# Cordova plugins for Capacitor
|
||||||
|
capacitor-cordova-android-plugins
|
||||||
|
|
||||||
|
# Copied web assets
|
||||||
|
app/src/main/assets/public
|
||||||
|
|
||||||
|
# Generated Config files
|
||||||
|
app/src/main/assets/capacitor.config.json
|
||||||
|
app/src/main/assets/capacitor.plugins.json
|
||||||
|
app/src/main/res/xml/config.xml
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
/build/*
|
||||||
|
!/build/.npmkeep
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
apply plugin: 'com.android.application'
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace "com.goldenchart.app"
|
||||||
|
compileSdk rootProject.ext.compileSdkVersion
|
||||||
|
defaultConfig {
|
||||||
|
applicationId "com.goldenchart.app"
|
||||||
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
|
versionCode 1
|
||||||
|
versionName "1.0"
|
||||||
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
aaptOptions {
|
||||||
|
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||||
|
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
|
||||||
|
ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
minifyEnabled false
|
||||||
|
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
repositories {
|
||||||
|
flatDir{
|
||||||
|
dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation fileTree(include: ['*.jar'], dir: 'libs')
|
||||||
|
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
|
||||||
|
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
|
||||||
|
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
|
||||||
|
implementation project(':capacitor-android')
|
||||||
|
testImplementation "junit:junit:$junitVersion"
|
||||||
|
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
|
||||||
|
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
|
||||||
|
implementation project(':capacitor-cordova-android-plugins')
|
||||||
|
}
|
||||||
|
|
||||||
|
apply from: 'capacitor.build.gradle'
|
||||||
|
|
||||||
|
try {
|
||||||
|
def servicesJSON = file('google-services.json')
|
||||||
|
if (servicesJSON.text) {
|
||||||
|
apply plugin: 'com.google.gms.google-services'
|
||||||
|
}
|
||||||
|
} catch(Exception e) {
|
||||||
|
logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work")
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
|
||||||
|
|
||||||
|
android {
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility JavaVersion.VERSION_21
|
||||||
|
targetCompatibility JavaVersion.VERSION_21
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
||||||
|
dependencies {
|
||||||
|
implementation project(':capacitor-app')
|
||||||
|
implementation project(':capacitor-haptics')
|
||||||
|
implementation project(':capacitor-preferences')
|
||||||
|
implementation project(':capacitor-push-notifications')
|
||||||
|
implementation project(':capacitor-splash-screen')
|
||||||
|
implementation project(':capacitor-status-bar')
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if (hasProperty('postBuildExtras')) {
|
||||||
|
postBuildExtras()
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Add project specific ProGuard rules here.
|
||||||
|
# You can control the set of applied configuration files using the
|
||||||
|
# proguardFiles setting in build.gradle.
|
||||||
|
#
|
||||||
|
# For more details, see
|
||||||
|
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||||
|
|
||||||
|
# If your project uses WebView with JS, uncomment the following
|
||||||
|
# and specify the fully qualified class name to the JavaScript interface
|
||||||
|
# class:
|
||||||
|
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||||
|
# public *;
|
||||||
|
#}
|
||||||
|
|
||||||
|
# Uncomment this to preserve the line number information for
|
||||||
|
# debugging stack traces.
|
||||||
|
#-keepattributes SourceFile,LineNumberTable
|
||||||
|
|
||||||
|
# If you keep the line number information, uncomment this to
|
||||||
|
# hide the original source file name.
|
||||||
|
#-renamesourcefileattribute SourceFile
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package com.getcapacitor.myapp;
|
||||||
|
|
||||||
|
import static org.junit.Assert.*;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||||
|
import androidx.test.platform.app.InstrumentationRegistry;
|
||||||
|
import org.junit.Test;
|
||||||
|
import org.junit.runner.RunWith;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Instrumented test, which will execute on an Android device.
|
||||||
|
*
|
||||||
|
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
|
||||||
|
*/
|
||||||
|
@RunWith(AndroidJUnit4.class)
|
||||||
|
public class ExampleInstrumentedTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void useAppContext() throws Exception {
|
||||||
|
// Context of the app under test.
|
||||||
|
Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
|
||||||
|
|
||||||
|
assertEquals("com.getcapacitor.app", appContext.getPackageName());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:allowBackup="true"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:usesCleartextTraffic="true"
|
||||||
|
android:theme="@style/AppTheme">
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation"
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:label="@string/title_activity_main"
|
||||||
|
android:theme="@style/AppTheme.NoActionBarLaunch"
|
||||||
|
android:launchMode="singleTask"
|
||||||
|
android:exported="true">
|
||||||
|
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
<provider
|
||||||
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
android:authorities="${applicationId}.fileprovider"
|
||||||
|
android:exported="false"
|
||||||
|
android:grantUriPermissions="true">
|
||||||
|
<meta-data
|
||||||
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
|
android:resource="@xml/file_paths"></meta-data>
|
||||||
|
</provider>
|
||||||
|
</application>
|
||||||
|
|
||||||
|
<!-- Permissions -->
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package com.goldenchart.app;
|
||||||
|
|
||||||
|
import com.getcapacitor.BridgeActivity;
|
||||||
|
|
||||||
|
public class MainActivity extends BridgeActivity {}
|
||||||
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 7.7 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 17 KiB |
@@ -0,0 +1,34 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:aapt="http://schemas.android.com/aapt"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportHeight="108"
|
||||||
|
android:viewportWidth="108">
|
||||||
|
<path
|
||||||
|
android:fillType="evenOdd"
|
||||||
|
android:pathData="M32,64C32,64 38.39,52.99 44.13,50.95C51.37,48.37 70.14,49.57 70.14,49.57L108.26,87.69L108,109.01L75.97,107.97L32,64Z"
|
||||||
|
android:strokeColor="#00000000"
|
||||||
|
android:strokeWidth="1">
|
||||||
|
<aapt:attr name="android:fillColor">
|
||||||
|
<gradient
|
||||||
|
android:endX="78.5885"
|
||||||
|
android:endY="90.9159"
|
||||||
|
android:startX="48.7653"
|
||||||
|
android:startY="61.0927"
|
||||||
|
android:type="linear">
|
||||||
|
<item
|
||||||
|
android:color="#44000000"
|
||||||
|
android:offset="0.0" />
|
||||||
|
<item
|
||||||
|
android:color="#00000000"
|
||||||
|
android:offset="1.0" />
|
||||||
|
</gradient>
|
||||||
|
</aapt:attr>
|
||||||
|
</path>
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:fillType="nonZero"
|
||||||
|
android:pathData="M66.94,46.02L66.94,46.02C72.44,50.07 76,56.61 76,64L32,64C32,56.61 35.56,50.11 40.98,46.06L36.18,41.19C35.45,40.45 35.45,39.3 36.18,38.56C36.91,37.81 38.05,37.81 38.78,38.56L44.25,44.05C47.18,42.57 50.48,41.71 54,41.71C57.48,41.71 60.78,42.57 63.68,44.05L69.11,38.56C69.84,37.81 70.98,37.81 71.71,38.56C72.44,39.3 72.44,40.45 71.71,41.19L66.94,46.02ZM62.94,56.92C64.08,56.92 65,56.01 65,54.88C65,53.76 64.08,52.85 62.94,52.85C61.8,52.85 60.88,53.76 60.88,54.88C60.88,56.01 61.8,56.92 62.94,56.92ZM45.06,56.92C46.2,56.92 47.13,56.01 47.13,54.88C47.13,53.76 46.2,52.85 45.06,52.85C43.92,52.85 43,53.76 43,54.88C43,56.01 43.92,56.92 45.06,56.92Z"
|
||||||
|
android:strokeColor="#00000000"
|
||||||
|
android:strokeWidth="1" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportHeight="108"
|
||||||
|
android:viewportWidth="108">
|
||||||
|
<path
|
||||||
|
android:fillColor="#26A69A"
|
||||||
|
android:pathData="M0,0h108v108h-108z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M9,0L9,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,0L19,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M29,0L29,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M39,0L39,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M49,0L49,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M59,0L59,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M69,0L69,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M79,0L79,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M89,0L89,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M99,0L99,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,9L108,9"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,19L108,19"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,29L108,29"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,39L108,39"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,49L108,49"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,59L108,59"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,69L108,69"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,79L108,79"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,89L108,89"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,99L108,99"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,29L89,29"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,39L89,39"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,49L89,49"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,59L89,59"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,69L89,69"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,79L89,79"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M29,19L29,89"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M39,19L39,89"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M49,19L49,89"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M59,19L59,89"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M69,19L69,89"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M79,19L79,89"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
</vector>
|
||||||
|
After Width: | Height: | Size: 3.9 KiB |
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
tools:context=".MainActivity">
|
||||||
|
|
||||||
|
<WebView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent" />
|
||||||
|
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||||
@@ -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/ic_launcher_background"/>
|
||||||
|
<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/ic_launcher_background"/>
|
||||||
|
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||||
|
</adaptive-icon>
|
||||||
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 9.2 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 16 KiB |
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="ic_launcher_background">#FFFFFF</color>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version='1.0' encoding='utf-8'?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">GoldenChart</string>
|
||||||
|
<string name="title_activity_main">GoldenChart</string>
|
||||||
|
<string name="package_name">com.goldenchart.app</string>
|
||||||
|
<string name="custom_url_scheme">com.goldenchart.app</string>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
|
||||||
|
<!-- Base application theme. -->
|
||||||
|
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
|
||||||
|
<!-- Customize your theme here. -->
|
||||||
|
<item name="colorPrimary">@color/colorPrimary</item>
|
||||||
|
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
|
||||||
|
<item name="colorAccent">@color/colorAccent</item>
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||||
|
<item name="windowActionBar">false</item>
|
||||||
|
<item name="windowNoTitle">true</item>
|
||||||
|
<item name="android:background">@null</item>
|
||||||
|
</style>
|
||||||
|
|
||||||
|
|
||||||
|
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
|
||||||
|
<item name="android:background">@drawable/splash</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<external-path name="my_images" path="." />
|
||||||
|
<cache-path name="my_cache_images" path="." />
|
||||||
|
</paths>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package com.getcapacitor.myapp;
|
||||||
|
|
||||||
|
import static org.junit.Assert.*;
|
||||||
|
|
||||||
|
import org.junit.Test;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example local unit test, which will execute on the development machine (host).
|
||||||
|
*
|
||||||
|
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
|
||||||
|
*/
|
||||||
|
public class ExampleUnitTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void addition_isCorrect() throws Exception {
|
||||||
|
assertEquals(4, 2 + 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
// 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:8.7.2'
|
||||||
|
classpath 'com.google.gms:google-services:4.4.2'
|
||||||
|
|
||||||
|
// NOTE: Do not place your application dependencies here; they belong
|
||||||
|
// in the individual module build.gradle files
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
apply from: "variables.gradle"
|
||||||
|
|
||||||
|
allprojects {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
task clean(type: Delete) {
|
||||||
|
delete rootProject.buildDir
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
|
||||||
|
include ':capacitor-android'
|
||||||
|
project(':capacitor-android').projectDir = new File('../../node_modules/@capacitor/android/capacitor')
|
||||||
|
|
||||||
|
include ':capacitor-app'
|
||||||
|
project(':capacitor-app').projectDir = new File('../../node_modules/@capacitor/app/android')
|
||||||
|
|
||||||
|
include ':capacitor-haptics'
|
||||||
|
project(':capacitor-haptics').projectDir = new File('../../node_modules/@capacitor/haptics/android')
|
||||||
|
|
||||||
|
include ':capacitor-preferences'
|
||||||
|
project(':capacitor-preferences').projectDir = new File('../../node_modules/@capacitor/preferences/android')
|
||||||
|
|
||||||
|
include ':capacitor-push-notifications'
|
||||||
|
project(':capacitor-push-notifications').projectDir = new File('../../node_modules/@capacitor/push-notifications/android')
|
||||||
|
|
||||||
|
include ':capacitor-splash-screen'
|
||||||
|
project(':capacitor-splash-screen').projectDir = new File('../../node_modules/@capacitor/splash-screen/android')
|
||||||
|
|
||||||
|
include ':capacitor-status-bar'
|
||||||
|
project(':capacitor-status-bar').projectDir = new File('../../node_modules/@capacitor/status-bar/android')
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# 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.
|
||||||
|
org.gradle.jvmargs=-Xmx1536m
|
||||||
|
|
||||||
|
# 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
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip
|
||||||
|
networkTimeout=10000
|
||||||
|
validateDistributionUrl=true
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
#!/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/.
|
||||||
|
#
|
||||||
|
##############################################################################
|
||||||
|
|
||||||
|
# GoldenChart: use Temurin JDK 21 from ~/.local when JAVA_HOME is unset
|
||||||
|
if [ -z "$JAVA_HOME" ] && [ -x "$HOME/.local/jdk-21.0.11+10/Contents/Home/bin/java" ]; then
|
||||||
|
JAVA_HOME="$HOME/.local/jdk-21.0.11+10/Contents/Home"
|
||||||
|
export JAVA_HOME
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Attempt to set APP_HOME
|
||||||
|
|
||||||
|
# Resolve links: $0 may be a link
|
||||||
|
app_path=$0
|
||||||
|
|
||||||
|
# Need this for daisy-chained symlinks.
|
||||||
|
while
|
||||||
|
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||||
|
[ -h "$app_path" ]
|
||||||
|
do
|
||||||
|
ls=$( ls -ld "$app_path" )
|
||||||
|
link=${ls#*' -> '}
|
||||||
|
case $link in #(
|
||||||
|
/*) app_path=$link ;; #(
|
||||||
|
*) app_path=$APP_HOME$link ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# This is normally unused
|
||||||
|
# shellcheck disable=SC2034
|
||||||
|
APP_BASE_NAME=${0##*/}
|
||||||
|
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||||
|
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
|
||||||
|
' "$PWD" ) || exit
|
||||||
|
|
||||||
|
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||||
|
MAX_FD=maximum
|
||||||
|
|
||||||
|
warn () {
|
||||||
|
echo "$*"
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
die () {
|
||||||
|
echo
|
||||||
|
echo "$*"
|
||||||
|
echo
|
||||||
|
exit 1
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
# OS specific support (must be 'true' or 'false').
|
||||||
|
cygwin=false
|
||||||
|
msys=false
|
||||||
|
darwin=false
|
||||||
|
nonstop=false
|
||||||
|
case "$( uname )" in #(
|
||||||
|
CYGWIN* ) cygwin=true ;; #(
|
||||||
|
Darwin* ) darwin=true ;; #(
|
||||||
|
MSYS* | MINGW* ) msys=true ;; #(
|
||||||
|
NONSTOP* ) nonstop=true ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||||
|
|
||||||
|
|
||||||
|
# Determine the Java command to use to start the JVM.
|
||||||
|
if [ -n "$JAVA_HOME" ] ; then
|
||||||
|
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||||
|
# IBM's JDK on AIX uses strange locations for the executables
|
||||||
|
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||||
|
else
|
||||||
|
JAVACMD=$JAVA_HOME/bin/java
|
||||||
|
fi
|
||||||
|
if [ ! -x "$JAVACMD" ] ; then
|
||||||
|
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
JAVACMD=java
|
||||||
|
if ! command -v java >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Increase the maximum file descriptors if we can.
|
||||||
|
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||||
|
case $MAX_FD in #(
|
||||||
|
max*)
|
||||||
|
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
MAX_FD=$( ulimit -H -n ) ||
|
||||||
|
warn "Could not query maximum file descriptor limit"
|
||||||
|
esac
|
||||||
|
case $MAX_FD in #(
|
||||||
|
'' | soft) :;; #(
|
||||||
|
*)
|
||||||
|
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
ulimit -n "$MAX_FD" ||
|
||||||
|
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Collect all arguments for the java command, stacking in reverse order:
|
||||||
|
# * args from the command line
|
||||||
|
# * the main class name
|
||||||
|
# * -classpath
|
||||||
|
# * -D...appname settings
|
||||||
|
# * --module-path (only if needed)
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||||
|
|
||||||
|
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||||
|
if "$cygwin" || "$msys" ; then
|
||||||
|
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||||
|
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||||
|
|
||||||
|
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||||
|
|
||||||
|
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||||
|
for arg do
|
||||||
|
if
|
||||||
|
case $arg in #(
|
||||||
|
-*) false ;; # don't mess with options #(
|
||||||
|
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||||
|
[ -e "$t" ] ;; #(
|
||||||
|
*) false ;;
|
||||||
|
esac
|
||||||
|
then
|
||||||
|
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||||
|
fi
|
||||||
|
# Roll the args list around exactly as many times as the number of
|
||||||
|
# args, so each arg winds up back in the position where it started, but
|
||||||
|
# possibly modified.
|
||||||
|
#
|
||||||
|
# NB: a `for` loop captures its iteration list before it begins, so
|
||||||
|
# changing the positional parameters here affects neither the number of
|
||||||
|
# iterations, nor the values presented in `arg`.
|
||||||
|
shift # remove old arg
|
||||||
|
set -- "$@" "$arg" # push replacement arg
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||||
|
|
||||||
|
# Collect all arguments for the java command:
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||||
|
# and any embedded shellness will be escaped.
|
||||||
|
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||||
|
# treated as '${Hostname}' itself on the command line.
|
||||||
|
|
||||||
|
set -- \
|
||||||
|
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||||
|
-classpath "$CLASSPATH" \
|
||||||
|
org.gradle.wrapper.GradleWrapperMain \
|
||||||
|
"$@"
|
||||||
|
|
||||||
|
# Stop when "xargs" is not available.
|
||||||
|
if ! command -v xargs >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "xargs is not available"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Use "xargs" to parse quoted args.
|
||||||
|
#
|
||||||
|
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||||
|
#
|
||||||
|
# In Bash we could simply go:
|
||||||
|
#
|
||||||
|
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||||
|
# set -- "${ARGS[@]}" "$@"
|
||||||
|
#
|
||||||
|
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||||
|
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||||
|
# character that might be a shell metacharacter, then use eval to reverse
|
||||||
|
# that process (while maintaining the separation between arguments), and wrap
|
||||||
|
# the whole thing up as a single "set" statement.
|
||||||
|
#
|
||||||
|
# This will of course break if any of these variables contains a newline or
|
||||||
|
# an unmatched quote.
|
||||||
|
#
|
||||||
|
|
||||||
|
eval "set -- $(
|
||||||
|
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||||
|
xargs -n1 |
|
||||||
|
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||||
|
tr '\n' ' '
|
||||||
|
)" '"$@"'
|
||||||
|
|
||||||
|
exec "$JAVACMD" "$@"
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
@rem
|
||||||
|
@rem Copyright 2015 the original author or authors.
|
||||||
|
@rem
|
||||||
|
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
@rem you may not use this file except in compliance with the License.
|
||||||
|
@rem You may obtain a copy of the License at
|
||||||
|
@rem
|
||||||
|
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
@rem
|
||||||
|
@rem Unless required by applicable law or agreed to in writing, software
|
||||||
|
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
@rem See the License for the specific language governing permissions and
|
||||||
|
@rem limitations under the License.
|
||||||
|
@rem
|
||||||
|
@rem SPDX-License-Identifier: Apache-2.0
|
||||||
|
@rem
|
||||||
|
|
||||||
|
@if "%DEBUG%"=="" @echo off
|
||||||
|
@rem ##########################################################################
|
||||||
|
@rem
|
||||||
|
@rem Gradle startup script for Windows
|
||||||
|
@rem
|
||||||
|
@rem ##########################################################################
|
||||||
|
|
||||||
|
@rem Set local scope for the variables with windows NT shell
|
||||||
|
if "%OS%"=="Windows_NT" setlocal
|
||||||
|
|
||||||
|
set DIRNAME=%~dp0
|
||||||
|
if "%DIRNAME%"=="" set DIRNAME=.
|
||||||
|
@rem This is normally unused
|
||||||
|
set APP_BASE_NAME=%~n0
|
||||||
|
set APP_HOME=%DIRNAME%
|
||||||
|
|
||||||
|
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||||
|
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||||
|
|
||||||
|
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||||
|
|
||||||
|
@rem Find java.exe
|
||||||
|
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||||
|
|
||||||
|
set JAVA_EXE=java.exe
|
||||||
|
%JAVA_EXE% -version >NUL 2>&1
|
||||||
|
if %ERRORLEVEL% equ 0 goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:findJavaFromJavaHome
|
||||||
|
set JAVA_HOME=%JAVA_HOME:"=%
|
||||||
|
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||||
|
|
||||||
|
if exist "%JAVA_EXE%" goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:execute
|
||||||
|
@rem Setup the command line
|
||||||
|
|
||||||
|
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||||
|
|
||||||
|
|
||||||
|
@rem Execute Gradle
|
||||||
|
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||||
|
|
||||||
|
:end
|
||||||
|
@rem End local scope for the variables with windows NT shell
|
||||||
|
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||||
|
|
||||||
|
:fail
|
||||||
|
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||||
|
rem the _cmd.exe /c_ return code!
|
||||||
|
set EXIT_CODE=%ERRORLEVEL%
|
||||||
|
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||||
|
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||||
|
exit /b %EXIT_CODE%
|
||||||
|
|
||||||
|
:mainEnd
|
||||||
|
if "%OS%"=="Windows_NT" endlocal
|
||||||
|
|
||||||
|
:omega
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
include ':app'
|
||||||
|
include ':capacitor-cordova-android-plugins'
|
||||||
|
project(':capacitor-cordova-android-plugins').projectDir = new File('./capacitor-cordova-android-plugins/')
|
||||||
|
|
||||||
|
apply from: 'capacitor.settings.gradle'
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
ext {
|
||||||
|
minSdkVersion = 23
|
||||||
|
compileSdkVersion = 35
|
||||||
|
targetSdkVersion = 35
|
||||||
|
androidxActivityVersion = '1.9.2'
|
||||||
|
androidxAppCompatVersion = '1.7.0'
|
||||||
|
androidxCoordinatorLayoutVersion = '1.2.0'
|
||||||
|
androidxCoreVersion = '1.15.0'
|
||||||
|
androidxFragmentVersion = '1.8.4'
|
||||||
|
coreSplashScreenVersion = '1.0.1'
|
||||||
|
androidxWebkitVersion = '1.12.1'
|
||||||
|
junitVersion = '4.13.2'
|
||||||
|
androidxJunitVersion = '1.2.1'
|
||||||
|
androidxEspressoCoreVersion = '3.6.1'
|
||||||
|
cordovaAndroidVersion = '10.1.1'
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import type { CapacitorConfig } from '@capacitor/cli';
|
||||||
|
|
||||||
|
const config: CapacitorConfig = {
|
||||||
|
appId: 'com.goldenchart.app',
|
||||||
|
appName: 'GoldenChart',
|
||||||
|
webDir: 'dist',
|
||||||
|
server: {
|
||||||
|
androidScheme: 'https',
|
||||||
|
iosScheme: 'capacitor',
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
SplashScreen: {
|
||||||
|
launchAutoHide: true,
|
||||||
|
backgroundColor: '#0f0f23',
|
||||||
|
showSpinner: false,
|
||||||
|
},
|
||||||
|
PushNotifications: {
|
||||||
|
presentationOptions: ['badge', 'sound', 'alert'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, user-scalable=no" />
|
||||||
|
<meta name="theme-color" content="#0f0f23" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
|
<title>GoldenChart</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"name": "@goldenchart/app",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"cap:sync": "npm run build && npx cap sync",
|
||||||
|
"cap:open:android": "npx cap open android",
|
||||||
|
"cap:open:ios": "npx cap open ios"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@capacitor/android": "^7.0.0",
|
||||||
|
"@capacitor/app": "^7.0.0",
|
||||||
|
"@capacitor/core": "^7.0.0",
|
||||||
|
"@capacitor/haptics": "^7.0.0",
|
||||||
|
"@capacitor/ios": "^7.0.0",
|
||||||
|
"@capacitor/preferences": "^7.0.0",
|
||||||
|
"@capacitor/push-notifications": "^7.0.0",
|
||||||
|
"@capacitor/splash-screen": "^7.0.0",
|
||||||
|
"@capacitor/status-bar": "^7.0.0",
|
||||||
|
"@goldenchart/shared": "*",
|
||||||
|
"@stomp/stompjs": "^7.3.0",
|
||||||
|
"@xyflow/react": "^12.10.2",
|
||||||
|
"lightweight-charts": "^5.2.0",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"sockjs-client": "^1.6.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@capacitor/cli": "^7.0.0",
|
||||||
|
"@types/react": "^18.3.1",
|
||||||
|
"@types/react-dom": "^18.3.1",
|
||||||
|
"@types/sockjs-client": "^1.5.4",
|
||||||
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
|
"typescript": "^5.5.4",
|
||||||
|
"vite": "^5.4.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="g" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" style="stop-color:#8b5cf6"/>
|
||||||
|
<stop offset="100%" style="stop-color:#1e1e3f"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="512" height="512" rx="96" fill="url(#g)"/>
|
||||||
|
<text x="256" y="300" text-anchor="middle" font-size="200" font-family="system-ui,sans-serif" fill="#fff" font-weight="700">G</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 476 B |
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="colorPrimary">#8b5cf6</color>
|
||||||
|
<color name="colorPrimaryDark">#0f0f23</color>
|
||||||
|
<color name="colorAccent">#8b5cf6</color>
|
||||||
|
<color name="splash_background">#0f0f23</color>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"images": [
|
||||||
|
{
|
||||||
|
"src": "../public/icon.svg",
|
||||||
|
"type": "svg"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import React, { lazy, Suspense, useCallback, useEffect, useState } from 'react';
|
||||||
|
import { Capacitor } from '@capacitor/core';
|
||||||
|
import { StatusBar, Style } from '@capacitor/status-bar';
|
||||||
|
import { SplashScreen } from '@capacitor/splash-screen';
|
||||||
|
import { initStorage } from './lib/shared';
|
||||||
|
import { NavigationProvider, useNavigation } from './contexts/NavigationContext';
|
||||||
|
import { TradeNotificationProvider, useTradeNotification } from './contexts/TradeNotificationContext';
|
||||||
|
import { useAppSettings, resolveAppDefaults } from './hooks/useAppSettings';
|
||||||
|
import TabBar, { type TabId } from './components/TabBar';
|
||||||
|
import { initFcmPush, type FcmPayload } from './services/fcm';
|
||||||
|
import LiveSignalBridge from './components/LiveSignalBridge';
|
||||||
|
import './theme/global.css';
|
||||||
|
|
||||||
|
const VirtualTradingScreen = lazy(() => import('./screens/virtual/VirtualTradingScreen'));
|
||||||
|
const StrategyEditorScreen = lazy(() => import('./screens/strategy/StrategyEditorScreen'));
|
||||||
|
const NotificationsScreen = lazy(() => import('./screens/notifications/NotificationsScreen'));
|
||||||
|
const SettingsScreen = lazy(() => import('./screens/settings/SettingsScreen'));
|
||||||
|
|
||||||
|
function ToastStack() {
|
||||||
|
const { toastNotifications, dismissToast } = useTradeNotification();
|
||||||
|
if (toastNotifications.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<div className="toast-stack">
|
||||||
|
{toastNotifications.slice(0, 3).map(t => (
|
||||||
|
<div key={t.id} className="toast-item" role="button" tabIndex={0} onClick={() => dismissToast(t.id)}>
|
||||||
|
<strong className={t.signalType === 'BUY' ? 'text-green' : 'text-red'}>
|
||||||
|
{t.signalType === 'BUY' ? '매수' : '매도'}
|
||||||
|
</strong>
|
||||||
|
{' '}{t.market} · ₩{t.price?.toLocaleString()}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AppInner() {
|
||||||
|
const { tab, setTab, openVirtualFocus } = useNavigation();
|
||||||
|
const { addNotification, refreshHistory, unreadCount } = useTradeNotification();
|
||||||
|
const { settings, isLoaded } = useAppSettings();
|
||||||
|
const defaults = resolveAppDefaults(settings);
|
||||||
|
const [ready, setReady] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void (async () => {
|
||||||
|
await initStorage();
|
||||||
|
if (Capacitor.isNativePlatform()) {
|
||||||
|
try {
|
||||||
|
await StatusBar.setStyle({ style: Style.Dark });
|
||||||
|
await StatusBar.setBackgroundColor({ color: '#0f0f23' });
|
||||||
|
await SplashScreen.hide();
|
||||||
|
} catch { /* web */ }
|
||||||
|
}
|
||||||
|
setReady(true);
|
||||||
|
})();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLoaded || !defaults.fcmPushEnabled) return;
|
||||||
|
void initFcmPush({
|
||||||
|
onForeground: (title, body, data) => {
|
||||||
|
if (data?.market && data?.signalType) {
|
||||||
|
addNotification({
|
||||||
|
market: data.market,
|
||||||
|
signalType: data.signalType,
|
||||||
|
price: Number(data.price) || 0,
|
||||||
|
candleTime: Math.floor(Date.now() / 1000),
|
||||||
|
dbId: data.signalId ? Number(data.signalId) : undefined,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
void refreshHistory();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onTap: (data: FcmPayload) => {
|
||||||
|
if (data.market) {
|
||||||
|
setTab('virtual');
|
||||||
|
openVirtualFocus(data.market);
|
||||||
|
} else {
|
||||||
|
setTab('notifications');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [isLoaded, defaults.fcmPushEnabled, addNotification, refreshHistory, setTab, openVirtualFocus]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.documentElement.setAttribute('data-theme', defaults.theme ?? 'dark');
|
||||||
|
}, [defaults.theme]);
|
||||||
|
|
||||||
|
if (!ready) {
|
||||||
|
return <div className="loading-center">GoldenChart</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const screens: Record<TabId, React.ReactNode> = {
|
||||||
|
virtual: <VirtualTradingScreen />,
|
||||||
|
strategy: <StrategyEditorScreen />,
|
||||||
|
notifications: <NotificationsScreen />,
|
||||||
|
settings: <SettingsScreen />,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app-shell">
|
||||||
|
<LiveSignalBridge />
|
||||||
|
<ToastStack />
|
||||||
|
<main className="app-content">
|
||||||
|
<Suspense fallback={<div className="loading-center">로딩…</div>}>
|
||||||
|
{screens[tab]}
|
||||||
|
</Suspense>
|
||||||
|
</main>
|
||||||
|
<TabBar
|
||||||
|
active={tab}
|
||||||
|
onChange={setTab}
|
||||||
|
unreadCount={unreadCount}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<NavigationProvider>
|
||||||
|
<TradeNotificationProvider soundEnabled={true}>
|
||||||
|
<AppInner />
|
||||||
|
</TradeNotificationProvider>
|
||||||
|
</NavigationProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import React, { useEffect } from 'react';
|
||||||
|
|
||||||
|
interface BottomSheetProps {
|
||||||
|
open: boolean;
|
||||||
|
title?: string;
|
||||||
|
onClose: () => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
height?: 'auto' | 'half' | 'full';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BottomSheet({
|
||||||
|
open,
|
||||||
|
title,
|
||||||
|
onClose,
|
||||||
|
children,
|
||||||
|
height = 'auto',
|
||||||
|
}: BottomSheetProps) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const prev = document.body.style.overflow;
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
return () => { document.body.style.overflow = prev; };
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bs-root" role="dialog" aria-modal="true">
|
||||||
|
<button type="button" className="bs-backdrop" aria-label="닫기" onClick={onClose} />
|
||||||
|
<div className={`bs-panel bs-${height}`}>
|
||||||
|
<div className="bs-handle" />
|
||||||
|
{title && (
|
||||||
|
<div className="bs-header">
|
||||||
|
<h2>{title}</h2>
|
||||||
|
<button type="button" className="bs-close" onClick={onClose} aria-label="닫기">✕</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="bs-body">{children}</div>
|
||||||
|
</div>
|
||||||
|
<style>{`
|
||||||
|
.bs-root {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 150;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.bs-backdrop {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
border: none;
|
||||||
|
min-height: auto;
|
||||||
|
}
|
||||||
|
.bs-panel {
|
||||||
|
position: relative;
|
||||||
|
background: var(--gc-bg-elevated);
|
||||||
|
border-radius: 20px 20px 0 0;
|
||||||
|
max-height: 90vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
animation: bsUp 0.28s ease;
|
||||||
|
}
|
||||||
|
.bs-auto { max-height: 70vh; }
|
||||||
|
.bs-half { height: 55vh; }
|
||||||
|
.bs-full { height: calc(100% - var(--gc-safe-top) - 24px); }
|
||||||
|
.bs-handle {
|
||||||
|
width: 36px;
|
||||||
|
height: 4px;
|
||||||
|
background: var(--gc-border);
|
||||||
|
border-radius: 2px;
|
||||||
|
margin: 8px auto 4px;
|
||||||
|
}
|
||||||
|
.bs-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 8px 16px 12px;
|
||||||
|
border-bottom: 1px solid var(--gc-border);
|
||||||
|
}
|
||||||
|
.bs-header h2 {
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.bs-close {
|
||||||
|
min-height: 36px;
|
||||||
|
min-width: 36px;
|
||||||
|
font-size: 18px;
|
||||||
|
color: var(--gc-text-muted);
|
||||||
|
}
|
||||||
|
.bs-body {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 16px;
|
||||||
|
padding-bottom: calc(16px + var(--gc-safe-bottom));
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
@keyframes bsUp {
|
||||||
|
from { transform: translateY(100%); }
|
||||||
|
to { transform: translateY(0); }
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { loadVirtualTargets, loadVirtualSession } from '@frontend/utils/virtualTradingStorage';
|
||||||
|
import { useLiveStrategyMarkers } from '@frontend/hooks/useLiveStrategyMarkers';
|
||||||
|
import { useTradeNotification } from '../contexts/TradeNotificationContext';
|
||||||
|
|
||||||
|
/** 가상매매 대상 종목 STOMP 시그널 → 알림 Context */
|
||||||
|
export default function LiveSignalBridge() {
|
||||||
|
const { addNotification, refreshHistory } = useTradeNotification();
|
||||||
|
const targets = loadVirtualTargets();
|
||||||
|
const session = loadVirtualSession();
|
||||||
|
const markets = targets.map(t => t.market);
|
||||||
|
|
||||||
|
useLiveStrategyMarkers({
|
||||||
|
markets,
|
||||||
|
activeMarket: markets[0] ?? 'KRW-BTC',
|
||||||
|
enabled: session.running && markets.length > 0,
|
||||||
|
onMarkersChange: () => {},
|
||||||
|
onSignal: marker => {
|
||||||
|
addNotification({
|
||||||
|
market: marker.market,
|
||||||
|
signalType: marker.signal,
|
||||||
|
price: marker.price,
|
||||||
|
candleTime: marker.time,
|
||||||
|
candleType: marker.candleType ?? '1m',
|
||||||
|
});
|
||||||
|
window.setTimeout(() => { void refreshHistory(); }, 800);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!session.running) return;
|
||||||
|
const id = window.setInterval(() => { void refreshHistory(); }, 20000);
|
||||||
|
return () => window.clearInterval(id);
|
||||||
|
}, [session.running, refreshHistory]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface SegmentedControlProps<T extends string> {
|
||||||
|
options: { value: T; label: string }[];
|
||||||
|
value: T;
|
||||||
|
onChange: (v: T) => void;
|
||||||
|
size?: 'sm' | 'md';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SegmentedControl<T extends string>({
|
||||||
|
options,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
size = 'md',
|
||||||
|
}: SegmentedControlProps<T>) {
|
||||||
|
return (
|
||||||
|
<div className={`seg seg-${size}`} role="tablist">
|
||||||
|
{options.map(opt => (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={value === opt.value}
|
||||||
|
className={`seg-item${value === opt.value ? ' active' : ''}`}
|
||||||
|
onClick={() => onChange(opt.value)}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<style>{`
|
||||||
|
.seg {
|
||||||
|
display: flex;
|
||||||
|
background: rgba(255,255,255,0.06);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 3px;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
.seg-sm .seg-item { font-size: 11px; padding: 6px 8px; }
|
||||||
|
.seg-md .seg-item { font-size: 12px; padding: 8px 10px; }
|
||||||
|
.seg-item {
|
||||||
|
flex: 1;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--gc-text-muted);
|
||||||
|
font-weight: 500;
|
||||||
|
min-height: 32px;
|
||||||
|
transition: background 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
.seg-item.active {
|
||||||
|
background: var(--gc-accent-soft);
|
||||||
|
color: var(--gc-accent);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface SettingRowProps {
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SettingGroup({ title, children }: { title?: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<section className="setting-group">
|
||||||
|
{title && <h3 className="setting-group-title">{title}</h3>}
|
||||||
|
<div className="setting-group-body">{children}</div>
|
||||||
|
<style>{`
|
||||||
|
.setting-group { margin-bottom: 24px; }
|
||||||
|
.setting-group-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--gc-text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
padding: 0 16px 8px;
|
||||||
|
}
|
||||||
|
.setting-group-body {
|
||||||
|
background: var(--gc-bg-elevated);
|
||||||
|
border-radius: var(--gc-radius-md);
|
||||||
|
margin: 0 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--gc-border);
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SettingRow({ label, description, children }: SettingRowProps) {
|
||||||
|
return (
|
||||||
|
<div className="setting-row">
|
||||||
|
<div className="setting-row-text">
|
||||||
|
<span className="setting-row-label">{label}</span>
|
||||||
|
{description && <span className="setting-row-desc">{description}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="setting-row-control">{children}</div>
|
||||||
|
<style>{`
|
||||||
|
.setting-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid var(--gc-border);
|
||||||
|
min-height: var(--gc-touch);
|
||||||
|
}
|
||||||
|
.setting-row:last-child { border-bottom: none; }
|
||||||
|
.setting-row-text { flex: 1; min-width: 0; }
|
||||||
|
.setting-row-label { display: block; font-size: 15px; font-weight: 500; }
|
||||||
|
.setting-row-desc { display: block; font-size: 12px; color: var(--gc-text-muted); margin-top: 2px; }
|
||||||
|
.setting-row-control { flex-shrink: 0; }
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Toggle({ checked, onChange, label }: { checked: boolean; onChange: (v: boolean) => void; label?: string }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={checked}
|
||||||
|
aria-label={label}
|
||||||
|
className={`toggle${checked ? ' on' : ''}`}
|
||||||
|
onClick={() => onChange(!checked)}
|
||||||
|
>
|
||||||
|
<span className="toggle-thumb" />
|
||||||
|
<style>{`
|
||||||
|
.toggle {
|
||||||
|
width: 50px;
|
||||||
|
height: 30px;
|
||||||
|
border-radius: 15px;
|
||||||
|
background: rgba(255,255,255,0.15);
|
||||||
|
position: relative;
|
||||||
|
min-height: 30px;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
.toggle.on { background: var(--gc-accent); }
|
||||||
|
.toggle-thumb {
|
||||||
|
position: absolute;
|
||||||
|
top: 3px;
|
||||||
|
left: 3px;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #fff;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
|
||||||
|
}
|
||||||
|
.toggle.on .toggle-thumb { transform: translateX(20px); }
|
||||||
|
`}</style>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export type TabId = 'virtual' | 'strategy' | 'notifications' | 'settings';
|
||||||
|
|
||||||
|
interface TabBarProps {
|
||||||
|
active: TabId;
|
||||||
|
onChange: (tab: TabId) => void;
|
||||||
|
unreadCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TABS: { id: TabId; label: string; icon: string }[] = [
|
||||||
|
{ id: 'virtual', label: '가상매매', icon: '📊' },
|
||||||
|
{ id: 'strategy', label: '전략', icon: '🔀' },
|
||||||
|
{ id: 'notifications', label: '알림', icon: '🔔' },
|
||||||
|
{ id: 'settings', label: '설정', icon: '⚙️' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function TabBar({ active, onChange, unreadCount }: TabBarProps) {
|
||||||
|
return (
|
||||||
|
<nav className="tab-bar" role="tablist">
|
||||||
|
{TABS.map(tab => (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={active === tab.id}
|
||||||
|
className={`tab-bar-item${active === tab.id ? ' active' : ''}`}
|
||||||
|
onClick={() => onChange(tab.id)}
|
||||||
|
>
|
||||||
|
<span className="tab-bar-icon" aria-hidden>
|
||||||
|
{tab.icon}
|
||||||
|
{tab.id === 'notifications' && unreadCount > 0 && (
|
||||||
|
<span className="tab-bar-badge">{unreadCount > 99 ? '99+' : unreadCount}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="tab-bar-label">{tab.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<style>{`
|
||||||
|
.tab-bar {
|
||||||
|
display: flex;
|
||||||
|
height: calc(var(--gc-tab-height) + var(--gc-safe-bottom));
|
||||||
|
padding-bottom: var(--gc-safe-bottom);
|
||||||
|
background: var(--gc-bg-elevated);
|
||||||
|
border-top: 1px solid var(--gc-border);
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
.tab-bar-item {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 2px;
|
||||||
|
color: var(--gc-text-dim);
|
||||||
|
min-height: var(--gc-tab-height);
|
||||||
|
}
|
||||||
|
.tab-bar-item.active { color: var(--gc-accent); }
|
||||||
|
.tab-bar-icon {
|
||||||
|
position: relative;
|
||||||
|
font-size: 20px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.tab-bar-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: -6px;
|
||||||
|
right: -10px;
|
||||||
|
min-width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
padding: 0 4px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--gc-red);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.tab-bar-label {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import React, { createContext, useContext, useMemo, useState, useCallback } from 'react';
|
||||||
|
import type { TabId } from '../components/TabBar';
|
||||||
|
|
||||||
|
interface NavContextValue {
|
||||||
|
tab: TabId;
|
||||||
|
setTab: (t: TabId) => void;
|
||||||
|
focusMarket: string | null;
|
||||||
|
openVirtualFocus: (market: string) => void;
|
||||||
|
clearVirtualFocus: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NavContext = createContext<NavContextValue | null>(null);
|
||||||
|
|
||||||
|
export function NavigationProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [tab, setTab] = useState<TabId>('virtual');
|
||||||
|
const [focusMarket, setFocusMarket] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const openVirtualFocus = useCallback((market: string) => {
|
||||||
|
setFocusMarket(market);
|
||||||
|
setTab('virtual');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearVirtualFocus = useCallback(() => setFocusMarket(null), []);
|
||||||
|
|
||||||
|
const value = useMemo(
|
||||||
|
() => ({ tab, setTab, focusMarket, openVirtualFocus, clearVirtualFocus }),
|
||||||
|
[tab, focusMarket, openVirtualFocus, clearVirtualFocus],
|
||||||
|
);
|
||||||
|
|
||||||
|
return <NavContext.Provider value={value}>{children}</NavContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useNavigation() {
|
||||||
|
const ctx = useContext(NavContext);
|
||||||
|
if (!ctx) throw new Error('useNavigation requires NavigationProvider');
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
import {
|
||||||
|
deleteAllTradeSignals,
|
||||||
|
deleteTradeSignal,
|
||||||
|
deleteTradeSignalsBatch,
|
||||||
|
loadTradeSignals,
|
||||||
|
type TradeSignalDto,
|
||||||
|
} from '../lib/shared';
|
||||||
|
import { getUiPreferences, patchUiPreferences } from '@frontend/utils/uiPreferencesDb';
|
||||||
|
import React, {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
|
||||||
|
export interface TradeSignalInfo {
|
||||||
|
market: string;
|
||||||
|
signalType: string;
|
||||||
|
price: number;
|
||||||
|
candleTime: number;
|
||||||
|
strategyName?: string;
|
||||||
|
strategyId?: number;
|
||||||
|
executionType?: string;
|
||||||
|
candleType?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TradeNotificationItem extends TradeSignalInfo {
|
||||||
|
id: string;
|
||||||
|
dbId?: number;
|
||||||
|
isRead: boolean;
|
||||||
|
receivedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TradeNotificationContextValue {
|
||||||
|
toastNotifications: TradeNotificationItem[];
|
||||||
|
allNotifications: TradeNotificationItem[];
|
||||||
|
unreadCount: number;
|
||||||
|
addNotification: (signal: TradeSignalInfo & { dbId?: number }) => void;
|
||||||
|
dismissToast: (id: string) => void;
|
||||||
|
dismissAllToasts: () => void;
|
||||||
|
markAsRead: (id: string) => void;
|
||||||
|
markAllAsRead: () => void;
|
||||||
|
deleteNotification: (id: string) => Promise<void>;
|
||||||
|
deleteAllNotifications: () => Promise<void>;
|
||||||
|
refreshHistory: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TradeNotificationContext = createContext<TradeNotificationContextValue | null>(null);
|
||||||
|
|
||||||
|
function makeId(signal: Pick<TradeSignalInfo, 'market' | 'candleTime' | 'signalType'>): string {
|
||||||
|
return `${signal.market}:${signal.candleTime}:${signal.signalType}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadReadIds(): Set<string> {
|
||||||
|
return new Set(getUiPreferences().tradeNotifications?.readIds ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveReadIds(ids: Set<string>) {
|
||||||
|
patchUiPreferences({ tradeNotifications: { readIds: [...ids] } });
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadHiddenIds(): Set<string> {
|
||||||
|
return new Set(getUiPreferences().tradeNotifications?.hiddenIds ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveHiddenIds(ids: Set<string>) {
|
||||||
|
patchUiPreferences({ tradeNotifications: { hiddenIds: [...ids] } });
|
||||||
|
}
|
||||||
|
|
||||||
|
function dtoToItem(dto: TradeSignalDto, readIds: Set<string>): TradeNotificationItem {
|
||||||
|
const id = `${dto.market}:${dto.candleTime}:${dto.signalType}`;
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
dbId: dto.id,
|
||||||
|
market: dto.market,
|
||||||
|
signalType: dto.signalType,
|
||||||
|
price: dto.price,
|
||||||
|
candleTime: dto.candleTime,
|
||||||
|
strategyName: dto.strategyName,
|
||||||
|
strategyId: dto.strategyId,
|
||||||
|
executionType: dto.executionType,
|
||||||
|
candleType: dto.candleType,
|
||||||
|
isRead: readIds.has(id),
|
||||||
|
receivedAt: new Date(dto.createdAt).getTime() || dto.candleTime * 1000,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTradeNotification() {
|
||||||
|
const ctx = useContext(TradeNotificationContext);
|
||||||
|
if (!ctx) throw new Error('useTradeNotification requires provider');
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TradeNotificationProvider({
|
||||||
|
children,
|
||||||
|
soundEnabled = true,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
soundEnabled?: boolean;
|
||||||
|
}) {
|
||||||
|
const [toasts, setToasts] = useState<TradeNotificationItem[]>([]);
|
||||||
|
const [all, setAll] = useState<TradeNotificationItem[]>([]);
|
||||||
|
const readRef = useRef(loadReadIds());
|
||||||
|
const hiddenRef = useRef(loadHiddenIds());
|
||||||
|
|
||||||
|
const refreshHistory = useCallback(async () => {
|
||||||
|
readRef.current = loadReadIds();
|
||||||
|
hiddenRef.current = loadHiddenIds();
|
||||||
|
const dtos = await loadTradeSignals();
|
||||||
|
const items = (dtos ?? [])
|
||||||
|
.map(d => dtoToItem(d, readRef.current))
|
||||||
|
.filter(i => !hiddenRef.current.has(i.id))
|
||||||
|
.sort((a, b) => b.receivedAt - a.receivedAt);
|
||||||
|
setAll(items);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refreshHistory();
|
||||||
|
const t = setInterval(() => void refreshHistory(), 20000);
|
||||||
|
return () => clearInterval(t);
|
||||||
|
}, [refreshHistory]);
|
||||||
|
|
||||||
|
const addNotification = useCallback((signal: TradeSignalInfo & { dbId?: number }) => {
|
||||||
|
const id = makeId(signal);
|
||||||
|
if (hiddenRef.current.has(id)) return;
|
||||||
|
const item: TradeNotificationItem = {
|
||||||
|
...signal,
|
||||||
|
id,
|
||||||
|
isRead: readRef.current.has(id),
|
||||||
|
receivedAt: Date.now(),
|
||||||
|
};
|
||||||
|
setAll(prev => {
|
||||||
|
const filtered = prev.filter(p => p.id !== id);
|
||||||
|
return [item, ...filtered].slice(0, 300);
|
||||||
|
});
|
||||||
|
if (!item.isRead) {
|
||||||
|
setToasts(prev => [item, ...prev.filter(p => p.id !== id)].slice(0, 50));
|
||||||
|
if (soundEnabled && typeof navigator !== 'undefined' && 'vibrate' in navigator) {
|
||||||
|
navigator.vibrate(80);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [soundEnabled]);
|
||||||
|
|
||||||
|
const dismissToast = useCallback((id: string) => {
|
||||||
|
setToasts(prev => prev.filter(t => t.id !== id));
|
||||||
|
readRef.current.add(id);
|
||||||
|
saveReadIds(readRef.current);
|
||||||
|
setAll(prev => prev.map(n => (n.id === id ? { ...n, isRead: true } : n)));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const dismissAllToasts = useCallback(() => {
|
||||||
|
setToasts(prev => {
|
||||||
|
prev.forEach(t => readRef.current.add(t.id));
|
||||||
|
saveReadIds(readRef.current);
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
setAll(prev => prev.map(n => ({ ...n, isRead: true })));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const markAsRead = useCallback((id: string) => {
|
||||||
|
readRef.current.add(id);
|
||||||
|
saveReadIds(readRef.current);
|
||||||
|
setAll(prev => prev.map(n => (n.id === id ? { ...n, isRead: true } : n)));
|
||||||
|
setToasts(prev => prev.filter(t => t.id !== id));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const markAllAsRead = useCallback(() => {
|
||||||
|
all.forEach(n => readRef.current.add(n.id));
|
||||||
|
saveReadIds(readRef.current);
|
||||||
|
setAll(prev => prev.map(n => ({ ...n, isRead: true })));
|
||||||
|
setToasts([]);
|
||||||
|
}, [all]);
|
||||||
|
|
||||||
|
const deleteNotification = useCallback(async (id: string) => {
|
||||||
|
const item = all.find(n => n.id === id);
|
||||||
|
hiddenRef.current.add(id);
|
||||||
|
saveHiddenIds(hiddenRef.current);
|
||||||
|
if (item?.dbId) await deleteTradeSignal(item.dbId);
|
||||||
|
setAll(prev => prev.filter(n => n.id !== id));
|
||||||
|
setToasts(prev => prev.filter(t => t.id !== id));
|
||||||
|
}, [all]);
|
||||||
|
|
||||||
|
const deleteAllNotifications = useCallback(async () => {
|
||||||
|
await deleteAllTradeSignals();
|
||||||
|
all.forEach(n => hiddenRef.current.add(n.id));
|
||||||
|
saveHiddenIds(hiddenRef.current);
|
||||||
|
setAll([]);
|
||||||
|
setToasts([]);
|
||||||
|
}, [all]);
|
||||||
|
|
||||||
|
const unreadCount = useMemo(() => all.filter(n => !n.isRead).length, [all]);
|
||||||
|
|
||||||
|
const value = useMemo(
|
||||||
|
() => ({
|
||||||
|
toastNotifications: toasts,
|
||||||
|
allNotifications: all,
|
||||||
|
unreadCount,
|
||||||
|
addNotification,
|
||||||
|
dismissToast,
|
||||||
|
dismissAllToasts,
|
||||||
|
markAsRead,
|
||||||
|
markAllAsRead,
|
||||||
|
deleteNotification,
|
||||||
|
deleteAllNotifications,
|
||||||
|
refreshHistory,
|
||||||
|
}),
|
||||||
|
[toasts, all, unreadCount, addNotification, dismissToast, dismissAllToasts, markAsRead, markAllAsRead, deleteNotification, deleteAllNotifications, refreshHistory],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TradeNotificationContext.Provider value={value}>{children}</TradeNotificationContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export {
|
||||||
|
useAppSettings,
|
||||||
|
getAppSettingsCache,
|
||||||
|
subscribeAppSettings,
|
||||||
|
resolveAppDefaults,
|
||||||
|
invalidateAppSettingsCache,
|
||||||
|
} from '@frontend/hooks/useAppSettings';
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
export * from '../../../packages/shared/src/api/backendApi';
|
||||||
|
export {
|
||||||
|
initStorage,
|
||||||
|
storageGet,
|
||||||
|
storageSet,
|
||||||
|
storageGetSync,
|
||||||
|
storageSetSync,
|
||||||
|
storageRemove,
|
||||||
|
} from '../../../packages/shared/src/storage/index';
|
||||||
|
export * from '../../../packages/shared/src/auth';
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import App from './App';
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>,
|
||||||
|
);
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useTradeNotification } from '../../contexts/TradeNotificationContext';
|
||||||
|
import { useNavigation } from '../../contexts/NavigationContext';
|
||||||
|
|
||||||
|
export default function NotificationsScreen() {
|
||||||
|
const {
|
||||||
|
allNotifications,
|
||||||
|
unreadCount,
|
||||||
|
markAsRead,
|
||||||
|
markAllAsRead,
|
||||||
|
deleteNotification,
|
||||||
|
deleteAllNotifications,
|
||||||
|
refreshHistory,
|
||||||
|
} = useTradeNotification();
|
||||||
|
const { openVirtualFocus } = useNavigation();
|
||||||
|
const [refreshing, setRefreshing] = React.useState(false);
|
||||||
|
|
||||||
|
const onRefresh = async () => {
|
||||||
|
setRefreshing(true);
|
||||||
|
await refreshHistory();
|
||||||
|
setRefreshing(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="screen">
|
||||||
|
<header className="screen-header">
|
||||||
|
<h1 className="screen-title">알림 {unreadCount > 0 && `(${unreadCount})`}</h1>
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<button type="button" className="chip" onClick={() => void onRefresh()}>{refreshing ? '…' : '새로고침'}</button>
|
||||||
|
<button type="button" className="chip" onClick={markAllAsRead}>모두 읽음</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{allNotifications.length === 0 ? (
|
||||||
|
<div className="empty-state">
|
||||||
|
<h3>알림 없음</h3>
|
||||||
|
<p>전략 조건 충족 시 알림이 표시됩니다</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ padding: '0 16px', display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
{allNotifications.map(item => (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className="card"
|
||||||
|
style={{
|
||||||
|
padding: 14,
|
||||||
|
opacity: item.isRead ? 0.65 : 1,
|
||||||
|
borderLeft: `3px solid ${item.signalType === 'BUY' ? 'var(--gc-green)' : 'var(--gc-red)'}`,
|
||||||
|
}}
|
||||||
|
onClick={() => {
|
||||||
|
markAsRead(item.id);
|
||||||
|
openVirtualFocus(item.market);
|
||||||
|
}}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||||
|
<div>
|
||||||
|
<span className={item.signalType === 'BUY' ? 'text-green' : 'text-red'} style={{ fontWeight: 700 }}>
|
||||||
|
{item.signalType === 'BUY' ? '매수' : '매도'}
|
||||||
|
</span>
|
||||||
|
<span style={{ marginLeft: 8, fontWeight: 600 }}>{item.market}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="chip"
|
||||||
|
style={{ minHeight: 28, padding: '4px 8px', color: 'var(--gc-red)' }}
|
||||||
|
onClick={e => { e.stopPropagation(); void deleteNotification(item.id); }}
|
||||||
|
>
|
||||||
|
삭제
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 14, marginTop: 4 }}>₩{item.price?.toLocaleString()}</div>
|
||||||
|
{item.strategyName && (
|
||||||
|
<div className="text-muted" style={{ fontSize: 11, marginTop: 2 }}>{item.strategyName}</div>
|
||||||
|
)}
|
||||||
|
<div className="text-muted" style={{ fontSize: 10, marginTop: 4 }}>
|
||||||
|
{new Date(item.receivedAt).toLocaleString('ko-KR')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{allNotifications.length > 0 && (
|
||||||
|
<div style={{ padding: 16, textAlign: 'center' }}>
|
||||||
|
<button type="button" className="btn-danger" onClick={() => void deleteAllNotifications()}>전체 삭제</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
loginUser,
|
||||||
|
resetPaperAccount,
|
||||||
|
sendFcmTest,
|
||||||
|
loadFcmStatus,
|
||||||
|
setApiBase,
|
||||||
|
getStoredDeviceId,
|
||||||
|
type AppSettingsDto,
|
||||||
|
} from '../../lib/shared';
|
||||||
|
import { getAuthSession, setAuthSession, clearAuthSession } from '../../lib/shared';
|
||||||
|
import { useAppSettings, resolveAppDefaults } from '../../hooks/useAppSettings';
|
||||||
|
import { SettingGroup, SettingRow, Toggle } from '../../components/SettingsList';
|
||||||
|
import { initFcmPush, checkPushPermission } from '../../services/fcm';
|
||||||
|
import { API_BASE } from '../../lib/shared';
|
||||||
|
|
||||||
|
export default function SettingsScreen() {
|
||||||
|
const { settings, save, isLoaded } = useAppSettings();
|
||||||
|
const defaults = resolveAppDefaults(settings);
|
||||||
|
const session = getAuthSession();
|
||||||
|
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [apiUrl, setApiUrl] = useState(API_BASE);
|
||||||
|
const [fcmAvailable, setFcmAvailable] = useState(false);
|
||||||
|
const [pushPerm, setPushPerm] = useState<string>('prompt');
|
||||||
|
const [loginError, setLoginError] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadFcmStatus().then(s => setFcmAvailable(!!s?.available));
|
||||||
|
void checkPushPermission().then(setPushPerm);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const patch = useCallback((p: AppSettingsDto) => save(p as unknown as Parameters<typeof save>[0]), [save]);
|
||||||
|
|
||||||
|
const handleLogin = async () => {
|
||||||
|
setLoginError('');
|
||||||
|
try {
|
||||||
|
const res = await loginUser(username, password);
|
||||||
|
setAuthSession({
|
||||||
|
userId: res.userId,
|
||||||
|
username: res.username,
|
||||||
|
displayName: res.displayName ?? res.username,
|
||||||
|
role: res.role === 'ADMIN' ? 'ADMIN' : 'USER',
|
||||||
|
});
|
||||||
|
setUsername('');
|
||||||
|
setPassword('');
|
||||||
|
} catch (e) {
|
||||||
|
setLoginError(e instanceof Error ? e.message : '로그인 실패');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
await clearAuthSession();
|
||||||
|
window.location.reload();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFcmToggle = async (enabled: boolean) => {
|
||||||
|
patch({ fcmPushEnabled: enabled });
|
||||||
|
if (enabled) {
|
||||||
|
await initFcmPush();
|
||||||
|
setPushPerm(await checkPushPermission());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isLoaded) return <div className="loading-center">설정 로딩…</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="screen settings-screen">
|
||||||
|
<header className="screen-header">
|
||||||
|
<h1 className="screen-title">설정</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<SettingGroup title="일반">
|
||||||
|
<SettingRow label="테마" description="앱 색상">
|
||||||
|
<select
|
||||||
|
value={defaults.theme ?? 'dark'}
|
||||||
|
onChange={e => patch({ defaultTheme: e.target.value })}
|
||||||
|
style={{ padding: '8px 12px', borderRadius: 8, border: '1px solid var(--gc-border)', background: 'transparent' }}
|
||||||
|
>
|
||||||
|
<option value="dark">다크</option>
|
||||||
|
<option value="light">라이트</option>
|
||||||
|
</select>
|
||||||
|
</SettingRow>
|
||||||
|
<SettingRow label="타임존">
|
||||||
|
<input
|
||||||
|
value={defaults.displayTimezone ?? 'Asia/Seoul'}
|
||||||
|
onChange={e => patch({ displayTimezone: e.target.value })}
|
||||||
|
style={{ padding: '8px 12px', borderRadius: 8, border: '1px solid var(--gc-border)', background: 'transparent', width: 140 }}
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
|
</SettingGroup>
|
||||||
|
|
||||||
|
<SettingGroup title="전략 · 실시간">
|
||||||
|
<SettingRow label="실시간 전략 체크">
|
||||||
|
<Toggle checked={!!defaults.liveStrategyCheck} onChange={v => patch({ liveStrategyCheck: v })} label="실시간 전략 체크" />
|
||||||
|
</SettingRow>
|
||||||
|
<SettingRow label="실행 방식">
|
||||||
|
<select
|
||||||
|
value={defaults.liveExecutionType ?? 'CANDLE_CLOSE'}
|
||||||
|
onChange={e => patch({ liveExecutionType: e.target.value })}
|
||||||
|
style={{ padding: '8px 12px', borderRadius: 8, border: '1px solid var(--gc-border)', background: 'transparent' }}
|
||||||
|
>
|
||||||
|
<option value="CANDLE_CLOSE">봉 마감</option>
|
||||||
|
<option value="REALTIME_TICK">실시간 틱</option>
|
||||||
|
</select>
|
||||||
|
</SettingRow>
|
||||||
|
</SettingGroup>
|
||||||
|
|
||||||
|
<SettingGroup title="가상매매 · 모의">
|
||||||
|
<SettingRow label="모의투자">
|
||||||
|
<Toggle checked={!!defaults.paperTradingEnabled} onChange={v => patch({ paperTradingEnabled: v })} label="모의투자" />
|
||||||
|
</SettingRow>
|
||||||
|
<SettingRow label="자동 모의 체결">
|
||||||
|
<Toggle checked={!!defaults.paperAutoTradeEnabled} onChange={v => patch({ paperAutoTradeEnabled: v })} label="자동 모의 체결" />
|
||||||
|
</SettingRow>
|
||||||
|
<SettingRow label="최대 종목 수">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={defaults.virtualTargetMaxCount ?? 20}
|
||||||
|
onChange={e => patch({ virtualTargetMaxCount: Number(e.target.value) })}
|
||||||
|
style={{ width: 64, padding: 8, borderRadius: 8, border: '1px solid var(--gc-border)', background: 'transparent' }}
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
|
<SettingRow label="모의 계좌 초기화">
|
||||||
|
<button type="button" className="btn-danger" style={{ padding: '8px 12px', minHeight: 36 }} onClick={() => void resetPaperAccount()}>리셋</button>
|
||||||
|
</SettingRow>
|
||||||
|
</SettingGroup>
|
||||||
|
|
||||||
|
<SettingGroup title="알림">
|
||||||
|
<SettingRow label="매매 알림">
|
||||||
|
<Toggle checked={!!defaults.tradeAlertPopup} onChange={v => patch({ tradeAlertPopup: v })} label="매매 알림" />
|
||||||
|
</SettingRow>
|
||||||
|
<SettingRow label="알림 사운드">
|
||||||
|
<Toggle checked={!!defaults.tradeAlertSoundEnabled} onChange={v => patch({ tradeAlertSoundEnabled: v })} label="알림 사운드" />
|
||||||
|
</SettingRow>
|
||||||
|
</SettingGroup>
|
||||||
|
|
||||||
|
<SettingGroup title="FCM 푸시">
|
||||||
|
<SettingRow label="푸시 알림" description={fcmAvailable ? 'Firebase 연결됨' : 'Firebase 미설정'}>
|
||||||
|
<Toggle checked={!!defaults.fcmPushEnabled} onChange={v => void handleFcmToggle(v)} label="FCM 푸시" />
|
||||||
|
</SettingRow>
|
||||||
|
<SettingRow label="권한 상태">
|
||||||
|
<span className="text-muted">{pushPerm}</span>
|
||||||
|
</SettingRow>
|
||||||
|
<SettingRow label="테스트 발송">
|
||||||
|
<button type="button" className="btn-secondary" style={{ padding: '8px 12px', minHeight: 36 }} onClick={() => void sendFcmTest()}>테스트</button>
|
||||||
|
</SettingRow>
|
||||||
|
</SettingGroup>
|
||||||
|
|
||||||
|
<SettingGroup title="네트워크 · 계정">
|
||||||
|
<SettingRow label="API URL">
|
||||||
|
<input
|
||||||
|
value={apiUrl}
|
||||||
|
onChange={e => setApiUrl(e.target.value)}
|
||||||
|
style={{ width: 160, padding: 8, borderRadius: 8, border: '1px solid var(--gc-border)', background: 'transparent', fontSize: 11 }}
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
|
<SettingRow label="적용">
|
||||||
|
<button type="button" className="btn-secondary" style={{ padding: '8px 12px' }} onClick={() => { setApiBase(apiUrl); window.location.reload(); }}>저장</button>
|
||||||
|
</SettingRow>
|
||||||
|
<SettingRow label="Device ID">
|
||||||
|
<span className="text-muted" style={{ fontSize: 10, wordBreak: 'break-all', maxWidth: 180 }}>{getStoredDeviceId()}</span>
|
||||||
|
</SettingRow>
|
||||||
|
{session ? (
|
||||||
|
<SettingRow label={`${session.displayName} (${session.username})`}>
|
||||||
|
<button type="button" className="btn-secondary" onClick={() => void handleLogout()}>로그아웃</button>
|
||||||
|
</SettingRow>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<SettingRow label="아이디">
|
||||||
|
<input value={username} onChange={e => setUsername(e.target.value)} style={{ width: 120, padding: 8, borderRadius: 8, border: '1px solid var(--gc-border)', background: 'transparent' }} />
|
||||||
|
</SettingRow>
|
||||||
|
<SettingRow label="비밀번호">
|
||||||
|
<input type="password" value={password} onChange={e => setPassword(e.target.value)} style={{ width: 120, padding: 8, borderRadius: 8, border: '1px solid var(--gc-border)', background: 'transparent' }} />
|
||||||
|
</SettingRow>
|
||||||
|
<SettingRow label="로그인">
|
||||||
|
<button type="button" className="btn-primary" style={{ padding: '8px 16px' }} onClick={() => void handleLogin()}>로그인</button>
|
||||||
|
</SettingRow>
|
||||||
|
{loginError && <div className="text-red" style={{ padding: '8px 16px', fontSize: 12 }}>{loginError}</div>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</SettingGroup>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
loadStrategies,
|
||||||
|
loadStrategy,
|
||||||
|
saveStrategy,
|
||||||
|
deleteStrategy,
|
||||||
|
type StrategyDto,
|
||||||
|
} from '../../lib/shared';
|
||||||
|
import type { LogicNode } from '@frontend/utils/strategyTypes';
|
||||||
|
import { genId } from '@frontend/utils/strategyEditorShared';
|
||||||
|
|
||||||
|
const emptyAnd = (): LogicNode => ({ id: genId(), type: 'AND', children: [] });
|
||||||
|
import {
|
||||||
|
decodeConditionForEditor,
|
||||||
|
encodeConditionForSave,
|
||||||
|
} from '@frontend/utils/strategyConditionSerde';
|
||||||
|
import {
|
||||||
|
downloadStrategyJson,
|
||||||
|
parseStrategyImportFile,
|
||||||
|
pickJsonFile,
|
||||||
|
buildStrategyExportPayload,
|
||||||
|
} from '@frontend/utils/strategyImportExport';
|
||||||
|
import { loadEditorMode, saveEditorMode, type StrategyEditorMode } from '@frontend/utils/strategyEditorModeStorage';
|
||||||
|
import SegmentedControl from '../../components/SegmentedControl';
|
||||||
|
import BottomSheet from '../../components/BottomSheet';
|
||||||
|
import StrategyFlowMobile from './StrategyFlowMobile';
|
||||||
|
|
||||||
|
const emptyEditorState = {
|
||||||
|
root: null as LogicNode | null,
|
||||||
|
startMeta: {},
|
||||||
|
extraStartIds: [] as string[],
|
||||||
|
extraRoots: {} as Record<string, LogicNode | null>,
|
||||||
|
startCombineOp: 'AND' as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function StrategyEditorScreen() {
|
||||||
|
const [strategies, setStrategies] = useState<StrategyDto[]>([]);
|
||||||
|
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||||
|
const [editorMode, setEditorMode] = useState<StrategyEditorMode>(() => loadEditorMode());
|
||||||
|
const [signalTab, setSignalTab] = useState<'buy' | 'sell'>('buy');
|
||||||
|
const [name, setName] = useState('새 전략');
|
||||||
|
const [description, setDescription] = useState('');
|
||||||
|
const [buyRoot, setBuyRoot] = useState<LogicNode | null>(emptyAnd());
|
||||||
|
const [sellRoot, setSellRoot] = useState<LogicNode | null>(emptyAnd());
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const refreshList = useCallback(async () => {
|
||||||
|
setStrategies((await loadStrategies()) ?? []);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refreshList().finally(() => setLoading(false));
|
||||||
|
}, [refreshList]);
|
||||||
|
|
||||||
|
const loadOne = async (id: number) => {
|
||||||
|
const s = await loadStrategy(id);
|
||||||
|
if (!s) return;
|
||||||
|
setSelectedId(id);
|
||||||
|
setName(s.name);
|
||||||
|
setDescription(s.description ?? '');
|
||||||
|
setBuyRoot(decodeConditionForEditor(s.buyCondition as LogicNode).root);
|
||||||
|
setSellRoot(decodeConditionForEditor(s.sellCondition as LogicNode).root);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const saved = await saveStrategy({
|
||||||
|
id: selectedId ?? undefined,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
buyCondition: encodeConditionForSave({ ...emptyEditorState, root: buyRoot }),
|
||||||
|
sellCondition: encodeConditionForSave({ ...emptyEditorState, root: sellRoot }),
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
if (saved?.id) {
|
||||||
|
setSelectedId(saved.id);
|
||||||
|
await refreshList();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (selectedId == null || !confirm('삭제할까요?')) return;
|
||||||
|
await deleteStrategy(selectedId);
|
||||||
|
setSelectedId(null);
|
||||||
|
setName('새 전략');
|
||||||
|
setBuyRoot(emptyAnd());
|
||||||
|
setSellRoot(emptyAnd());
|
||||||
|
await refreshList();
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentRoot = signalTab === 'buy' ? buyRoot : sellRoot;
|
||||||
|
const setCurrentRoot = signalTab === 'buy' ? setBuyRoot : setSellRoot;
|
||||||
|
|
||||||
|
const handleExport = () => {
|
||||||
|
const payload = buildStrategyExportPayload({
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
buyCondition: encodeConditionForSave({ ...emptyEditorState, root: buyRoot }),
|
||||||
|
sellCondition: encodeConditionForSave({ ...emptyEditorState, root: sellRoot }),
|
||||||
|
});
|
||||||
|
downloadStrategyJson(name, payload);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImport = async () => {
|
||||||
|
const file = await pickJsonFile();
|
||||||
|
if (!file) return;
|
||||||
|
const parsed = await parseStrategyImportFile(file);
|
||||||
|
if (!parsed) return;
|
||||||
|
const data = parsed.kind === 'single' ? parsed.data : parsed.data.strategies[0];
|
||||||
|
if (!data) return;
|
||||||
|
setName(data.name ?? '가져온 전략');
|
||||||
|
setDescription(data.description ?? '');
|
||||||
|
setBuyRoot(decodeConditionForEditor(data.buyCondition as LogicNode).root);
|
||||||
|
setSellRoot(decodeConditionForEditor(data.sellCondition as LogicNode).root);
|
||||||
|
setSelectedId(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) return <div className="loading-center">로딩 중…</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="screen">
|
||||||
|
<header className="screen-header">
|
||||||
|
<h1 className="screen-title">전략편집기</h1>
|
||||||
|
<button type="button" className="btn-primary" disabled={saving} onClick={() => void handleSave()}>저장</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="chip-row">
|
||||||
|
<button type="button" className="chip" onClick={() => { setSelectedId(null); setName('새 전략'); }}>+ 새 전략</button>
|
||||||
|
<button type="button" className="chip" onClick={handleExport}>내보내기</button>
|
||||||
|
<button type="button" className="chip" onClick={() => void handleImport()}>가져오기</button>
|
||||||
|
{selectedId != null && <button type="button" className="chip" style={{ color: 'var(--gc-red)' }} onClick={() => void handleDelete()}>삭제</button>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ padding: '0 16px 12px' }}>
|
||||||
|
<select
|
||||||
|
className="chip"
|
||||||
|
style={{ width: '100%', marginBottom: 8 }}
|
||||||
|
value={selectedId ?? ''}
|
||||||
|
onChange={e => { const id = Number(e.target.value); if (id) void loadOne(id); }}
|
||||||
|
>
|
||||||
|
<option value="">전략 선택…</option>
|
||||||
|
{strategies.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
|
||||||
|
</select>
|
||||||
|
<input value={name} onChange={e => setName(e.target.value)} placeholder="전략 이름" style={{ width: '100%', padding: 12, borderRadius: 10, border: '1px solid var(--gc-border)', background: 'rgba(255,255,255,0.05)', marginBottom: 8 }} />
|
||||||
|
<textarea value={description} onChange={e => setDescription(e.target.value)} placeholder="설명" rows={2} style={{ width: '100%', padding: 12, borderRadius: 10, border: '1px solid var(--gc-border)', background: 'rgba(255,255,255,0.05)' }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="chip-row">
|
||||||
|
<SegmentedControl options={[{ value: 'buy' as const, label: '매수' }, { value: 'sell' as const, label: '매도' }]} value={signalTab} onChange={(v: 'buy' | 'sell') => setSignalTab(v)} />
|
||||||
|
<SegmentedControl options={[{ value: 'list', label: '목록' }, { value: 'graph', label: '그래프' }]} value={editorMode} onChange={m => { setEditorMode(m); saveEditorMode(m); }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{editorMode === 'graph' ? (
|
||||||
|
<StrategyFlowMobile root={currentRoot} signalTab={signalTab} />
|
||||||
|
) : (
|
||||||
|
<div style={{ padding: '0 16px' }}>
|
||||||
|
<ConditionSummary root={currentRoot} />
|
||||||
|
<p className="text-muted" style={{ fontSize: 12, marginTop: 12 }}>
|
||||||
|
상세 조건 편집은 데스크톱 전략편집기와 동기화됩니다. Import/Export JSON으로 조건을 가져올 수 있습니다.
|
||||||
|
</p>
|
||||||
|
<button type="button" className="btn-secondary" style={{ width: '100%', marginTop: 12 }} onClick={() => setCurrentRoot(emptyAnd())}>조건 초기화</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConditionSummary({ root }: { root: LogicNode | null }) {
|
||||||
|
const count = countConditions(root);
|
||||||
|
return (
|
||||||
|
<div className="card">
|
||||||
|
<div style={{ fontWeight: 600 }}>논리 트리</div>
|
||||||
|
<div className="text-muted" style={{ fontSize: 13, marginTop: 4 }}>루트: {root?.type ?? '없음'} · 조건 {count}개</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function countConditions(node: LogicNode | null): number {
|
||||||
|
if (!node) return 0;
|
||||||
|
if (node.type === 'CONDITION') return 1;
|
||||||
|
return (node.children ?? []).reduce((s, c) => s + countConditions(c), 0);
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import type { LogicNode } from '@frontend/utils/strategyTypes';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
root: LogicNode | null;
|
||||||
|
signalTab: 'buy' | 'sell';
|
||||||
|
}
|
||||||
|
|
||||||
|
function countNodes(node: LogicNode | null): number {
|
||||||
|
if (!node) return 0;
|
||||||
|
return 1 + (node.children ?? []).reduce((s, c) => s + countNodes(c), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StrategyFlowMobile({ root, signalTab }: Props) {
|
||||||
|
const count = useMemo(() => countNodes(root), [root]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 16 }}>
|
||||||
|
<div className="card" style={{ textAlign: 'center', padding: 32 }}>
|
||||||
|
<div style={{ fontSize: 48, marginBottom: 8 }}>🔀</div>
|
||||||
|
<div style={{ fontWeight: 600, marginBottom: 4 }}>{signalTab === 'buy' ? '매수' : '매도'} 그래프</div>
|
||||||
|
<div className="text-muted" style={{ fontSize: 13, marginBottom: 16 }}>
|
||||||
|
{count}개 노드 · 모바일에서는 목록 모드 편집을 권장합니다
|
||||||
|
</div>
|
||||||
|
<p className="text-muted" style={{ fontSize: 12, lineHeight: 1.5 }}>
|
||||||
|
데스크톱 전략편집기에서 저장한 flowLayout 그래프는 그대로 유지됩니다.
|
||||||
|
조건 추가·편집은 목록 탭을 사용하세요.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import type { StrategyDto } from '../../lib/shared';
|
||||||
|
import type { VirtualIndicatorSnapshot } from '@frontend/hooks/useVirtualIndicatorSnapshots';
|
||||||
|
import type { VirtualSessionConfig, VirtualTargetItem } from '@frontend/utils/virtualTradingStorage';
|
||||||
|
import { buildConditionMetrics, computeMatchRate } from '@frontend/utils/virtualSignalMetrics';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
target: VirtualTargetItem;
|
||||||
|
session: VirtualSessionConfig;
|
||||||
|
strategies: StrategyDto[];
|
||||||
|
snapshot?: VirtualIndicatorSnapshot;
|
||||||
|
liveConnected: boolean;
|
||||||
|
onBack: () => void;
|
||||||
|
onOpenTrade: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function VirtualFocusScreen({
|
||||||
|
target,
|
||||||
|
snapshot,
|
||||||
|
liveConnected,
|
||||||
|
onBack,
|
||||||
|
onOpenTrade,
|
||||||
|
}: Props) {
|
||||||
|
const metrics = snapshot?.rows?.length ? buildConditionMetrics(snapshot.rows) : [];
|
||||||
|
const buyPct = computeMatchRate(metrics.filter(m => m.row.side === 'buy'), snapshot?.matchRate);
|
||||||
|
const sellPct = computeMatchRate(metrics.filter(m => m.row.side === 'sell'));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="screen focus-screen">
|
||||||
|
<header className="screen-header">
|
||||||
|
<button type="button" onClick={onBack} style={{ fontSize: 24, minWidth: 44 }}>←</button>
|
||||||
|
<h1 className="screen-title" style={{ flex: 1 }}>{target.market}</h1>
|
||||||
|
<button type="button" className="btn-primary" onClick={onOpenTrade}>매매</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div style={{ padding: 16 }}>
|
||||||
|
<div className="card" style={{ marginBottom: 16, textAlign: 'center' }}>
|
||||||
|
<div className="text-muted" style={{ fontSize: 12 }}>일치율</div>
|
||||||
|
<div style={{ fontSize: 48, fontWeight: 700, color: 'var(--gc-accent)' }}>
|
||||||
|
{(snapshot?.matchRate ?? 0).toFixed(0)}%
|
||||||
|
</div>
|
||||||
|
<div className="text-muted" style={{ fontSize: 12, marginTop: 4 }}>
|
||||||
|
{liveConnected ? '실시간 연결됨' : '연결 대기'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="metrics-grid" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 16 }}>
|
||||||
|
<div className="card" style={{ textAlign: 'center', padding: 14 }}>
|
||||||
|
<div className="text-green" style={{ fontSize: 22, fontWeight: 700 }}>{buyPct.toFixed(0)}%</div>
|
||||||
|
<div className="text-muted" style={{ fontSize: 11 }}>매수 조건</div>
|
||||||
|
</div>
|
||||||
|
<div className="card" style={{ textAlign: 'center', padding: 14 }}>
|
||||||
|
<div className="text-red" style={{ fontSize: 22, fontWeight: 700 }}>{sellPct.toFixed(0)}%</div>
|
||||||
|
<div className="text-muted" style={{ fontSize: 11 }}>매도 조건</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 style={{ fontSize: 14, marginBottom: 8 }}>조건 목록</h3>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
|
{(snapshot?.rows ?? []).map(row => (
|
||||||
|
<div key={row.id} className="card" style={{ padding: 10, display: 'flex', justifyContent: 'space-between' }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 500 }}>{row.displayName}</div>
|
||||||
|
<div className="text-muted" style={{ fontSize: 11 }}>{row.side} · {row.timeframe}</div>
|
||||||
|
</div>
|
||||||
|
<span className={row.satisfied ? 'text-green' : row.satisfied === false ? 'text-red' : 'text-muted'}>
|
||||||
|
{row.satisfied === true ? '충족' : row.satisfied === false ? '미충족' : '—'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import type { StrategyDto } from '../../lib/shared';
|
||||||
|
import type { VirtualIndicatorSnapshot } from '@frontend/hooks/useVirtualIndicatorSnapshots';
|
||||||
|
import type { VirtualLiveStatus } from '@frontend/hooks/useVirtualTargetLiveStatus';
|
||||||
|
import type { VirtualCardViewMode, VirtualSessionConfig, VirtualTargetItem } from '@frontend/utils/virtualTradingStorage';
|
||||||
|
import { resolveVirtualTargetStrategyId } from '@frontend/utils/virtualTargetStrategy';
|
||||||
|
import { buildConditionMetrics, computeMatchRate } from '@frontend/utils/virtualSignalMetrics';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
target: VirtualTargetItem;
|
||||||
|
session: VirtualSessionConfig;
|
||||||
|
strategies: StrategyDto[];
|
||||||
|
snapshot?: VirtualIndicatorSnapshot;
|
||||||
|
viewMode: VirtualCardViewMode;
|
||||||
|
liveFlash?: boolean;
|
||||||
|
liveStatus?: VirtualLiveStatus;
|
||||||
|
onFocus: () => void;
|
||||||
|
onTrade: () => void;
|
||||||
|
onRemove: () => void;
|
||||||
|
onTogglePin: () => void;
|
||||||
|
onStrategyChange: (id: number | null) => void;
|
||||||
|
onCandleTypeChange: (ct: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function VirtualTargetCardMobile({
|
||||||
|
target,
|
||||||
|
session,
|
||||||
|
strategies,
|
||||||
|
snapshot,
|
||||||
|
viewMode,
|
||||||
|
liveFlash,
|
||||||
|
liveStatus,
|
||||||
|
onFocus,
|
||||||
|
onTrade,
|
||||||
|
onRemove,
|
||||||
|
onTogglePin,
|
||||||
|
onStrategyChange,
|
||||||
|
}: Props) {
|
||||||
|
const strategyId = resolveVirtualTargetStrategyId(target, session.globalStrategyId);
|
||||||
|
const strategyName = strategies.find(s => s.id === strategyId)?.name ?? '전략 없음';
|
||||||
|
const metrics = snapshot?.rows?.length ? buildConditionMetrics(snapshot.rows) : [];
|
||||||
|
const buyPct = computeMatchRate(metrics.filter(m => m.row.side === 'buy'), snapshot?.matchRate);
|
||||||
|
const sellPct = computeMatchRate(metrics.filter(m => m.row.side === 'sell'));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article
|
||||||
|
className={`v-card card${liveFlash ? ' flash' : ''}`}
|
||||||
|
onClick={onFocus}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
<div className="v-card-top">
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 700, fontSize: 16 }}>{target.koreanName ?? target.market}</div>
|
||||||
|
<div className="text-muted" style={{ fontSize: 11 }}>{target.market}</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ textAlign: 'right' }}>
|
||||||
|
<span className={`live-dot ${liveStatus ?? 'idle'}`} />
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--gc-accent)' }}>{(snapshot?.matchRate ?? buyPct).toFixed(0)}%</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="v-card-eq">
|
||||||
|
<div className="eq-bar">
|
||||||
|
<div className="eq-fill buy" style={{ width: `${buyPct}%` }} />
|
||||||
|
</div>
|
||||||
|
<div className="eq-bar">
|
||||||
|
<div className="eq-fill sell" style={{ width: `${sellPct}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="v-card-meta text-muted" style={{ fontSize: 11 }}>
|
||||||
|
{strategyName} · {snapshot?.timeframe ?? '—'}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{viewMode === 'detail' && snapshot?.rows && (
|
||||||
|
<div className="v-card-conditions">
|
||||||
|
{snapshot.rows.slice(0, 6).map(row => (
|
||||||
|
<div key={row.id} className="cond-row">
|
||||||
|
<span>{row.displayName}</span>
|
||||||
|
<span className={row.satisfied ? 'text-green' : row.satisfied === false ? 'text-red' : ''}>
|
||||||
|
{row.satisfied === true ? '✓' : row.satisfied === false ? '✗' : '—'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="v-card-actions" onClick={e => e.stopPropagation()}>
|
||||||
|
<select
|
||||||
|
className="chip"
|
||||||
|
value={strategyId ?? ''}
|
||||||
|
onChange={e => onStrategyChange(e.target.value ? Number(e.target.value) : null)}
|
||||||
|
style={{ flex: 1, fontSize: 11 }}
|
||||||
|
>
|
||||||
|
<option value="">기본 전략</option>
|
||||||
|
{strategies.map(s => (
|
||||||
|
<option key={s.id} value={s.id}>{s.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button type="button" className="btn-secondary" style={{ padding: '8px 12px', minHeight: 36 }} onClick={onTrade}>매매</button>
|
||||||
|
<button type="button" className="btn-secondary" style={{ padding: '8px 10px', minHeight: 36 }} onClick={onTogglePin}>{target.pinned ? '📌' : '○'}</button>
|
||||||
|
{!target.pinned && (
|
||||||
|
<button type="button" className="btn-danger" style={{ padding: '8px 10px', minHeight: 36 }} onClick={onRemove}>✕</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>{`
|
||||||
|
.v-card { cursor: pointer; transition: box-shadow 0.2s; }
|
||||||
|
.v-card.flash { box-shadow: 0 0 0 2px var(--gc-accent); }
|
||||||
|
.v-card-top { display: flex; justify-content: space-between; margin-bottom: 10px; }
|
||||||
|
.live-dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 8px; height: 8px; border-radius: 50%;
|
||||||
|
background: var(--gc-text-dim); margin-right: 4px;
|
||||||
|
}
|
||||||
|
.live-dot.live { background: var(--gc-green); }
|
||||||
|
.live-dot.connecting { background: var(--gc-orange); }
|
||||||
|
.live-dot.disconnected { background: var(--gc-red); }
|
||||||
|
.v-card-eq { display: flex; flex-direction: column; gap: 4px; margin-bottom: 8px; }
|
||||||
|
.eq-bar { height: 4px; background: rgba(255,255,255,0.1); border-radius: 2px; overflow: hidden; }
|
||||||
|
.eq-fill.buy { height: 100%; background: var(--gc-green); }
|
||||||
|
.eq-fill.sell { height: 100%; background: var(--gc-red); }
|
||||||
|
.v-card-conditions { margin: 8px 0; font-size: 11px; }
|
||||||
|
.cond-row { display: flex; justify-content: space-between; padding: 2px 0; }
|
||||||
|
.v-card-actions { display: flex; gap: 6px; margin-top: 10px; align-items: center; }
|
||||||
|
`}</style>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import type { PaperSummaryDto } from '../../lib/shared';
|
||||||
|
import { Haptics, ImpactStyle } from '@capacitor/haptics';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
market: string;
|
||||||
|
summary: PaperSummaryDto | null;
|
||||||
|
onOrder: (side: 'BUY' | 'SELL', qty: number, price: number) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function VirtualTradePanel({ market, summary, onOrder }: Props) {
|
||||||
|
const [qty, setQty] = useState('0.001');
|
||||||
|
const [price, setPrice] = useState('');
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const position = summary?.positions?.find(p => p.symbol === market);
|
||||||
|
|
||||||
|
const submit = async (side: 'BUY' | 'SELL') => {
|
||||||
|
const q = parseFloat(qty);
|
||||||
|
const p = parseFloat(price) || 0;
|
||||||
|
if (!Number.isFinite(q) || q <= 0) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await onOrder(side, q, p);
|
||||||
|
try { await Haptics.impact({ style: ImpactStyle.Medium }); } catch { /* web */ }
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="trade-panel">
|
||||||
|
{position && (
|
||||||
|
<div className="card" style={{ marginBottom: 12, padding: 12 }}>
|
||||||
|
<div className="text-muted" style={{ fontSize: 11 }}>보유</div>
|
||||||
|
<div>{position.quantity} @ ₩{position.avgPrice?.toLocaleString()}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<label style={{ display: 'block', marginBottom: 8, fontSize: 13 }}>수량</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={qty}
|
||||||
|
onChange={e => setQty(e.target.value)}
|
||||||
|
style={inputStyle}
|
||||||
|
/>
|
||||||
|
<label style={{ display: 'block', margin: '12px 0 8px', fontSize: 13 }}>가격 (0=시장가)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={price}
|
||||||
|
onChange={e => setPrice(e.target.value)}
|
||||||
|
placeholder="시장가"
|
||||||
|
style={inputStyle}
|
||||||
|
/>
|
||||||
|
<div style={{ display: 'flex', gap: 10, marginTop: 16 }}>
|
||||||
|
<button type="button" className="btn-primary" style={{ flex: 1, background: 'var(--gc-green)' }} disabled={busy} onClick={() => void submit('BUY')}>
|
||||||
|
매수
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn-danger" style={{ flex: 1 }} disabled={busy} onClick={() => void submit('SELL')}>
|
||||||
|
매도
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputStyle: React.CSSProperties = {
|
||||||
|
width: '100%',
|
||||||
|
padding: '12px 14px',
|
||||||
|
borderRadius: 10,
|
||||||
|
border: '1px solid var(--gc-border)',
|
||||||
|
background: 'rgba(255,255,255,0.05)',
|
||||||
|
};
|
||||||
@@ -0,0 +1,340 @@
|
|||||||
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
loadPaperSummary,
|
||||||
|
loadPaperTrades,
|
||||||
|
loadStrategies,
|
||||||
|
placePaperOrder,
|
||||||
|
resetPaperAccount,
|
||||||
|
type PaperSummaryDto,
|
||||||
|
type PaperTradeDto,
|
||||||
|
type StrategyDto,
|
||||||
|
} from '../../lib/shared';
|
||||||
|
import { useVirtualIndicatorSnapshots } from '@frontend/hooks/useVirtualIndicatorSnapshots';
|
||||||
|
import { useVirtualAutoTrade } from '@frontend/hooks/useVirtualAutoTrade';
|
||||||
|
import { useVirtualTargetLiveStatus } from '@frontend/hooks/useVirtualTargetLiveStatus';
|
||||||
|
import {
|
||||||
|
loadVirtualSession,
|
||||||
|
loadVirtualTargets,
|
||||||
|
saveVirtualSession,
|
||||||
|
saveVirtualTargets,
|
||||||
|
loadVirtualCardViewMode,
|
||||||
|
saveVirtualCardViewMode,
|
||||||
|
type VirtualSessionConfig,
|
||||||
|
type VirtualTargetItem,
|
||||||
|
type VirtualCardViewMode,
|
||||||
|
resolveTargetCandleType,
|
||||||
|
} from '@frontend/utils/virtualTradingStorage';
|
||||||
|
import {
|
||||||
|
syncVirtualTargetsToBackend,
|
||||||
|
stopVirtualLiveOnBackend,
|
||||||
|
} from '@frontend/utils/virtualLiveStrategySync';
|
||||||
|
import { resolveVirtualTargetStrategyId } from '@frontend/utils/virtualTargetStrategy';
|
||||||
|
import { virtualTargetLimitMessage, isVirtualTargetAddAllowed } from '@frontend/utils/virtualTargetLimits';
|
||||||
|
import { persistVirtualTargetPinned } from '@frontend/utils/virtualTargetMutations';
|
||||||
|
import { useAppSettings, resolveAppDefaults } from '../../hooks/useAppSettings';
|
||||||
|
import { useNavigation } from '../../contexts/NavigationContext';
|
||||||
|
import BottomSheet from '../../components/BottomSheet';
|
||||||
|
import SegmentedControl from '../../components/SegmentedControl';
|
||||||
|
import VirtualTargetCardMobile from './VirtualTargetCardMobile';
|
||||||
|
import VirtualFocusScreen from './VirtualFocusScreen';
|
||||||
|
import VirtualTradePanel from './VirtualTradePanel';
|
||||||
|
|
||||||
|
type RightTab = 'trade' | 'history';
|
||||||
|
|
||||||
|
export default function VirtualTradingScreen() {
|
||||||
|
const { focusMarket, clearVirtualFocus, openVirtualFocus } = useNavigation();
|
||||||
|
const { settings } = useAppSettings();
|
||||||
|
const defaults = resolveAppDefaults(settings);
|
||||||
|
|
||||||
|
const [targets, setTargets] = useState<VirtualTargetItem[]>(() => loadVirtualTargets());
|
||||||
|
const [session, setSession] = useState<VirtualSessionConfig>(() => loadVirtualSession());
|
||||||
|
const [strategies, setStrategies] = useState<StrategyDto[]>([]);
|
||||||
|
const [summary, setSummary] = useState<PaperSummaryDto | null>(null);
|
||||||
|
const [trades, setTrades] = useState<PaperTradeDto[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [selectedMarket, setSelectedMarket] = useState('KRW-BTC');
|
||||||
|
const [viewMode, setViewMode] = useState<VirtualCardViewMode>(() => loadVirtualCardViewMode());
|
||||||
|
const [sheetOpen, setSheetOpen] = useState(false);
|
||||||
|
const [addSheetOpen, setAddSheetOpen] = useState(false);
|
||||||
|
const [rightTab, setRightTab] = useState<RightTab>('trade');
|
||||||
|
const [newMarket, setNewMarket] = useState('');
|
||||||
|
|
||||||
|
const refreshSummary = useCallback(async () => {
|
||||||
|
const [s, t] = await Promise.all([loadPaperSummary(), loadPaperTrades()]);
|
||||||
|
setSummary(s);
|
||||||
|
setTrades(t ?? []);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void Promise.all([
|
||||||
|
loadStrategies().then(setStrategies).catch(() => []),
|
||||||
|
refreshSummary(),
|
||||||
|
]).finally(() => setLoading(false));
|
||||||
|
}, [refreshSummary]);
|
||||||
|
|
||||||
|
useEffect(() => { saveVirtualTargets(targets); }, [targets]);
|
||||||
|
useEffect(() => { saveVirtualSession(session); }, [session]);
|
||||||
|
useEffect(() => { saveVirtualCardViewMode(viewMode); }, [viewMode]);
|
||||||
|
|
||||||
|
const targetRefs = useMemo(
|
||||||
|
() => targets.map(t => ({
|
||||||
|
market: t.market,
|
||||||
|
strategyId: resolveVirtualTargetStrategyId(t, session.globalStrategyId),
|
||||||
|
})),
|
||||||
|
[targets, session.globalStrategyId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const snapshots = useVirtualIndicatorSnapshots(
|
||||||
|
targetRefs,
|
||||||
|
strategies as Parameters<typeof useVirtualIndicatorSnapshots>[1],
|
||||||
|
session.running,
|
||||||
|
);
|
||||||
|
|
||||||
|
const liveStatus = useVirtualTargetLiveStatus(targetRefs, session.running);
|
||||||
|
|
||||||
|
const liveConnected = useMemo(
|
||||||
|
() => Object.values(liveStatus.statusByMarket).some(s => s === 'live' || s === 'connecting'),
|
||||||
|
[liveStatus.statusByMarket],
|
||||||
|
);
|
||||||
|
|
||||||
|
useVirtualAutoTrade({
|
||||||
|
targets,
|
||||||
|
session,
|
||||||
|
snapshots,
|
||||||
|
enabled: defaults.paperAutoTradeEnabled && defaults.paperTradingEnabled,
|
||||||
|
paperAutoTradeBudgetPct: defaults.paperAutoTradeBudgetPct ?? 95,
|
||||||
|
positions: summary?.positions,
|
||||||
|
onFilled: refreshSummary,
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleRunning = useCallback(async () => {
|
||||||
|
const next = { ...session, running: !session.running };
|
||||||
|
setSession(next);
|
||||||
|
if (next.running) {
|
||||||
|
await syncVirtualTargetsToBackend(targets, next, true);
|
||||||
|
} else {
|
||||||
|
await stopVirtualLiveOnBackend(targets, next);
|
||||||
|
}
|
||||||
|
}, [session, targets]);
|
||||||
|
|
||||||
|
const handleAddTarget = useCallback(() => {
|
||||||
|
const market = newMarket.trim().toUpperCase();
|
||||||
|
if (!market) return;
|
||||||
|
if (targets.some(t => t.market === market)) return;
|
||||||
|
if (!isVirtualTargetAddAllowed(targets.length, defaults.virtualTargetMaxCount)) {
|
||||||
|
alert(virtualTargetLimitMessage(defaults.virtualTargetMaxCount));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setTargets(prev => [...prev, { market, strategyId: null }]);
|
||||||
|
setNewMarket('');
|
||||||
|
setAddSheetOpen(false);
|
||||||
|
}, [newMarket, targets, defaults.virtualTargetMaxCount]);
|
||||||
|
|
||||||
|
const handleRemoveTarget = useCallback((market: string) => {
|
||||||
|
const t = targets.find(x => x.market === market);
|
||||||
|
if (t?.pinned) return;
|
||||||
|
setTargets(prev => prev.filter(x => x.market !== market));
|
||||||
|
}, [targets]);
|
||||||
|
|
||||||
|
const handleTogglePin = useCallback(async (market: string) => {
|
||||||
|
const t = targets.find(x => x.market === market);
|
||||||
|
if (!t) return;
|
||||||
|
const pinned = !t.pinned;
|
||||||
|
setTargets(prev => prev.map(x => (x.market === market ? { ...x, pinned } : x)));
|
||||||
|
await persistVirtualTargetPinned(market, pinned);
|
||||||
|
}, [targets]);
|
||||||
|
|
||||||
|
const pnlPct = summary?.totalReturnPct ?? 0;
|
||||||
|
|
||||||
|
if (focusMarket) {
|
||||||
|
const target = targets.find(t => t.market === focusMarket);
|
||||||
|
if (target) {
|
||||||
|
return (
|
||||||
|
<VirtualFocusScreen
|
||||||
|
target={target}
|
||||||
|
session={session}
|
||||||
|
strategies={strategies}
|
||||||
|
snapshot={(() => {
|
||||||
|
const sid = resolveVirtualTargetStrategyId(target, session.globalStrategyId);
|
||||||
|
return snapshots[`${target.market}:${sid ?? ''}`] ?? snapshots[target.market];
|
||||||
|
})()}
|
||||||
|
liveConnected={Object.values(liveStatus.statusByMarket).some(s => s === 'live')}
|
||||||
|
onBack={clearVirtualFocus}
|
||||||
|
onOpenTrade={() => {
|
||||||
|
setSelectedMarket(focusMarket);
|
||||||
|
setSheetOpen(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
clearVirtualFocus();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="screen virtual-screen">
|
||||||
|
<header className="screen-header">
|
||||||
|
<h1 className="screen-title">가상매매</h1>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`btn-primary session-btn${session.running ? ' running' : ''}`}
|
||||||
|
onClick={() => void toggleRunning()}
|
||||||
|
>
|
||||||
|
{session.running ? '■ 중지' : '▶ 시작'}
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="virtual-summary card" style={{ margin: '0 16px 12px' }}>
|
||||||
|
<div className="virtual-summary-row">
|
||||||
|
<div>
|
||||||
|
<div className="text-muted" style={{ fontSize: 12 }}>총 자산</div>
|
||||||
|
<div style={{ fontSize: 22, fontWeight: 700 }}>
|
||||||
|
₩{(summary?.totalAsset ?? 0).toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ textAlign: 'right' }}>
|
||||||
|
<div className="text-muted" style={{ fontSize: 12 }}>수익률</div>
|
||||||
|
<div className={`${pnlPct >= 0 ? 'text-green' : 'text-red'}`} style={{ fontSize: 22, fontWeight: 700 }}>
|
||||||
|
{pnlPct >= 0 ? '+' : ''}{pnlPct.toFixed(2)}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{!liveConnected && session.running && (
|
||||||
|
<div className="connection-banner" style={{ marginTop: 8, borderRadius: 8 }}>실시간 연결 끊김</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="chip-row">
|
||||||
|
<select
|
||||||
|
className="chip"
|
||||||
|
value={session.globalStrategyId ?? ''}
|
||||||
|
onChange={e => setSession(s => ({ ...s, globalStrategyId: e.target.value ? Number(e.target.value) : null }))}
|
||||||
|
style={{ maxWidth: 160 }}
|
||||||
|
>
|
||||||
|
<option value="">전략 선택</option>
|
||||||
|
{strategies.map(s => (
|
||||||
|
<option key={s.id} value={s.id}>{s.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<SegmentedControl
|
||||||
|
size="sm"
|
||||||
|
options={[
|
||||||
|
{ value: 'CANDLE_CLOSE' as const, label: '봉마감' },
|
||||||
|
{ value: 'REALTIME_TICK' as const, label: '실시간' },
|
||||||
|
]}
|
||||||
|
value={session.executionType}
|
||||||
|
onChange={v => setSession(s => ({ ...s, executionType: v }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="chip-row">
|
||||||
|
<SegmentedControl
|
||||||
|
size="sm"
|
||||||
|
options={[
|
||||||
|
{ value: 'LONG_ONLY' as const, label: '롱온리' },
|
||||||
|
{ value: 'SIGNAL_ONLY' as const, label: '시그널' },
|
||||||
|
]}
|
||||||
|
value={session.positionMode}
|
||||||
|
onChange={v => setSession(s => ({ ...s, positionMode: v }))}
|
||||||
|
/>
|
||||||
|
<SegmentedControl
|
||||||
|
size="sm"
|
||||||
|
options={[
|
||||||
|
{ value: 'summary' as const, label: '요약' },
|
||||||
|
{ value: 'detail' as const, label: '상세' },
|
||||||
|
]}
|
||||||
|
value={viewMode}
|
||||||
|
onChange={(v: VirtualCardViewMode) => setViewMode(v)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="loading-center">로딩 중…</div>
|
||||||
|
) : targets.length === 0 ? (
|
||||||
|
<div className="empty-state">
|
||||||
|
<h3>투자 대상 없음</h3>
|
||||||
|
<p>+ 버튼으로 종목을 추가하세요</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="virtual-cards" style={{ padding: '0 16px', display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
|
{targets.map(target => {
|
||||||
|
const strategyId = resolveVirtualTargetStrategyId(target, session.globalStrategyId);
|
||||||
|
const snapKey = `${target.market}:${strategyId ?? ''}`;
|
||||||
|
const snap = snapshots[snapKey] ?? snapshots[target.market];
|
||||||
|
return (
|
||||||
|
<VirtualTargetCardMobile
|
||||||
|
key={target.market}
|
||||||
|
target={target}
|
||||||
|
session={session}
|
||||||
|
strategies={strategies}
|
||||||
|
snapshot={snap}
|
||||||
|
viewMode={viewMode}
|
||||||
|
liveFlash={!!liveStatus.lastTickAtByMarket[target.market]}
|
||||||
|
liveStatus={liveStatus.statusByMarket[target.market]}
|
||||||
|
onFocus={() => openVirtualFocus(target.market)}
|
||||||
|
onTrade={() => { setSelectedMarket(target.market); setSheetOpen(true); }}
|
||||||
|
onRemove={() => handleRemoveTarget(target.market)}
|
||||||
|
onTogglePin={() => void handleTogglePin(target.market)}
|
||||||
|
onStrategyChange={sid => setTargets(prev => prev.map(t => t.market === target.market ? { ...t, strategyId: sid } : t))}
|
||||||
|
onCandleTypeChange={ct => setTargets(prev => prev.map(t => t.market === target.market ? { ...t, candleType: ct } : t))}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button type="button" className="fab" aria-label="종목 추가" onClick={() => setAddSheetOpen(true)}>+</button>
|
||||||
|
|
||||||
|
<BottomSheet open={addSheetOpen} title="종목 추가" onClose={() => setAddSheetOpen(false)}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="KRW-BTC"
|
||||||
|
value={newMarket}
|
||||||
|
onChange={e => setNewMarket(e.target.value)}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '12px 14px',
|
||||||
|
borderRadius: 10,
|
||||||
|
border: '1px solid var(--gc-border)',
|
||||||
|
background: 'rgba(255,255,255,0.05)',
|
||||||
|
marginBottom: 12,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button type="button" className="btn-primary" style={{ width: '100%' }} onClick={handleAddTarget}>추가</button>
|
||||||
|
</BottomSheet>
|
||||||
|
|
||||||
|
<BottomSheet open={sheetOpen} title={selectedMarket} onClose={() => setSheetOpen(false)} height="half">
|
||||||
|
<div className="chip-row" style={{ padding: '0 0 12px' }}>
|
||||||
|
<button type="button" className={`chip${rightTab === 'trade' ? ' active' : ''}`} onClick={() => setRightTab('trade')}>매매</button>
|
||||||
|
<button type="button" className={`chip${rightTab === 'history' ? ' active' : ''}`} onClick={() => setRightTab('history')}>내역</button>
|
||||||
|
</div>
|
||||||
|
{rightTab === 'trade' ? (
|
||||||
|
<VirtualTradePanel
|
||||||
|
market={selectedMarket}
|
||||||
|
summary={summary}
|
||||||
|
onOrder={async (side, qty, price) => {
|
||||||
|
await placePaperOrder({ market: selectedMarket, side, price, quantity: qty });
|
||||||
|
await refreshSummary();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
{trades.filter(t => t.symbol === selectedMarket).slice(0, 30).map(t => (
|
||||||
|
<div key={t.id} className="card" style={{ padding: 12 }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||||
|
<span className={t.side === 'BUY' ? 'text-green' : 'text-red'}>{t.side}</span>
|
||||||
|
<span>{t.quantity} @ ₩{t.price?.toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-muted" style={{ fontSize: 11, marginTop: 4 }}>{t.createdAt ?? ''}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</BottomSheet>
|
||||||
|
|
||||||
|
<style>{`
|
||||||
|
.session-btn.running { background: var(--gc-red); }
|
||||||
|
.virtual-summary-row { display: flex; justify-content: space-between; align-items: flex-start; }
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import {
|
||||||
|
registerFcmToken,
|
||||||
|
deleteFcmToken,
|
||||||
|
} from '../lib/shared';
|
||||||
|
import { PushNotifications } from '@capacitor/push-notifications';
|
||||||
|
import { Capacitor } from '@capacitor/core';
|
||||||
|
|
||||||
|
export interface FcmPayload {
|
||||||
|
type?: string;
|
||||||
|
market?: string;
|
||||||
|
signalId?: string;
|
||||||
|
signalType?: string;
|
||||||
|
price?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type FcmHandlers = {
|
||||||
|
onForeground?: (title: string, body: string, data?: FcmPayload) => void;
|
||||||
|
onTap?: (data: FcmPayload) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
let initialized = false;
|
||||||
|
|
||||||
|
export async function initFcmPush(handlers: FcmHandlers = {}): Promise<boolean> {
|
||||||
|
if (!Capacitor.isNativePlatform()) {
|
||||||
|
console.info('[FCM] Web dev — native push skipped');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (initialized) return true;
|
||||||
|
|
||||||
|
const perm = await PushNotifications.requestPermissions();
|
||||||
|
if (perm.receive !== 'granted') return false;
|
||||||
|
|
||||||
|
await PushNotifications.addListener('registration', async token => {
|
||||||
|
try {
|
||||||
|
await registerFcmToken(token.value);
|
||||||
|
console.info('[FCM] token registered');
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[FCM] token register failed', e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await PushNotifications.addListener('registrationError', err => {
|
||||||
|
console.warn('[FCM] registration error', err);
|
||||||
|
});
|
||||||
|
|
||||||
|
await PushNotifications.addListener('pushNotificationReceived', notification => {
|
||||||
|
const title = notification.title ?? 'GoldenChart';
|
||||||
|
const body = notification.body ?? '';
|
||||||
|
handlers.onForeground?.(title, body, notification.data as FcmPayload);
|
||||||
|
});
|
||||||
|
|
||||||
|
await PushNotifications.addListener('pushNotificationActionPerformed', action => {
|
||||||
|
handlers.onTap?.(action.notification.data as FcmPayload);
|
||||||
|
});
|
||||||
|
|
||||||
|
await PushNotifications.register();
|
||||||
|
initialized = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unregisterFcmPush(): Promise<void> {
|
||||||
|
if (!Capacitor.isNativePlatform()) return;
|
||||||
|
try {
|
||||||
|
await deleteFcmToken();
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
initialized = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function checkPushPermission(): Promise<'granted' | 'denied' | 'prompt'> {
|
||||||
|
if (!Capacitor.isNativePlatform()) return 'prompt';
|
||||||
|
const perm = await PushNotifications.checkPermissions();
|
||||||
|
if (perm.receive === 'granted') return 'granted';
|
||||||
|
if (perm.receive === 'denied') return 'denied';
|
||||||
|
return 'prompt';
|
||||||
|
}
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
@import './tokens.css';
|
||||||
|
|
||||||
|
*, *::before, *::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body, #root {
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--gc-font);
|
||||||
|
background: var(--gc-bg);
|
||||||
|
color: var(--gc-text);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
overscroll-behavior: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
button, input, select, textarea {
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
min-height: var(--gc-touch);
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--gc-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
padding-top: var(--gc-safe-top);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.screen {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
padding-bottom: calc(var(--gc-tab-height) + var(--gc-safe-bottom) + 8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.screen-header {
|
||||||
|
padding: 12px 16px 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
background: var(--gc-bg);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.screen-title {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--gc-bg-card);
|
||||||
|
border: 1px solid var(--gc-border);
|
||||||
|
border-radius: var(--gc-radius-lg);
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding: 0 16px 12px;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-row::-webkit-scrollbar { display: none; }
|
||||||
|
|
||||||
|
.chip {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 8px 14px;
|
||||||
|
border-radius: var(--gc-radius-sm);
|
||||||
|
border: 1px solid var(--gc-border);
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
color: var(--gc-text-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
min-height: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip.active {
|
||||||
|
background: var(--gc-accent-soft);
|
||||||
|
border-color: var(--gc-accent);
|
||||||
|
color: var(--gc-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--gc-accent);
|
||||||
|
color: #fff;
|
||||||
|
border-radius: var(--gc-radius-sm);
|
||||||
|
padding: 10px 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
border: 1px solid var(--gc-border);
|
||||||
|
border-radius: var(--gc-radius-sm);
|
||||||
|
padding: 10px 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: rgba(239, 68, 68, 0.15);
|
||||||
|
color: var(--gc-red);
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||||
|
border-radius: var(--gc-radius-sm);
|
||||||
|
padding: 10px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-green { color: var(--gc-green); }
|
||||||
|
.text-red { color: var(--gc-red); }
|
||||||
|
.text-muted { color: var(--gc-text-muted); }
|
||||||
|
|
||||||
|
.loading-center {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 48px;
|
||||||
|
color: var(--gc-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 48px 24px;
|
||||||
|
color: var(--gc-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state h3 {
|
||||||
|
font-size: 16px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: var(--gc-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fab {
|
||||||
|
position: fixed;
|
||||||
|
right: 16px;
|
||||||
|
bottom: calc(var(--gc-tab-height) + var(--gc-safe-bottom) + 16px);
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--gc-accent);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 28px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: 0 8px 24px rgba(139, 92, 246, 0.4);
|
||||||
|
z-index: 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-banner {
|
||||||
|
background: rgba(239, 68, 68, 0.15);
|
||||||
|
color: var(--gc-red);
|
||||||
|
text-align: center;
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-stack {
|
||||||
|
position: fixed;
|
||||||
|
top: calc(var(--gc-safe-top) + 8px);
|
||||||
|
left: 12px;
|
||||||
|
right: 12px;
|
||||||
|
z-index: 200;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-item {
|
||||||
|
pointer-events: auto;
|
||||||
|
background: var(--gc-bg-elevated);
|
||||||
|
border: 1px solid var(--gc-border);
|
||||||
|
border-radius: var(--gc-radius-md);
|
||||||
|
padding: 12px 14px;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.35);
|
||||||
|
animation: slideDown 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideDown {
|
||||||
|
from { opacity: 0; transform: translateY(-12px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.pull-indicator {
|
||||||
|
text-align: center;
|
||||||
|
padding: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--gc-text-muted);
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
:root {
|
||||||
|
--gc-bg: #0f0f23;
|
||||||
|
--gc-bg-elevated: #1a1a35;
|
||||||
|
--gc-bg-card: linear-gradient(135deg, #1e1e3f 0%, #141428 100%);
|
||||||
|
--gc-border: rgba(255, 255, 255, 0.1);
|
||||||
|
--gc-text: #ffffff;
|
||||||
|
--gc-text-muted: #9ca3af;
|
||||||
|
--gc-text-dim: #71717a;
|
||||||
|
--gc-accent: #8b5cf6;
|
||||||
|
--gc-accent-soft: rgba(139, 92, 246, 0.2);
|
||||||
|
--gc-green: #22c55e;
|
||||||
|
--gc-red: #ef4444;
|
||||||
|
--gc-orange: #f7931a;
|
||||||
|
--gc-radius-sm: 10px;
|
||||||
|
--gc-radius-md: 16px;
|
||||||
|
--gc-radius-lg: 20px;
|
||||||
|
--gc-touch: 44px;
|
||||||
|
--gc-tab-height: 56px;
|
||||||
|
--gc-safe-top: env(safe-area-inset-top, 0px);
|
||||||
|
--gc-safe-bottom: env(safe-area-inset-bottom, 0px);
|
||||||
|
--gc-font: 'Noto Sans KR', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme='light'] {
|
||||||
|
--gc-bg: #f4f4f8;
|
||||||
|
--gc-bg-elevated: #ffffff;
|
||||||
|
--gc-bg-card: linear-gradient(135deg, #ffffff 0%, #f8f8fc 100%);
|
||||||
|
--gc-border: rgba(0, 0, 0, 0.08);
|
||||||
|
--gc-text: #111827;
|
||||||
|
--gc-text-muted: #6b7280;
|
||||||
|
--gc-text-dim: #9ca3af;
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly VITE_API_BASE_URL?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '@goldenchart/shared' {
|
||||||
|
interface ImportMeta {
|
||||||
|
env?: Record<string, string>;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": false,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"paths": {
|
||||||
|
"@goldenchart/shared": ["../packages/shared/src/index.ts"],
|
||||||
|
"@goldenchart/shared/*": ["../packages/shared/src/*"],
|
||||||
|
"@frontend/*": ["../frontend/src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src", "../packages/shared/src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const frontendRoot = path.resolve(__dirname, '../frontend/src');
|
||||||
|
const sharedRoot = path.resolve(__dirname, '../packages/shared/src');
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
resolve: {
|
||||||
|
dedupe: ['react', 'react-dom', 'lightweight-charts'],
|
||||||
|
alias: {
|
||||||
|
'@goldenchart/shared': sharedRoot,
|
||||||
|
'@frontend': frontendRoot,
|
||||||
|
[path.resolve(frontendRoot, 'utils/backendApi.ts')]: path.resolve(sharedRoot, 'api/backendApi.ts'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
optimizeDeps: {
|
||||||
|
include: ['react', 'react-dom', '@stomp/stompjs', 'sockjs-client'],
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5174,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:8080',
|
||||||
|
changeOrigin: true,
|
||||||
|
ws: true,
|
||||||
|
},
|
||||||
|
'/upbit-api': {
|
||||||
|
target: 'https://api.upbit.com',
|
||||||
|
changeOrigin: true,
|
||||||
|
rewrite: p => p.replace(/^\/upbit-api/, ''),
|
||||||
|
},
|
||||||
|
'/upbit-ws': {
|
||||||
|
target: 'wss://api.upbit.com',
|
||||||
|
changeOrigin: true,
|
||||||
|
ws: true,
|
||||||
|
rewrite: () => '/websocket/v1',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
sourcemap: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package com.goldenchart.controller;
|
||||||
|
|
||||||
|
import com.goldenchart.dto.MobileAppReleaseInfoDto;
|
||||||
|
import com.goldenchart.service.MobileAppReleaseService;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.core.io.Resource;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/mobile-app")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class MobileAppReleaseController {
|
||||||
|
|
||||||
|
private final MobileAppReleaseService releaseService;
|
||||||
|
|
||||||
|
@GetMapping("/info")
|
||||||
|
public MobileAppReleaseInfoDto info(HttpServletRequest request) {
|
||||||
|
return releaseService.getInfo(resolvePublicBaseUrl(request));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/download/android")
|
||||||
|
public ResponseEntity<Resource> downloadAndroid() {
|
||||||
|
return releaseService.downloadAndroid();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String resolvePublicBaseUrl(HttpServletRequest request) {
|
||||||
|
String forwardedProto = request.getHeader("X-Forwarded-Proto");
|
||||||
|
String forwardedHost = request.getHeader("X-Forwarded-Host");
|
||||||
|
if (forwardedHost != null && !forwardedHost.isBlank()) {
|
||||||
|
String scheme = forwardedProto != null && !forwardedProto.isBlank() ? forwardedProto : "https";
|
||||||
|
return scheme + "://" + forwardedHost.split(",")[0].trim();
|
||||||
|
}
|
||||||
|
String scheme = request.getScheme();
|
||||||
|
int port = request.getServerPort();
|
||||||
|
String host = request.getServerName();
|
||||||
|
boolean defaultPort = ("http".equals(scheme) && port == 80) || ("https".equals(scheme) && port == 443);
|
||||||
|
return defaultPort ? scheme + "://" + host : scheme + "://" + host + ":" + port;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package com.goldenchart.dto;
|
||||||
|
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
public class MobileAppReleaseInfoDto {
|
||||||
|
private boolean available;
|
||||||
|
private String fileName;
|
||||||
|
private String version;
|
||||||
|
private Long sizeBytes;
|
||||||
|
private String updatedAt;
|
||||||
|
/** 브라우저·QR 스캔용 절대 다운로드 URL */
|
||||||
|
private String downloadUrl;
|
||||||
|
/** 모바일 설치 안내 페이지 URL */
|
||||||
|
private String installPageUrl;
|
||||||
|
}
|
||||||
@@ -23,6 +23,9 @@ public class StrategyDto {
|
|||||||
/** 매도 조건 LogicNode 트리 (JSON 그대로 직렬화) */
|
/** 매도 조건 LogicNode 트리 (JSON 그대로 직렬화) */
|
||||||
private JsonNode sellCondition;
|
private JsonNode sellCondition;
|
||||||
|
|
||||||
|
/** 편집기 flow layout { buy, sell } — START 분봉·노드 좌표 등 */
|
||||||
|
private JsonNode flowLayout;
|
||||||
|
|
||||||
private Boolean enabled;
|
private Boolean enabled;
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
private LocalDateTime updatedAt;
|
private LocalDateTime updatedAt;
|
||||||
|
|||||||
@@ -233,6 +233,11 @@ public class GcAppSettings {
|
|||||||
@JdbcTypeCode(SqlTypes.JSON)
|
@JdbcTypeCode(SqlTypes.JSON)
|
||||||
private String trendSearchSettingsJson;
|
private String trendSearchSettingsJson;
|
||||||
|
|
||||||
|
/** 편집기·팔레트·패널 크기·가상투자 목록 등 UI 설정 통합 JSON */
|
||||||
|
@Column(name = "ui_preferences_json", columnDefinition = "JSON")
|
||||||
|
@JdbcTypeCode(SqlTypes.JSON)
|
||||||
|
private String uiPreferencesJson;
|
||||||
|
|
||||||
@Column(name = "created_at", nullable = false, updatable = false)
|
@Column(name = "created_at", nullable = false, updatable = false)
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,11 @@ public class GcStrategy {
|
|||||||
@JdbcTypeCode(SqlTypes.JSON)
|
@JdbcTypeCode(SqlTypes.JSON)
|
||||||
private String sellConditionJson;
|
private String sellConditionJson;
|
||||||
|
|
||||||
|
/** 편집기 flow layout — START 분봉·좌표·orphans (Logic Expression 표시·평가 분봉 동기화) */
|
||||||
|
@Column(name = "flow_layout_json", columnDefinition = "JSON")
|
||||||
|
@JdbcTypeCode(SqlTypes.JSON)
|
||||||
|
private String flowLayoutJson;
|
||||||
|
|
||||||
@Column(name = "enabled", nullable = false)
|
@Column(name = "enabled", nullable = false)
|
||||||
@Builder.Default
|
@Builder.Default
|
||||||
private Boolean enabled = true;
|
private Boolean enabled = true;
|
||||||
|
|||||||
@@ -174,6 +174,9 @@ public class AppSettingsService {
|
|||||||
if (d.containsKey("trendSearchSettings")) {
|
if (d.containsKey("trendSearchSettings")) {
|
||||||
s.setTrendSearchSettingsJson(toJson(d.get("trendSearchSettings")));
|
s.setTrendSearchSettingsJson(toJson(d.get("trendSearchSettings")));
|
||||||
}
|
}
|
||||||
|
if (d.containsKey("uiPreferences")) {
|
||||||
|
s.setUiPreferencesJson(toJson(d.get("uiPreferences")));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private Map<String, Object> toMap(GcAppSettings s) {
|
private Map<String, Object> toMap(GcAppSettings s) {
|
||||||
@@ -226,6 +229,7 @@ public class AppSettingsService {
|
|||||||
? s.getLiveAutoTradeBudgetPct().doubleValue() : 95);
|
? s.getLiveAutoTradeBudgetPct().doubleValue() : 95);
|
||||||
m.put("fcmPushEnabled", s.getFcmPushEnabled() != null ? s.getFcmPushEnabled() : false);
|
m.put("fcmPushEnabled", s.getFcmPushEnabled() != null ? s.getFcmPushEnabled() : false);
|
||||||
m.put("trendSearchSettings", parseJson(s.getTrendSearchSettingsJson()));
|
m.put("trendSearchSettings", parseJson(s.getTrendSearchSettingsJson()));
|
||||||
|
m.put("uiPreferences", parseJson(s.getUiPreferencesJson()));
|
||||||
return m;
|
return m;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
package com.goldenchart.service;
|
||||||
|
|
||||||
|
import com.goldenchart.dto.MobileAppReleaseInfoDto;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.core.io.FileSystemResource;
|
||||||
|
import org.springframework.core.io.Resource;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.*;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@Slf4j
|
||||||
|
public class MobileAppReleaseService {
|
||||||
|
|
||||||
|
private static final String DEFAULT_APK_NAME = "goldenchart-android.apk";
|
||||||
|
private static final DateTimeFormatter DT_FMT =
|
||||||
|
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm").withZone(ZoneId.of("Asia/Seoul"));
|
||||||
|
|
||||||
|
@Value("${goldenchart.mobile-app.release-dir:data/mobile-releases}")
|
||||||
|
private String releaseDir;
|
||||||
|
|
||||||
|
@Value("${goldenchart.mobile-app.version:}")
|
||||||
|
private String configuredVersion;
|
||||||
|
|
||||||
|
@Value("${goldenchart.mobile-app.public-base-url:}")
|
||||||
|
private String configuredPublicBaseUrl;
|
||||||
|
|
||||||
|
public MobileAppReleaseInfoDto getInfo(String requestBaseUrl) {
|
||||||
|
String publicBaseUrl = resolvePublicBaseUrl(requestBaseUrl);
|
||||||
|
Optional<Path> apk = resolveApkPath();
|
||||||
|
if (apk.isEmpty()) {
|
||||||
|
return MobileAppReleaseInfoDto.builder()
|
||||||
|
.available(false)
|
||||||
|
.installPageUrl(buildInstallPageUrl(publicBaseUrl))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
Path file = apk.get();
|
||||||
|
try {
|
||||||
|
long size = Files.size(file);
|
||||||
|
Instant updated = Files.getLastModifiedTime(file).toInstant();
|
||||||
|
String base = normalizeBase(publicBaseUrl);
|
||||||
|
String downloadUrl = base + "/api/mobile-app/download/android";
|
||||||
|
return MobileAppReleaseInfoDto.builder()
|
||||||
|
.available(true)
|
||||||
|
.fileName(file.getFileName().toString())
|
||||||
|
.version(configuredVersion != null && !configuredVersion.isBlank()
|
||||||
|
? configuredVersion
|
||||||
|
: DT_FMT.format(updated))
|
||||||
|
.sizeBytes(size)
|
||||||
|
.updatedAt(DT_FMT.format(updated))
|
||||||
|
.downloadUrl(downloadUrl)
|
||||||
|
.installPageUrl(buildInstallPageUrl(publicBaseUrl))
|
||||||
|
.build();
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.warn("[mobile-app] stat failed: {}", e.getMessage());
|
||||||
|
return MobileAppReleaseInfoDto.builder()
|
||||||
|
.available(false)
|
||||||
|
.installPageUrl(buildInstallPageUrl(publicBaseUrl))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public ResponseEntity<Resource> downloadAndroid() {
|
||||||
|
Optional<Path> apk = resolveApkPath();
|
||||||
|
if (apk.isEmpty()) {
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
Path file = apk.get();
|
||||||
|
FileSystemResource resource = new FileSystemResource(file);
|
||||||
|
String filename = file.getFileName().toString();
|
||||||
|
return ResponseEntity.ok()
|
||||||
|
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"")
|
||||||
|
.contentType(MediaType.parseMediaType("application/vnd.android.package-archive"))
|
||||||
|
.body(resource);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Optional<Path> resolveApkPath() {
|
||||||
|
Path dir = Paths.get(releaseDir).toAbsolutePath().normalize();
|
||||||
|
if (!Files.isDirectory(dir)) {
|
||||||
|
log.debug("[mobile-app] release dir missing: {}", dir);
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
Path preferred = dir.resolve(DEFAULT_APK_NAME);
|
||||||
|
if (Files.isRegularFile(preferred)) {
|
||||||
|
return Optional.of(preferred);
|
||||||
|
}
|
||||||
|
try (Stream<Path> stream = Files.list(dir)) {
|
||||||
|
return stream
|
||||||
|
.filter(p -> Files.isRegularFile(p) && p.getFileName().toString().toLowerCase().endsWith(".apk"))
|
||||||
|
.max(Comparator.comparing(p -> {
|
||||||
|
try {
|
||||||
|
return Files.getLastModifiedTime(p).toMillis();
|
||||||
|
} catch (IOException e) {
|
||||||
|
return 0L;
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.warn("[mobile-app] list dir failed: {}", e.getMessage());
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String buildInstallPageUrl(String publicBaseUrl) {
|
||||||
|
return normalizeBase(publicBaseUrl) + "/install.html";
|
||||||
|
}
|
||||||
|
|
||||||
|
private String resolvePublicBaseUrl(String requestBaseUrl) {
|
||||||
|
if (configuredPublicBaseUrl != null && !configuredPublicBaseUrl.isBlank()) {
|
||||||
|
return configuredPublicBaseUrl.trim();
|
||||||
|
}
|
||||||
|
if (requestBaseUrl != null && !requestBaseUrl.isBlank()) {
|
||||||
|
return requestBaseUrl.trim();
|
||||||
|
}
|
||||||
|
return "http://exdev.co.kr";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String normalizeBase(String publicBaseUrl) {
|
||||||
|
if (publicBaseUrl == null || publicBaseUrl.isBlank()) {
|
||||||
|
return "http://exdev.co.kr";
|
||||||
|
}
|
||||||
|
return publicBaseUrl.replaceAll("/+$", "");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -67,6 +67,11 @@ public class StrategyConditionTimeframeService {
|
|||||||
GcStrategy strategy = opt.get();
|
GcStrategy strategy = opt.get();
|
||||||
ensureDslRepaired(strategy);
|
ensureDslRepaired(strategy);
|
||||||
|
|
||||||
|
Set<String> fromFlowLayout = collectFromFlowLayoutJson(strategy.getFlowLayoutJson());
|
||||||
|
if (!fromFlowLayout.isEmpty()) {
|
||||||
|
return fromFlowLayout;
|
||||||
|
}
|
||||||
|
|
||||||
Set<String> buyOut = new LinkedHashSet<>();
|
Set<String> buyOut = new LinkedHashSet<>();
|
||||||
Set<String> sellOut = new LinkedHashSet<>();
|
Set<String> sellOut = new LinkedHashSet<>();
|
||||||
boolean buyScoped = collectStartScopeFromJson(strategy.getBuyConditionJson(), buyOut);
|
boolean buyScoped = collectStartScopeFromJson(strategy.getBuyConditionJson(), buyOut);
|
||||||
@@ -241,4 +246,33 @@ public class StrategyConditionTimeframeService {
|
|||||||
out.add(ct);
|
out.add(ct);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** flow_layout_json.startMeta — 편집기 Logic Expression·START 분봉 (DSL과 동기 저장) */
|
||||||
|
private Set<String> collectFromFlowLayoutJson(String flowLayoutJson) {
|
||||||
|
Set<String> out = new LinkedHashSet<>();
|
||||||
|
if (flowLayoutJson == null || flowLayoutJson.isBlank()) return out;
|
||||||
|
try {
|
||||||
|
JsonNode root = objectMapper.readTree(flowLayoutJson);
|
||||||
|
for (String side : new String[] { "buy", "sell" }) {
|
||||||
|
JsonNode startMeta = root.path(side).path("startMeta");
|
||||||
|
if (!startMeta.isObject()) continue;
|
||||||
|
startMeta.fields().forEachRemaining(entry -> {
|
||||||
|
JsonNode meta = entry.getValue();
|
||||||
|
JsonNode types = meta.path("candleTypes");
|
||||||
|
if (types.isArray() && !types.isEmpty()) {
|
||||||
|
for (JsonNode t : types) {
|
||||||
|
String ct = LiveStrategyTimeframeService.normalize(t.asText(""));
|
||||||
|
if (!ct.isBlank()) out.add(ct);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
String ct = LiveStrategyTimeframeService.normalize(meta.path("candleType").asText(""));
|
||||||
|
if (!ct.isBlank()) out.add(ct);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("[StrategyTimeframes] flow_layout_json 파싱 실패: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,6 +72,9 @@ public class StrategyService {
|
|||||||
sellJson != null
|
sellJson != null
|
||||||
? dslTimeframeNormalizer.normalizeJson(sellJson, strategyName)
|
? dslTimeframeNormalizer.normalizeJson(sellJson, strategyName)
|
||||||
: null);
|
: null);
|
||||||
|
if (dto.getFlowLayout() != null && !dto.getFlowLayout().isNull()) {
|
||||||
|
entity.setFlowLayoutJson(objectMapper.writeValueAsString(dto.getFlowLayout()));
|
||||||
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.warn("전략 DSL 직렬화 실패: {}", e.getMessage());
|
log.warn("전략 DSL 직렬화 실패: {}", e.getMessage());
|
||||||
}
|
}
|
||||||
@@ -149,6 +152,8 @@ public class StrategyService {
|
|||||||
dto.setBuyCondition(objectMapper.readTree(e.getBuyConditionJson()));
|
dto.setBuyCondition(objectMapper.readTree(e.getBuyConditionJson()));
|
||||||
if (e.getSellConditionJson() != null)
|
if (e.getSellConditionJson() != null)
|
||||||
dto.setSellCondition(objectMapper.readTree(e.getSellConditionJson()));
|
dto.setSellCondition(objectMapper.readTree(e.getSellConditionJson()));
|
||||||
|
if (e.getFlowLayoutJson() != null && !e.getFlowLayoutJson().isBlank())
|
||||||
|
dto.setFlowLayout(objectMapper.readTree(e.getFlowLayoutJson()));
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
log.warn("전략 DSL 역직렬화 실패: {}", ex.getMessage());
|
log.warn("전략 DSL 역직렬화 실패: {}", ex.getMessage());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,12 +104,20 @@ goldenchart:
|
|||||||
upload-dir: ${GC_VERIFICATION_UPLOAD_DIR:data/verification-images}
|
upload-dir: ${GC_VERIFICATION_UPLOAD_DIR:data/verification-images}
|
||||||
max-image-size-mb: 5
|
max-image-size-mb: 5
|
||||||
max-images-per-issue: 30
|
max-images-per-issue: 30
|
||||||
|
mobile-app:
|
||||||
|
release-dir: ${GC_MOBILE_APP_RELEASE_DIR:data/mobile-releases}
|
||||||
|
version: ${GC_MOBILE_APP_VERSION:}
|
||||||
|
# QR·APK 다운로드 절대 URL (로컬 개발 시에도 exdev 서버 APK 사용)
|
||||||
|
public-base-url: ${GC_MOBILE_APP_PUBLIC_BASE_URL:http://exdev.co.kr}
|
||||||
cors:
|
cors:
|
||||||
allowed-origins:
|
allowed-origins:
|
||||||
- http://localhost:5173
|
- http://localhost:5173
|
||||||
|
- http://localhost:5174
|
||||||
- http://localhost:3000
|
- http://localhost:3000
|
||||||
- http://localhost:80
|
- http://localhost:80
|
||||||
- http://localhost
|
- http://localhost
|
||||||
|
- capacitor://localhost
|
||||||
|
- https://localhost
|
||||||
|
|
||||||
# Firebase FCM (서비스 계정 JSON: classpath:firebase-service-account.json)
|
# Firebase FCM (서비스 계정 JSON: classpath:firebase-service-account.json)
|
||||||
firebase:
|
firebase:
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- 전략편집기 flow layout (START 분봉·노드 좌표·orphans 등) — localStorage 대신 DB 단일 소스
|
||||||
|
ALTER TABLE gc_strategy
|
||||||
|
ADD COLUMN flow_layout_json JSON NULL COMMENT '전략편집기 UI 상태 {buy,sell}' AFTER sell_condition_json;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- UI·편집기·가상투자 등 클라이언트 설정 (localStorage 대체)
|
||||||
|
ALTER TABLE gc_app_settings
|
||||||
|
ADD COLUMN ui_preferences_json JSON NULL
|
||||||
|
COMMENT '프론트 UI 설정 통합 JSON'
|
||||||
|
AFTER trend_search_settings_json;
|
||||||