검증게시판 단계 추가
This commit is contained in:
+11
-2
@@ -34,11 +34,20 @@ npm run cap:android # or cap:ios
|
|||||||
|
|
||||||
Copy `.env.example` to `.env` and set `VITE_API_BASE_URL`.
|
Copy `.env.example` to `.env` and set `VITE_API_BASE_URL`.
|
||||||
|
|
||||||
|
### 로그인 시 "Failed to fetch"
|
||||||
|
|
||||||
|
- APK는 빌드 시 `.env`의 `VITE_API_BASE_URL`이 번들에 박힙니다 (기본: `http://exdev.co.kr/api`).
|
||||||
|
- `capacitor.config.ts`의 `androidScheme`은 **`http`** 여야 합니다. `https`이면 WebView가 `https://localhost`에서 뜨고 HTTP API 호출이 차단됩니다.
|
||||||
|
- 설정에 `localhost` API가 저장돼 있으면 앱 시작 시 자동으로 제거합니다.
|
||||||
|
|
||||||
## FCM (Firebase)
|
## FCM (Firebase)
|
||||||
|
|
||||||
- Android: `android/app/google-services.json`
|
- Android: `app/android/app/google-services.json` — [FCM_SETUP.md](android/FCM_SETUP.md) 참고
|
||||||
|
goldenApp(`goldenanalysisapp`)과 동일 프로젝트, 패키지 `com.goldenchart.app` 등록 필요
|
||||||
|
- 설정: `./scripts/setup-android-fcm.sh [다운로드한 json 경로]`
|
||||||
- iOS: `ios/App/GoogleService-Info.plist` + Push Notifications capability
|
- iOS: `ios/App/GoogleService-Info.plist` + Push Notifications capability
|
||||||
- Backend: `FIREBASE_ENABLED=true` + service account JSON
|
- Backend: `FIREBASE_ENABLED=true` + `firebase-service-account.json`
|
||||||
|
- 앱: 로그인 후 설정에서 **FCM 푸시** ON + 알림 권한 허용
|
||||||
|
|
||||||
## Structure
|
## Structure
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# Android FCM 설정 (goldenApp / goldenanalysisapp)
|
||||||
|
|
||||||
|
기존 **goldenApp** (`~/dev/goldenAnalysis/goldenApp`) 과 동일 Firebase 프로젝트를 사용합니다.
|
||||||
|
|
||||||
|
## 1. Firebase Console
|
||||||
|
|
||||||
|
1. [Firebase Console](https://console.firebase.google.com/) → 프로젝트 **goldenanalysisapp**
|
||||||
|
2. **앱 추가** → Android
|
||||||
|
3. 패키지 이름: `com.goldenchart.app` (Capacitor `applicationId` 와 동일)
|
||||||
|
4. `google-services.json` 다운로드
|
||||||
|
|
||||||
|
## 2. 프로젝트에 배치
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/setup-android-fcm.sh ~/Downloads/google-services.json
|
||||||
|
# 또는
|
||||||
|
cp ~/Downloads/google-services.json app/android/app/google-services.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. 빌드
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run cap:sync
|
||||||
|
cd app/android && ./gradlew assembleRelease
|
||||||
|
```
|
||||||
|
|
||||||
|
`google-services.json` 이 없으면 Gradle 로그에
|
||||||
|
`google-services plugin not applied` 가 나오며 **푸시가 동작하지 않습니다**.
|
||||||
|
|
||||||
|
## 4. 서버 (exdev)
|
||||||
|
|
||||||
|
- `FIREBASE_ENABLED=true`
|
||||||
|
- `firebase-service-account.json` (goldenanalysisapp 서비스 계정)
|
||||||
|
- 앱 설정에서 **FCM 푸시** ON (`fcm_push_enabled`)
|
||||||
|
|
||||||
|
## 5. 앱에서 확인
|
||||||
|
|
||||||
|
1. 로그인 후 **설정 → FCM 푸시** ON
|
||||||
|
2. 알림 권한 허용
|
||||||
|
3. **테스트 발송** 버튼
|
||||||
|
4. 전략 시그널 발생 시 푸시 수신
|
||||||
|
|
||||||
|
## goldenApp 과의 차이
|
||||||
|
|
||||||
|
| 항목 | goldenApp | GoldenChart (Capacitor) |
|
||||||
|
|------|-----------|-------------------------|
|
||||||
|
| 패키지 | `com.golden.app` | `com.goldenchart.app` |
|
||||||
|
| FCM SDK | 직접 Firebase Messaging | `@capacitor/push-notifications` |
|
||||||
|
| 토큰 등록 | `POST /api/fcm/token` | 동일 (`registerFcmToken`) |
|
||||||
|
| 알림 채널 | `golden_alerts` | `goldenchart_trade_signals` |
|
||||||
|
|
||||||
|
로그인 사용자는 **userId** 기준으로 FCM 토큰을 조회하므로, 웹·모바일 deviceId 가 달라도 푸시를 받을 수 있습니다.
|
||||||
@@ -7,8 +7,8 @@ android {
|
|||||||
applicationId "com.goldenchart.app"
|
applicationId "com.goldenchart.app"
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 2
|
versionCode 7
|
||||||
versionName "1.1"
|
versionName "1.6"
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
aaptOptions {
|
aaptOptions {
|
||||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"project_info": {
|
||||||
|
"project_number": "353782909389",
|
||||||
|
"project_id": "goldenanalysisapp",
|
||||||
|
"storage_bucket": "goldenanalysisapp.firebasestorage.app"
|
||||||
|
},
|
||||||
|
"client": [
|
||||||
|
{
|
||||||
|
"client_info": {
|
||||||
|
"mobilesdk_app_id": "1:353782909389:android:45e0218e69611efcc75c84",
|
||||||
|
"android_client_info": {
|
||||||
|
"package_name": "com.golden.app"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"oauth_client": [],
|
||||||
|
"api_key": [
|
||||||
|
{
|
||||||
|
"current_key": "AIzaSyBZWkVrPvG88_bYnIG94yiml3VXzht157Y"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"services": {
|
||||||
|
"appinvite_service": {
|
||||||
|
"other_platform_oauth_client": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"configuration_version": "1"
|
||||||
|
}
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<uses-permission android:name="android.permission.VIBRATE" />
|
||||||
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
@@ -10,6 +15,10 @@
|
|||||||
android:usesCleartextTraffic="true"
|
android:usesCleartextTraffic="true"
|
||||||
android:theme="@style/AppTheme">
|
android:theme="@style/AppTheme">
|
||||||
|
|
||||||
|
<meta-data
|
||||||
|
android:name="com.google.firebase.messaging.default_notification_channel_id"
|
||||||
|
android:value="@string/default_notification_channel_id" />
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation"
|
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation"
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
@@ -35,8 +44,4 @@
|
|||||||
android:resource="@xml/file_paths"></meta-data>
|
android:resource="@xml/file_paths"></meta-data>
|
||||||
</provider>
|
</provider>
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
<!-- Permissions -->
|
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@@ -1,5 +1,41 @@
|
|||||||
package com.goldenchart.app;
|
package com.goldenchart.app;
|
||||||
|
|
||||||
|
import android.app.NotificationChannel;
|
||||||
|
import android.app.NotificationManager;
|
||||||
|
import android.os.Build;
|
||||||
|
import android.os.Bundle;
|
||||||
|
|
||||||
import com.getcapacitor.BridgeActivity;
|
import com.getcapacitor.BridgeActivity;
|
||||||
|
|
||||||
public class MainActivity extends BridgeActivity {}
|
/**
|
||||||
|
* Capacitor 메인 액티비티 — FCM 알림 채널 생성 (goldenApp / Android 8+)
|
||||||
|
*/
|
||||||
|
public class MainActivity extends BridgeActivity {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onCreate(Bundle savedInstanceState) {
|
||||||
|
super.onCreate(savedInstanceState);
|
||||||
|
createNotificationChannel();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void createNotificationChannel() {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return;
|
||||||
|
String channelId = getString(R.string.default_notification_channel_id);
|
||||||
|
String channelName = getString(R.string.default_notification_channel_name);
|
||||||
|
String channelDescription = getString(R.string.default_notification_channel_description);
|
||||||
|
|
||||||
|
NotificationChannel channel = new NotificationChannel(
|
||||||
|
channelId,
|
||||||
|
channelName,
|
||||||
|
NotificationManager.IMPORTANCE_HIGH
|
||||||
|
);
|
||||||
|
channel.setDescription(channelDescription);
|
||||||
|
channel.enableVibration(true);
|
||||||
|
channel.enableLights(true);
|
||||||
|
|
||||||
|
NotificationManager manager = getSystemService(NotificationManager.class);
|
||||||
|
if (manager != null) {
|
||||||
|
manager.createNotificationChannel(channel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<?xml version='1.0' encoding='utf-8'?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">GoldenChart</string>
|
<string name="app_name">GoldenChart</string>
|
||||||
<string name="title_activity_main">GoldenChart</string>
|
<string name="title_activity_main">GoldenChart</string>
|
||||||
<string name="package_name">com.goldenchart.app</string>
|
<string name="default_notification_channel_id">goldenchart_trade_signals</string>
|
||||||
<string name="custom_url_scheme">com.goldenchart.app</string>
|
<string name="default_notification_channel_name">매매 시그널 알림</string>
|
||||||
|
<string name="default_notification_channel_description">전략 조건 일치 시 매수·매도 푸시 알림</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -5,10 +5,14 @@ const config: CapacitorConfig = {
|
|||||||
appName: 'GoldenChart',
|
appName: 'GoldenChart',
|
||||||
webDir: 'dist',
|
webDir: 'dist',
|
||||||
server: {
|
server: {
|
||||||
androidScheme: 'https',
|
// https 스킴이면 WebView가 https://localhost → http API 호출이 mixed content로 차단됨 (Failed to fetch)
|
||||||
|
androidScheme: 'http',
|
||||||
iosScheme: 'capacitor',
|
iosScheme: 'capacitor',
|
||||||
},
|
},
|
||||||
plugins: {
|
plugins: {
|
||||||
|
CapacitorHttp: {
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
SplashScreen: {
|
SplashScreen: {
|
||||||
launchAutoHide: true,
|
launchAutoHide: true,
|
||||||
backgroundColor: '#0f0f23',
|
backgroundColor: '#0f0f23',
|
||||||
|
|||||||
+8
-3
@@ -43,7 +43,7 @@ function MainApp() {
|
|||||||
const defaults = resolveAppDefaults(settings);
|
const defaults = resolveAppDefaults(settings);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isLoaded || !defaults.fcmPushEnabled) return;
|
if (!isLoaded) return;
|
||||||
void initFcmPush({
|
void initFcmPush({
|
||||||
onForeground: (_title, _body, data) => {
|
onForeground: (_title, _body, data) => {
|
||||||
if (data?.market && data?.signalType) {
|
if (data?.market && data?.signalType) {
|
||||||
@@ -67,7 +67,7 @@ function MainApp() {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}, [isLoaded, defaults.fcmPushEnabled, addNotification, refreshHistory, setTab, openVirtualFocus]);
|
}, [isLoaded, addNotification, refreshHistory, setTab, openVirtualFocus]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.documentElement.setAttribute('data-theme', defaults.theme ?? 'dark');
|
document.documentElement.setAttribute('data-theme', defaults.theme ?? 'dark');
|
||||||
@@ -105,6 +105,7 @@ function AppRoot() {
|
|||||||
isAppEntered,
|
isAppEntered,
|
||||||
handleLoginSuccess,
|
handleLoginSuccess,
|
||||||
handleGuestEnter,
|
handleGuestEnter,
|
||||||
|
sessionKey,
|
||||||
} = useAuth();
|
} = useAuth();
|
||||||
const [nativeReady, setNativeReady] = useState(false);
|
const [nativeReady, setNativeReady] = useState(false);
|
||||||
|
|
||||||
@@ -136,7 +137,11 @@ function AppRoot() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<NavigationProvider>
|
<NavigationProvider>
|
||||||
<TradeNotificationProvider soundEnabled popupEnabled={false}>
|
<TradeNotificationProvider
|
||||||
|
soundEnabled
|
||||||
|
popupEnabled={false}
|
||||||
|
settingsSessionKey={sessionKey}
|
||||||
|
>
|
||||||
<MainApp />
|
<MainApp />
|
||||||
</TradeNotificationProvider>
|
</TradeNotificationProvider>
|
||||||
</NavigationProvider>
|
</NavigationProvider>
|
||||||
|
|||||||
@@ -6,16 +6,20 @@ import React, {
|
|||||||
useMemo,
|
useMemo,
|
||||||
useState,
|
useState,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
|
import { Capacitor } from '@capacitor/core';
|
||||||
import {
|
import {
|
||||||
clearAuthSession,
|
clearAuthSession,
|
||||||
fetchAuthMe,
|
fetchAuthMe,
|
||||||
getAuthSession,
|
getAuthSession,
|
||||||
initStorage,
|
initStorage,
|
||||||
|
refreshApiBaseFromStorage,
|
||||||
setAuthSession,
|
setAuthSession,
|
||||||
|
storageGetSync,
|
||||||
|
storageRemove,
|
||||||
type AuthSession,
|
type AuthSession,
|
||||||
type LoginResponse,
|
type LoginResponse,
|
||||||
} from '../lib/shared';
|
} from '../lib/shared';
|
||||||
import { invalidateAppSettingsCache } from '../hooks/useAppSettings';
|
import { invalidateAppSettingsCache, reloadAppSettingsCache } from '../hooks/useAppSettings';
|
||||||
|
|
||||||
function normalizeRole(role: string): AuthSession['role'] {
|
function normalizeRole(role: string): AuthSession['role'] {
|
||||||
return role === 'ADMIN' ? 'ADMIN' : 'USER';
|
return role === 'ADMIN' ? 'ADMIN' : 'USER';
|
||||||
@@ -53,10 +57,22 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
void (async () => {
|
void (async () => {
|
||||||
await initStorage();
|
await initStorage();
|
||||||
|
if (Capacitor.isNativePlatform()) {
|
||||||
|
const savedApi = storageGetSync('gc_api_base_url');
|
||||||
|
if (savedApi && /localhost|127\.0\.0\.1/i.test(savedApi)) {
|
||||||
|
await storageRemove('gc_api_base_url');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
refreshApiBaseFromStorage();
|
||||||
const stored = getAuthSession();
|
const stored = getAuthSession();
|
||||||
if (stored) {
|
if (stored) {
|
||||||
try {
|
try {
|
||||||
const me = await fetchAuthMe();
|
const me = await Promise.race([
|
||||||
|
fetchAuthMe(),
|
||||||
|
new Promise<null>((_, reject) => {
|
||||||
|
setTimeout(() => reject(new Error('session verify timeout')), 15_000);
|
||||||
|
}),
|
||||||
|
]);
|
||||||
if (me && !cancelled) {
|
if (me && !cancelled) {
|
||||||
const session = loginToSession(me);
|
const session = loginToSession(me);
|
||||||
setAuthSession(session);
|
setAuthSession(session);
|
||||||
@@ -84,7 +100,9 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
setAuthUser(session);
|
setAuthUser(session);
|
||||||
setGuestMode(false);
|
setGuestMode(false);
|
||||||
invalidateAppSettingsCache();
|
invalidateAppSettingsCache();
|
||||||
|
void reloadAppSettingsCache().finally(() => {
|
||||||
setSessionKey(k => k + 1);
|
setSessionKey(k => k + 1);
|
||||||
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleGuestEnter = useCallback(() => {
|
const handleGuestEnter = useCallback(() => {
|
||||||
@@ -92,7 +110,9 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
setAuthUser(null);
|
setAuthUser(null);
|
||||||
setGuestMode(true);
|
setGuestMode(true);
|
||||||
invalidateAppSettingsCache();
|
invalidateAppSettingsCache();
|
||||||
|
void reloadAppSettingsCache().finally(() => {
|
||||||
setSessionKey(k => k + 1);
|
setSessionKey(k => k + 1);
|
||||||
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleLogout = useCallback(async () => {
|
const handleLogout = useCallback(async () => {
|
||||||
|
|||||||
@@ -4,4 +4,5 @@ export {
|
|||||||
subscribeAppSettings,
|
subscribeAppSettings,
|
||||||
resolveAppDefaults,
|
resolveAppDefaults,
|
||||||
invalidateAppSettingsCache,
|
invalidateAppSettingsCache,
|
||||||
|
reloadAppSettingsCache,
|
||||||
} from '@frontend/hooks/useAppSettings';
|
} from '@frontend/hooks/useAppSettings';
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
/** 웹 SplashScreen과 동일한 로그인 진입 화면 */
|
|
||||||
import SplashScreen from '@frontend/components/SplashScreen';
|
|
||||||
import type { LoginResponse } from '../lib/shared';
|
import type { LoginResponse } from '../lib/shared';
|
||||||
|
import MobileLoginScreen from './MobileLoginScreen';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onLoginSuccess: (res: LoginResponse) => void;
|
onLoginSuccess: (res: LoginResponse) => void;
|
||||||
@@ -8,5 +7,5 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function LoginScreen({ onLoginSuccess, onGuest }: Props) {
|
export default function LoginScreen({ onLoginSuccess, onGuest }: Props) {
|
||||||
return <SplashScreen onLoginSuccess={onLoginSuccess} onGuest={onGuest} />;
|
return <MobileLoginScreen onLoginSuccess={onLoginSuccess} onGuest={onGuest} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
/**
|
||||||
|
* 모바일 로그인 — SplashScreen UI + 느린 서버 대응(타임아웃·안내 문구)
|
||||||
|
*/
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { loginUser, type LoginResponse } from '../lib/shared';
|
||||||
|
import '@frontend/styles/splashScreen.css';
|
||||||
|
|
||||||
|
const DEFAULT_USERNAME = 'admin';
|
||||||
|
const DEFAULT_PASSWORD = 'admin';
|
||||||
|
const APP_VERSION = '1.2';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onLoginSuccess: (res: LoginResponse) => void;
|
||||||
|
onGuest: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MobileLoginScreen({ onLoginSuccess, onGuest }: Props) {
|
||||||
|
const [username, setUsername] = useState(DEFAULT_USERNAME);
|
||||||
|
const [password, setPassword] = useState(DEFAULT_PASSWORD);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [slowHint, setSlowHint] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!loading) {
|
||||||
|
setSlowHint(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const t = window.setTimeout(() => setSlowHint(true), 5000);
|
||||||
|
return () => window.clearTimeout(t);
|
||||||
|
}, [loading]);
|
||||||
|
|
||||||
|
const submitLogin = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await loginUser(username.trim(), password);
|
||||||
|
onLoginSuccess(res);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : '로그인에 실패했습니다.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="splash">
|
||||||
|
<div className="splash-photo-bg" aria-hidden />
|
||||||
|
<div className="splash-photo-overlay" aria-hidden />
|
||||||
|
|
||||||
|
<div className="splash-center">
|
||||||
|
<div className="splash-glass">
|
||||||
|
<h1 className="splash-brand">GoldenChart</h1>
|
||||||
|
|
||||||
|
<form className="splash-form" onSubmit={submitLogin}>
|
||||||
|
<input
|
||||||
|
className="splash-input"
|
||||||
|
type="text"
|
||||||
|
autoComplete="username"
|
||||||
|
value={username}
|
||||||
|
onChange={ev => setUsername(ev.target.value)}
|
||||||
|
disabled={loading}
|
||||||
|
placeholder="ID"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className="splash-input"
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
value={password}
|
||||||
|
onChange={ev => setPassword(ev.target.value)}
|
||||||
|
disabled={loading}
|
||||||
|
placeholder="••••••••"
|
||||||
|
/>
|
||||||
|
{error && <p className="splash-error">{error}</p>}
|
||||||
|
{slowHint && loading && !error && (
|
||||||
|
<p className="splash-error" style={{ opacity: 0.85 }}>
|
||||||
|
서버 응답이 느립니다. Wi‑Fi·모바일 데이터를 확인해 주세요…
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<button type="submit" className="splash-login-btn" disabled={loading}>
|
||||||
|
{loading ? '로그인 중…' : '로그인'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p className="splash-version">
|
||||||
|
버전: {APP_VERSION} | Core Engine: Ta4j & Spring Boot
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="button" className="splash-guest-link" onClick={onGuest} disabled={loading}>
|
||||||
|
게스트로 체험하기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import React, { useMemo, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useTradeNotification } from '../../contexts/TradeNotificationContext';
|
import { useTradeNotification } from '../../contexts/TradeNotificationContext';
|
||||||
import { useNavigation } from '../../contexts/NavigationContext';
|
import { useNavigation } from '../../contexts/NavigationContext';
|
||||||
import { useAuth } from '../../contexts/AuthContext';
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
|
import { useAppSettings, reloadAppSettingsCache } from '../../hooks/useAppSettings';
|
||||||
import { useVirtualTradingCore } from '@frontend/hooks/useVirtualTradingCore';
|
import { useVirtualTradingCore } from '@frontend/hooks/useVirtualTradingCore';
|
||||||
import NotificationListRow from './NotificationListRow';
|
import NotificationListRow from './NotificationListRow';
|
||||||
import NotificationDetailScreen from './NotificationDetailScreen';
|
import NotificationDetailScreen from './NotificationDetailScreen';
|
||||||
@@ -15,7 +16,8 @@ export default function NotificationsScreen() {
|
|||||||
goNotifyTrade,
|
goNotifyTrade,
|
||||||
goVirtualDetail,
|
goVirtualDetail,
|
||||||
} = useNavigation();
|
} = useNavigation();
|
||||||
const { sessionKey } = useAuth();
|
const { sessionKey, guestMode, authUser } = useAuth();
|
||||||
|
const { isLoaded: settingsLoaded } = useAppSettings(sessionKey);
|
||||||
const {
|
const {
|
||||||
allNotifications,
|
allNotifications,
|
||||||
unreadCount,
|
unreadCount,
|
||||||
@@ -28,16 +30,48 @@ export default function NotificationsScreen() {
|
|||||||
const { summary, refreshPaperData } = useVirtualTradingCore({ settingsSessionKey: sessionKey });
|
const { summary, refreshPaperData } = useVirtualTradingCore({ settingsSessionKey: sessionKey });
|
||||||
|
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const [pullY, setPullY] = useState(0);
|
||||||
|
const listRef = useRef<HTMLDivElement>(null);
|
||||||
|
const touchStartY = useRef(0);
|
||||||
|
|
||||||
const selectedItem = useMemo(
|
const selectedItem = useMemo(
|
||||||
() => allNotifications.find(n => n.id === notifyNav.notifyId) ?? null,
|
() => allNotifications.find(n => n.id === notifyNav.notifyId) ?? null,
|
||||||
[allNotifications, notifyNav.notifyId],
|
[allNotifications, notifyNav.notifyId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const onRefresh = async () => {
|
const onRefresh = useCallback(async () => {
|
||||||
|
if (refreshing) return;
|
||||||
setRefreshing(true);
|
setRefreshing(true);
|
||||||
await refreshHistory();
|
try {
|
||||||
|
await reloadAppSettingsCache();
|
||||||
|
await refreshHistory({ serverOnly: true, rawServerList: true });
|
||||||
|
} finally {
|
||||||
setRefreshing(false);
|
setRefreshing(false);
|
||||||
|
setPullY(0);
|
||||||
|
}
|
||||||
|
}, [refreshing, refreshHistory]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!settingsLoaded) return;
|
||||||
|
void refreshHistory({ serverOnly: true, rawServerList: true });
|
||||||
|
}, [settingsLoaded, sessionKey, refreshHistory]);
|
||||||
|
|
||||||
|
const onTouchStart = (e: React.TouchEvent) => {
|
||||||
|
const el = listRef.current;
|
||||||
|
if (!el || el.scrollTop > 0) return;
|
||||||
|
touchStartY.current = e.touches[0].clientY;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onTouchMove = (e: React.TouchEvent) => {
|
||||||
|
const el = listRef.current;
|
||||||
|
if (!el || el.scrollTop > 0) return;
|
||||||
|
const dy = e.touches[0].clientY - touchStartY.current;
|
||||||
|
if (dy > 0) setPullY(Math.min(dy, 72));
|
||||||
|
};
|
||||||
|
|
||||||
|
const onTouchEnd = () => {
|
||||||
|
if (pullY >= 48) void onRefresh();
|
||||||
|
else setPullY(0);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (notifyNav.view === 'detail' && selectedItem) {
|
if (notifyNav.view === 'detail' && selectedItem) {
|
||||||
@@ -70,19 +104,55 @@ export default function NotificationsScreen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="screen">
|
<div className="screen notify-screen">
|
||||||
<header className="screen-header">
|
<header className="screen-header">
|
||||||
<h1 className="screen-title">알림 {unreadCount > 0 && `(${unreadCount})`}</h1>
|
<h1 className="screen-title">알림 {unreadCount > 0 && `(${unreadCount})`}</h1>
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||||
<button type="button" className="chip" onClick={() => void onRefresh()}>{refreshing ? '…' : '↻'}</button>
|
<button
|
||||||
|
type="button"
|
||||||
|
className="chip notify-refresh-btn"
|
||||||
|
onClick={() => void onRefresh()}
|
||||||
|
disabled={refreshing}
|
||||||
|
aria-label="서버에서 알림 새로고침"
|
||||||
|
>
|
||||||
|
{refreshing ? '새로고침…' : '↻ 새로고침'}
|
||||||
|
</button>
|
||||||
<button type="button" className="chip" onClick={markAllAsRead}>읽음</button>
|
<button type="button" className="chip" onClick={markAllAsRead}>읽음</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
{guestMode && (
|
||||||
|
<p className="notify-hint text-muted" style={{ padding: '0 16px 8px', fontSize: 12, margin: 0 }}>
|
||||||
|
게스트 모드 — 웹과 동일 목록은 로그인 후 새로고침하세요.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{!guestMode && authUser && (
|
||||||
|
<p className="notify-hint text-muted" style={{ padding: '0 16px 8px', fontSize: 12, margin: 0 }}>
|
||||||
|
{authUser.username} · 서버 이력과 동기화됩니다
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={listRef}
|
||||||
|
className="notify-list-wrap"
|
||||||
|
onTouchStart={onTouchStart}
|
||||||
|
onTouchMove={onTouchMove}
|
||||||
|
onTouchEnd={onTouchEnd}
|
||||||
|
style={{ flex: 1, overflow: 'auto', WebkitOverflowScrolling: 'touch' }}
|
||||||
|
>
|
||||||
|
{(pullY > 0 || refreshing) && (
|
||||||
|
<div className="notify-pull-hint" style={{ textAlign: 'center', padding: pullY > 0 ? `${pullY / 4}px` : 8, fontSize: 12, color: 'var(--gc-text-muted)' }}>
|
||||||
|
{refreshing ? '서버에서 불러오는 중…' : pullY >= 48 ? '놓으면 새로고침' : '당겨서 새로고침'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{allNotifications.length === 0 ? (
|
{allNotifications.length === 0 ? (
|
||||||
<div className="empty-state">
|
<div className="empty-state">
|
||||||
<h3>알림 없음</h3>
|
<h3>알림 없음</h3>
|
||||||
<p>전략 조건 충족 시 알림이 표시됩니다</p>
|
<p>전략 조건 충족 시 알림이 표시됩니다</p>
|
||||||
|
<button type="button" className="btn-secondary" style={{ marginTop: 12 }} onClick={() => void onRefresh()}>
|
||||||
|
새로고침
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="stack-list" style={{ padding: '0 16px' }}>
|
<div className="stack-list" style={{ padding: '0 16px' }}>
|
||||||
@@ -103,6 +173,7 @@ export default function NotificationsScreen() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{allNotifications.length > 0 && (
|
{allNotifications.length > 0 && (
|
||||||
<div style={{ padding: 16, textAlign: 'center' }}>
|
<div style={{ padding: 16, textAlign: 'center' }}>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useAuth } from '../../contexts/AuthContext';
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
|
import { useAppSettings } from '../../hooks/useAppSettings';
|
||||||
import { useVirtualTradingCore } from '@frontend/hooks/useVirtualTradingCore';
|
import { useVirtualTradingCore } from '@frontend/hooks/useVirtualTradingCore';
|
||||||
import { resolveVirtualTargetStrategyId } from '@frontend/utils/virtualTargetStrategy';
|
import { resolveVirtualTargetStrategyId } from '@frontend/utils/virtualTargetStrategy';
|
||||||
import { useNavigation } from '../../contexts/NavigationContext';
|
import { useNavigation } from '../../contexts/NavigationContext';
|
||||||
@@ -20,6 +21,7 @@ export default function VirtualTradingScreen() {
|
|||||||
goVirtualHistory,
|
goVirtualHistory,
|
||||||
} = useNavigation();
|
} = useNavigation();
|
||||||
const { sessionKey: settingsSessionKey, authUser, guestMode } = useAuth();
|
const { sessionKey: settingsSessionKey, authUser, guestMode } = useAuth();
|
||||||
|
const { isLoaded: settingsLoaded } = useAppSettings(settingsSessionKey);
|
||||||
|
|
||||||
const core = useVirtualTradingCore({ settingsSessionKey });
|
const core = useVirtualTradingCore({ settingsSessionKey });
|
||||||
const {
|
const {
|
||||||
@@ -156,15 +158,15 @@ export default function VirtualTradingScreen() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading ? (
|
{!settingsLoaded || loading ? (
|
||||||
<div className="loading-center">로딩 중…</div>
|
<div className="loading-center">{!settingsLoaded ? '설정 동기화 중…' : '로딩 중…'}</div>
|
||||||
) : targets.length === 0 ? (
|
) : targets.length === 0 ? (
|
||||||
<div className="empty-state">
|
<div className="empty-state">
|
||||||
<h3>투자 대상 없음</h3>
|
<h3>투자 대상 없음</h3>
|
||||||
{guestMode ? (
|
{guestMode ? (
|
||||||
<p>게스트 모드입니다. 웹과 같은 목록을 보려면 동일 계정으로 로그인하세요.</p>
|
<p>게스트 모드입니다. 웹과 같은 목록을 보려면 <strong>동일 계정으로 로그인</strong>하세요.</p>
|
||||||
) : (
|
) : (
|
||||||
<p>웹과 동일 계정({authUser?.username}) · + 버튼으로 종목 추가</p>
|
<p>웹({authUser?.username})과 동일 DB입니다. ↻ 동기화 또는 + 로 종목 추가</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
+50
-12
@@ -5,6 +5,9 @@ import {
|
|||||||
import { PushNotifications } from '@capacitor/push-notifications';
|
import { PushNotifications } from '@capacitor/push-notifications';
|
||||||
import { Capacitor } from '@capacitor/core';
|
import { Capacitor } from '@capacitor/core';
|
||||||
|
|
||||||
|
/** AndroidManifest default_notification_channel_id 와 동일 */
|
||||||
|
export const FCM_CHANNEL_ID = 'goldenchart_trade_signals';
|
||||||
|
|
||||||
export interface FcmPayload {
|
export interface FcmPayload {
|
||||||
type?: string;
|
type?: string;
|
||||||
market?: string;
|
market?: string;
|
||||||
@@ -19,42 +22,77 @@ type FcmHandlers = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let initialized = false;
|
let initialized = false;
|
||||||
|
let listenersAttached = false;
|
||||||
|
|
||||||
export async function initFcmPush(handlers: FcmHandlers = {}): Promise<boolean> {
|
async function ensureAndroidNotificationChannel(): Promise<void> {
|
||||||
if (!Capacitor.isNativePlatform()) {
|
if (Capacitor.getPlatform() !== 'android') return;
|
||||||
console.info('[FCM] Web dev — native push skipped');
|
try {
|
||||||
return false;
|
await PushNotifications.createChannel({
|
||||||
|
id: FCM_CHANNEL_ID,
|
||||||
|
name: '매매 시그널 알림',
|
||||||
|
description: '전략 조건 일치 시 매수·매도 푸시',
|
||||||
|
importance: 5,
|
||||||
|
visibility: 1,
|
||||||
|
vibration: true,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[FCM] createChannel failed', e);
|
||||||
}
|
}
|
||||||
if (initialized) return true;
|
}
|
||||||
|
|
||||||
const perm = await PushNotifications.requestPermissions();
|
function attachListeners(handlers: FcmHandlers): void {
|
||||||
if (perm.receive !== 'granted') return false;
|
if (listenersAttached) return;
|
||||||
|
listenersAttached = true;
|
||||||
|
|
||||||
await PushNotifications.addListener('registration', async token => {
|
void PushNotifications.addListener('registration', async token => {
|
||||||
try {
|
try {
|
||||||
await registerFcmToken(token.value);
|
await registerFcmToken(token.value);
|
||||||
console.info('[FCM] token registered');
|
console.info('[FCM] token registered with server');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('[FCM] token register failed', e);
|
console.warn('[FCM] token register failed', e);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
await PushNotifications.addListener('registrationError', err => {
|
void PushNotifications.addListener('registrationError', err => {
|
||||||
console.warn('[FCM] registration error', err);
|
console.warn('[FCM] registration error', err);
|
||||||
});
|
});
|
||||||
|
|
||||||
await PushNotifications.addListener('pushNotificationReceived', notification => {
|
void PushNotifications.addListener('pushNotificationReceived', notification => {
|
||||||
const title = notification.title ?? 'GoldenChart';
|
const title = notification.title ?? 'GoldenChart';
|
||||||
const body = notification.body ?? '';
|
const body = notification.body ?? '';
|
||||||
handlers.onForeground?.(title, body, notification.data as FcmPayload);
|
handlers.onForeground?.(title, body, notification.data as FcmPayload);
|
||||||
});
|
});
|
||||||
|
|
||||||
await PushNotifications.addListener('pushNotificationActionPerformed', action => {
|
void PushNotifications.addListener('pushNotificationActionPerformed', action => {
|
||||||
handlers.onTap?.(action.notification.data as FcmPayload);
|
handlers.onTap?.(action.notification.data as FcmPayload);
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FCM 토큰 등록 (로그인 후 호출 — 서버 fcm_push_enabled 와 별개로 토큰만 등록)
|
||||||
|
*/
|
||||||
|
export async function initFcmPush(handlers: FcmHandlers = {}): Promise<boolean> {
|
||||||
|
if (!Capacitor.isNativePlatform()) {
|
||||||
|
console.info('[FCM] Web dev — native push skipped');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await ensureAndroidNotificationChannel();
|
||||||
|
attachListeners(handlers);
|
||||||
|
|
||||||
|
const perm = await PushNotifications.checkPermissions();
|
||||||
|
if (perm.receive !== 'granted') {
|
||||||
|
const req = await PushNotifications.requestPermissions();
|
||||||
|
if (req.receive !== 'granted') {
|
||||||
|
console.warn('[FCM] notification permission denied');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!initialized) {
|
||||||
await PushNotifications.register();
|
await PushNotifications.register();
|
||||||
initialized = true;
|
initialized = true;
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
plugins {
|
||||||
|
id 'com.android.application'
|
||||||
|
id 'org.jetbrains.kotlin.android'
|
||||||
|
id 'kotlin-kapt'
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace 'com.analysis.goldenanalysis'
|
||||||
|
compileSdk 34
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId "com.analysis.goldenanalysis"
|
||||||
|
minSdk 24
|
||||||
|
targetSdk 34
|
||||||
|
versionCode 1
|
||||||
|
versionName "1.0.0"
|
||||||
|
|
||||||
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
|
||||||
|
// 빌드 시간을 버전 이름에 포함하여 빌드 확인 가능
|
||||||
|
buildConfigField "long", "BUILD_TIMESTAMP", System.currentTimeMillis() + "L"
|
||||||
|
}
|
||||||
|
|
||||||
|
// signingConfigs는 buildTypes보다 먼저 정의되어야 함
|
||||||
|
signingConfigs {
|
||||||
|
release {
|
||||||
|
def keystoreFile = file("${rootProject.projectDir}/golden-analysis.keystore")
|
||||||
|
if (keystoreFile.exists()) {
|
||||||
|
storeFile keystoreFile
|
||||||
|
storePassword System.getenv("KEYSTORE_PASSWORD") ?: "goldenanalysis"
|
||||||
|
keyAlias System.getenv("KEY_ALIAS") ?: "golden-analysis"
|
||||||
|
keyPassword System.getenv("KEY_PASSWORD") ?: "goldenanalysis"
|
||||||
|
v1SigningEnabled true
|
||||||
|
v2SigningEnabled true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
minifyEnabled false
|
||||||
|
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||||
|
// 서명 설정 적용
|
||||||
|
def keystoreFile = file("${rootProject.projectDir}/golden-analysis.keystore")
|
||||||
|
if (keystoreFile.exists()) {
|
||||||
|
signingConfig signingConfigs.release
|
||||||
|
}
|
||||||
|
}
|
||||||
|
debug {
|
||||||
|
applicationIdSuffix ".debug"
|
||||||
|
versionNameSuffix "-debug"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility JavaVersion.VERSION_17
|
||||||
|
targetCompatibility JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = '17'
|
||||||
|
}
|
||||||
|
|
||||||
|
buildFeatures {
|
||||||
|
viewBinding true
|
||||||
|
buildConfig true // BuildConfig 생성 활성화
|
||||||
|
}
|
||||||
|
|
||||||
|
// 캐시 문제 방지를 위한 설정
|
||||||
|
gradle.projectsEvaluated {
|
||||||
|
tasks.withType(JavaCompile) {
|
||||||
|
options.compilerArgs << "-Xlint:unchecked" << "-Xlint:deprecation"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation 'androidx.core:core-ktx:1.12.0'
|
||||||
|
implementation 'androidx.appcompat:appcompat:1.6.1'
|
||||||
|
implementation 'com.google.android.material:material:1.11.0'
|
||||||
|
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
|
||||||
|
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
|
||||||
|
implementation 'androidx.recyclerview:recyclerview:1.3.2'
|
||||||
|
implementation 'androidx.coordinatorlayout:coordinatorlayout:1.2.0'
|
||||||
|
implementation 'androidx.navigation:navigation-fragment-ktx:2.7.7'
|
||||||
|
implementation 'androidx.navigation:navigation-ui-ktx:2.7.7'
|
||||||
|
testImplementation 'junit:junit:4.13.2'
|
||||||
|
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
|
||||||
|
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
|
||||||
|
|
||||||
|
// Room components
|
||||||
|
implementation "androidx.room:room-runtime:2.6.1"
|
||||||
|
kapt "androidx.room:room-compiler:2.6.1"
|
||||||
|
implementation "androidx.room:room-ktx:2.6.1"
|
||||||
|
|
||||||
|
// WorkManager
|
||||||
|
implementation "androidx.work:work-runtime-ktx:2.9.0"
|
||||||
|
|
||||||
|
// Retrofit for networking
|
||||||
|
implementation 'com.squareup.retrofit2:retrofit:2.9.0'
|
||||||
|
implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
|
||||||
|
implementation 'com.squareup.okhttp3:okhttp:4.11.0'
|
||||||
|
implementation 'com.squareup.okhttp3:logging-interceptor:4.11.0'
|
||||||
|
|
||||||
|
// Coroutines
|
||||||
|
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3"
|
||||||
|
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3"
|
||||||
|
|
||||||
|
// Lifecycle components
|
||||||
|
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0"
|
||||||
|
implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.7.0"
|
||||||
|
}
|
||||||
Vendored
+18
@@ -0,0 +1,18 @@
|
|||||||
|
# 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
|
||||||
|
|
||||||
|
# Keep Retrofit and Gson
|
||||||
|
-keepattributes Signature
|
||||||
|
-keepattributes *Annotation*
|
||||||
|
-keep class com.google.gson.** { *; }
|
||||||
|
-keep class com.squareup.retrofit2.** { *; }
|
||||||
|
|
||||||
|
# Keep Room Database
|
||||||
|
-keep class * extends androidx.room.RoomDatabase
|
||||||
|
-keep @androidx.room.Entity class *
|
||||||
|
-dontwarn androidx.room.paging.**
|
||||||
|
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
|
||||||
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:allowBackup="true"
|
||||||
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
|
android:fullBackupContent="@xml/backup_rules"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:theme="@style/Theme.GoldenAnalysis"
|
||||||
|
android:usesCleartextTraffic="true"
|
||||||
|
android:networkSecurityConfig="@xml/network_security_config"
|
||||||
|
tools:targetApi="31">
|
||||||
|
|
||||||
|
<!-- Splash Activity -->
|
||||||
|
<activity
|
||||||
|
android:name=".ui.SplashActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:theme="@style/Theme.GoldenAnalysis.Splash">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
<!-- Main Activity -->
|
||||||
|
<activity
|
||||||
|
android:name=".ui.MainActivity"
|
||||||
|
android:exported="false"
|
||||||
|
android:theme="@style/Theme.GoldenAnalysis" />
|
||||||
|
|
||||||
|
<!-- WorkManager Initialization -->
|
||||||
|
<provider
|
||||||
|
android:name="androidx.startup.InitializationProvider"
|
||||||
|
android:authorities="${applicationId}.androidx-startup"
|
||||||
|
android:exported="false"
|
||||||
|
tools:node="merge">
|
||||||
|
<meta-data
|
||||||
|
android:name="androidx.work.WorkManagerInitializer"
|
||||||
|
android:value="androidx.startup" />
|
||||||
|
</provider>
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package com.analysis.goldenanalysis.ui
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.util.Log
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.navigation.fragment.NavHostFragment
|
||||||
|
import androidx.navigation.ui.AppBarConfiguration
|
||||||
|
import androidx.navigation.ui.setupActionBarWithNavController
|
||||||
|
import androidx.navigation.ui.setupWithNavController
|
||||||
|
import com.analysis.goldenanalysis.R
|
||||||
|
import com.analysis.goldenanalysis.databinding.ActivityMainBinding
|
||||||
|
import com.google.android.material.bottomnavigation.BottomNavigationView
|
||||||
|
|
||||||
|
class MainActivity : AppCompatActivity() {
|
||||||
|
|
||||||
|
private lateinit var binding: ActivityMainBinding
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
|
try {
|
||||||
|
Log.d("MainActivity", "onCreate started")
|
||||||
|
|
||||||
|
binding = ActivityMainBinding.inflate(layoutInflater)
|
||||||
|
setContentView(binding.root)
|
||||||
|
|
||||||
|
Log.d("MainActivity", "Layout inflated")
|
||||||
|
|
||||||
|
// Setup bottom navigation
|
||||||
|
val navView: BottomNavigationView = binding.navView
|
||||||
|
|
||||||
|
Log.d("MainActivity", "Getting NavHostFragment")
|
||||||
|
val navHostFragment = supportFragmentManager
|
||||||
|
.findFragmentById(R.id.nav_host_fragment_activity_main) as? NavHostFragment
|
||||||
|
|
||||||
|
if (navHostFragment == null) {
|
||||||
|
Log.e("MainActivity", "NavHostFragment not found!")
|
||||||
|
Toast.makeText(this, "Navigation 초기화 실패", Toast.LENGTH_LONG).show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val navController = navHostFragment.navController
|
||||||
|
Log.d("MainActivity", "NavController obtained")
|
||||||
|
|
||||||
|
// Passing each menu ID as a set of Ids because each
|
||||||
|
// menu should be considered as top level destinations.
|
||||||
|
val appBarConfiguration = AppBarConfiguration(
|
||||||
|
setOf(
|
||||||
|
R.id.navigation_dashboard,
|
||||||
|
R.id.navigation_portfolio,
|
||||||
|
R.id.navigation_chart,
|
||||||
|
R.id.navigation_alerts,
|
||||||
|
R.id.navigation_scheduler,
|
||||||
|
R.id.navigation_settings
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
setupActionBarWithNavController(navController, appBarConfiguration)
|
||||||
|
navView.setupWithNavController(navController)
|
||||||
|
Log.d("MainActivity", "Navigation setup complete")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("MainActivity", "Error setting up navigation", e)
|
||||||
|
// Continue without action bar
|
||||||
|
navView.setupWithNavController(navController)
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("MainActivity", "Fatal error in onCreate", e)
|
||||||
|
Toast.makeText(this, "앱 초기화 중 오류 발생: ${e.message}", Toast.LENGTH_LONG).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package com.analysis.goldenanalysis.ui
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import com.analysis.goldenanalysis.R
|
||||||
|
|
||||||
|
class SplashActivity : AppCompatActivity() {
|
||||||
|
|
||||||
|
private val splashTimeOut: Long = 2000 // 2 seconds
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
|
try {
|
||||||
|
Log.d("SplashActivity", "onCreate started")
|
||||||
|
setContentView(R.layout.activity_splash)
|
||||||
|
Log.d("SplashActivity", "Layout set")
|
||||||
|
|
||||||
|
// Navigate to MainActivity after splash timeout
|
||||||
|
Handler(Looper.getMainLooper()).postDelayed({
|
||||||
|
try {
|
||||||
|
Log.d("SplashActivity", "Starting MainActivity")
|
||||||
|
val intent = Intent(this, MainActivity::class.java)
|
||||||
|
startActivity(intent)
|
||||||
|
finish()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("SplashActivity", "Error starting MainActivity", e)
|
||||||
|
}
|
||||||
|
}, splashTimeOut)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("SplashActivity", "Error in onCreate", e)
|
||||||
|
// Try to start MainActivity directly
|
||||||
|
try {
|
||||||
|
val intent = Intent(this, MainActivity::class.java)
|
||||||
|
startActivity(intent)
|
||||||
|
finish()
|
||||||
|
} catch (e2: Exception) {
|
||||||
|
Log.e("SplashActivity", "Failed to recover", e2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
package com.analysis.goldenanalysis.ui.alert
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.MenuItem
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.widget.LinearLayout
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.core.os.bundleOf
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import androidx.navigation.fragment.findNavController
|
||||||
|
import com.analysis.goldenanalysis.R
|
||||||
|
import com.analysis.goldenanalysis.data.api.ApiClientFactory
|
||||||
|
import com.analysis.goldenanalysis.data.model.AlertNotificationDto
|
||||||
|
import com.analysis.goldenanalysis.databinding.FragmentAlertsBinding
|
||||||
|
import com.analysis.goldenanalysis.databinding.ItemAlertPopupCardBinding
|
||||||
|
import com.analysis.goldenanalysis.databinding.ItemAlertStockRowBinding
|
||||||
|
import com.analysis.goldenanalysis.util.AlertMessageParser
|
||||||
|
import com.analysis.goldenanalysis.util.AlertSignalInference
|
||||||
|
import com.analysis.goldenanalysis.util.UpbitWebUrls
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 미읽음 알림 — 프론트 AlertSnackbar와 유사한 카드 스택 UI
|
||||||
|
*/
|
||||||
|
class AlertsFragment : Fragment() {
|
||||||
|
|
||||||
|
private var _binding: FragmentAlertsBinding? = null
|
||||||
|
private val binding get() = _binding!!
|
||||||
|
|
||||||
|
private var cryptoMap: Map<String, String> = emptyMap()
|
||||||
|
|
||||||
|
override fun onCreateView(
|
||||||
|
inflater: LayoutInflater,
|
||||||
|
container: ViewGroup?,
|
||||||
|
savedInstanceState: Bundle?,
|
||||||
|
): View {
|
||||||
|
_binding = FragmentAlertsBinding.inflate(inflater, container, false)
|
||||||
|
return binding.root
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
super.onViewCreated(view, savedInstanceState)
|
||||||
|
(activity as? AppCompatActivity)?.setSupportActionBar(binding.toolbar)
|
||||||
|
binding.toolbar.inflateMenu(R.menu.menu_alerts)
|
||||||
|
binding.toolbar.setOnMenuItemClickListener(::onMenuItem)
|
||||||
|
|
||||||
|
binding.swipeRefresh.setOnRefreshListener { loadAll() }
|
||||||
|
loadAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onMenuItem(item: MenuItem): Boolean {
|
||||||
|
if (item.itemId == R.id.action_all_notifications) {
|
||||||
|
findNavController().navigate(R.id.action_alerts_to_notification_list)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadAll() {
|
||||||
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
binding.progress.visibility = View.VISIBLE
|
||||||
|
binding.tvEmpty.visibility = View.GONE
|
||||||
|
try {
|
||||||
|
val api = ApiClientFactory.alertApi(requireContext())
|
||||||
|
val cryptos = withContext(Dispatchers.IO) {
|
||||||
|
runCatching { api.getCryptoList() }.getOrElse { emptyList() }
|
||||||
|
}
|
||||||
|
cryptoMap = cryptos.associate { it.koreanName to it.symbol }
|
||||||
|
val unread = withContext(Dispatchers.IO) {
|
||||||
|
runCatching { api.getUnreadNotifications(null) }.getOrElse { emptyList() }
|
||||||
|
}
|
||||||
|
renderCards(unread)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Toast.makeText(requireContext(), "알림 로드 실패: ${e.message}", Toast.LENGTH_LONG).show()
|
||||||
|
binding.tvEmpty.visibility = View.VISIBLE
|
||||||
|
} finally {
|
||||||
|
binding.progress.visibility = View.GONE
|
||||||
|
binding.swipeRefresh.isRefreshing = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun renderCards(notifications: List<AlertNotificationDto>) {
|
||||||
|
binding.cardsContainer.removeAllViews()
|
||||||
|
if (notifications.isEmpty()) {
|
||||||
|
binding.tvEmpty.visibility = View.VISIBLE
|
||||||
|
return
|
||||||
|
}
|
||||||
|
binding.tvEmpty.visibility = View.GONE
|
||||||
|
val inflater = layoutInflater
|
||||||
|
notifications.forEach { n ->
|
||||||
|
val card = ItemAlertPopupCardBinding.inflate(inflater, binding.cardsContainer, false)
|
||||||
|
val (title, pairs) = AlertMessageParser.titleAndPairs(n)
|
||||||
|
card.tvCardTitle.text = title
|
||||||
|
|
||||||
|
card.btnClose.setOnClickListener {
|
||||||
|
n.id?.let { id -> dismissNotification(id) }
|
||||||
|
}
|
||||||
|
card.btnInfo.setOnClickListener {
|
||||||
|
n.id?.let { id ->
|
||||||
|
SignalDetailBottomSheet.newInstance(
|
||||||
|
notificationId = id,
|
||||||
|
rowSymbol = n.symbol,
|
||||||
|
signal = AlertSignalInference.inferFromNotification(n, null) ?: "BUY",
|
||||||
|
).show(parentFragmentManager, "signal_detail")
|
||||||
|
} ?: Toast.makeText(requireContext(), "알림 ID 없음", Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pairs.isNotEmpty()) {
|
||||||
|
card.scrollRows.visibility = View.VISIBLE
|
||||||
|
card.tvSingleLineMessage.visibility = View.GONE
|
||||||
|
card.containerRows.removeAllViews()
|
||||||
|
pairs.forEach { pair ->
|
||||||
|
val row = ItemAlertStockRowBinding.inflate(inflater, card.containerRows, false)
|
||||||
|
val resolved = AlertMessageParser.resolveStockStrategyPairRow(pair, cryptoMap)
|
||||||
|
row.tvKoreanName.text = resolved.koreanName
|
||||||
|
row.tvSymbol.text = resolved.displaySymbol
|
||||||
|
|
||||||
|
val marketSym = resolved.navSymbol
|
||||||
|
val upbitUrl = UpbitWebUrls.getUpbitWebExchangeUrl(marketSym, n.timeInterval)
|
||||||
|
|
||||||
|
row.btnUpbit.setOnClickListener {
|
||||||
|
if (upbitUrl != null) {
|
||||||
|
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(upbitUrl)))
|
||||||
|
} else {
|
||||||
|
Toast.makeText(requireContext(), "업비트 URL을 만들 수 없습니다.", Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
row.btnAlertList.setOnClickListener {
|
||||||
|
openAlertListForSymbol(marketSym, resolved.koreanName, n)
|
||||||
|
}
|
||||||
|
row.root.setOnClickListener {
|
||||||
|
openAlertListForSymbol(marketSym, resolved.koreanName, n)
|
||||||
|
}
|
||||||
|
|
||||||
|
row.btnSignalDetail.setOnClickListener {
|
||||||
|
val nid = n.id ?: return@setOnClickListener
|
||||||
|
val sig = AlertSignalInference.inferFromNotification(n, resolved.koreanName) ?: "BUY"
|
||||||
|
SignalDetailBottomSheet.newInstance(nid, marketSym, sig)
|
||||||
|
.show(parentFragmentManager, "signal_detail")
|
||||||
|
}
|
||||||
|
|
||||||
|
row.root.layoutParams = LinearLayout.LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
|
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||||
|
)
|
||||||
|
card.containerRows.addView(row.root)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
card.scrollRows.visibility = View.GONE
|
||||||
|
card.tvSingleLineMessage.visibility = View.VISIBLE
|
||||||
|
card.tvSingleLineMessage.text =
|
||||||
|
n.message?.split("\n")?.firstOrNull()?.take(200).orEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.cardsContainer.addView(card.root)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun openAlertListForSymbol(
|
||||||
|
marketSym: String?,
|
||||||
|
koreanName: String,
|
||||||
|
notification: AlertNotificationDto,
|
||||||
|
) {
|
||||||
|
val prefs = requireContext().getSharedPreferences("app_prefs", Context.MODE_PRIVATE)
|
||||||
|
marketSym?.let {
|
||||||
|
prefs.edit()
|
||||||
|
.putString("pending_chart_symbol", it)
|
||||||
|
.putString("pending_chart_korean", koreanName)
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
findNavController().navigate(
|
||||||
|
R.id.action_alerts_to_notification_list,
|
||||||
|
bundleOf(
|
||||||
|
"filterSymbol" to (marketSym ?: ""),
|
||||||
|
"filterKorean" to koreanName,
|
||||||
|
"highlightId" to (notification.id ?: 0L),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun dismissNotification(id: Long) {
|
||||||
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
try {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
ApiClientFactory.alertApi(requireContext()).markAsRead(id)
|
||||||
|
}
|
||||||
|
loadAll()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Toast.makeText(requireContext(), "닫기 실패: ${e.message}", Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroyView() {
|
||||||
|
super.onDestroyView()
|
||||||
|
_binding = null
|
||||||
|
}
|
||||||
|
}
|
||||||
+101
@@ -0,0 +1,101 @@
|
|||||||
|
package com.analysis.goldenanalysis.ui.alert
|
||||||
|
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.widget.LinearLayout
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import com.analysis.goldenanalysis.data.model.AlertNotificationDto
|
||||||
|
import com.analysis.goldenanalysis.databinding.ItemAlertStockRowBinding
|
||||||
|
import com.analysis.goldenanalysis.databinding.ItemNotificationHistoryBinding
|
||||||
|
import com.analysis.goldenanalysis.util.AlertMessageParser
|
||||||
|
import com.analysis.goldenanalysis.util.AlertSignalInference
|
||||||
|
|
||||||
|
class NotificationHistoryAdapter(
|
||||||
|
private val onUpbit: (symbol: String?, timeInterval: String?) -> Unit,
|
||||||
|
private val onAlertListRow: (notification: AlertNotificationDto, symbol: String?, korean: String) -> Unit,
|
||||||
|
private val onSignalDetail: (notificationId: Long, rowSymbol: String?, signal: String) -> Unit,
|
||||||
|
private val onDelete: (Long) -> Unit,
|
||||||
|
) : RecyclerView.Adapter<NotificationHistoryAdapter.VH>() {
|
||||||
|
|
||||||
|
private val items = mutableListOf<AlertNotificationDto>()
|
||||||
|
private var filterSymbol: String = ""
|
||||||
|
private var filterKorean: String = ""
|
||||||
|
private var cryptoMap: Map<String, String> = emptyMap()
|
||||||
|
|
||||||
|
fun setFilter(symbol: String, korean: String) {
|
||||||
|
filterSymbol = symbol.trim()
|
||||||
|
filterKorean = korean.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun submit(list: List<AlertNotificationDto>, map: Map<String, String>) {
|
||||||
|
cryptoMap = map
|
||||||
|
items.clear()
|
||||||
|
val filtered = list.filter { n ->
|
||||||
|
if (filterSymbol.isEmpty() && filterKorean.isEmpty()) return@filter true
|
||||||
|
val msg = n.message.orEmpty()
|
||||||
|
if (filterSymbol.isNotEmpty() && msg.contains(filterSymbol, ignoreCase = true)) return@filter true
|
||||||
|
if (filterKorean.isNotEmpty() && msg.contains(filterKorean)) return@filter true
|
||||||
|
if (filterSymbol.isNotEmpty() && n.symbol.equals(filterSymbol, ignoreCase = true)) return@filter true
|
||||||
|
false
|
||||||
|
}
|
||||||
|
items.addAll(filtered)
|
||||||
|
notifyDataSetChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH {
|
||||||
|
val binding = ItemNotificationHistoryBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||||
|
return VH(binding)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getItemCount(): Int = items.size
|
||||||
|
|
||||||
|
override fun onBindViewHolder(holder: VH, position: Int) {
|
||||||
|
holder.bind(items[position])
|
||||||
|
}
|
||||||
|
|
||||||
|
inner class VH(private val binding: ItemNotificationHistoryBinding) : RecyclerView.ViewHolder(binding.root) {
|
||||||
|
|
||||||
|
fun bind(n: AlertNotificationDto) {
|
||||||
|
val (title, pairs) = AlertMessageParser.titleAndPairs(n)
|
||||||
|
binding.tvTitle.text = title
|
||||||
|
binding.tvTime.text = n.triggeredAt ?: n.notifiedAt ?: ""
|
||||||
|
|
||||||
|
binding.btnDelete.setOnClickListener {
|
||||||
|
n.id?.let { onDelete(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.containerRows.removeAllViews()
|
||||||
|
if (pairs.isEmpty()) {
|
||||||
|
binding.containerRows.visibility = View.GONE
|
||||||
|
} else {
|
||||||
|
binding.containerRows.visibility = View.VISIBLE
|
||||||
|
val inflater = LayoutInflater.from(binding.root.context)
|
||||||
|
pairs.forEach { pair ->
|
||||||
|
val resolved = AlertMessageParser.resolveStockStrategyPairRow(pair, cryptoMap)
|
||||||
|
val row = ItemAlertStockRowBinding.inflate(inflater, binding.containerRows, false)
|
||||||
|
row.tvKoreanName.text = resolved.koreanName
|
||||||
|
row.tvSymbol.text = resolved.displaySymbol
|
||||||
|
val sym = resolved.navSymbol
|
||||||
|
row.btnUpbit.setOnClickListener { onUpbit(sym, n.timeInterval) }
|
||||||
|
row.btnAlertList.setOnClickListener {
|
||||||
|
onAlertListRow(n, sym, resolved.koreanName)
|
||||||
|
}
|
||||||
|
row.root.setOnClickListener {
|
||||||
|
onAlertListRow(n, sym, resolved.koreanName)
|
||||||
|
}
|
||||||
|
row.btnSignalDetail.setOnClickListener {
|
||||||
|
val id = n.id ?: return@setOnClickListener
|
||||||
|
val sig = AlertSignalInference.inferFromNotification(n, resolved.koreanName) ?: "BUY"
|
||||||
|
onSignalDetail(id, sym, sig)
|
||||||
|
}
|
||||||
|
row.root.layoutParams = LinearLayout.LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
|
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||||
|
)
|
||||||
|
binding.containerRows.addView(row.root)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+114
@@ -0,0 +1,114 @@
|
|||||||
|
package com.analysis.goldenanalysis.ui.alert
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import androidx.navigation.fragment.findNavController
|
||||||
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
|
import com.analysis.goldenanalysis.R
|
||||||
|
import com.analysis.goldenanalysis.data.api.ApiClientFactory
|
||||||
|
import com.analysis.goldenanalysis.data.model.AlertNotificationDto
|
||||||
|
import com.analysis.goldenanalysis.databinding.FragmentNotificationListBinding
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 전체 알림 목록 (프론트 NotificationListPage 유사)
|
||||||
|
*/
|
||||||
|
class NotificationListFragment : Fragment() {
|
||||||
|
|
||||||
|
private var _binding: FragmentNotificationListBinding? = null
|
||||||
|
private val binding get() = _binding!!
|
||||||
|
|
||||||
|
private lateinit var adapter: NotificationHistoryAdapter
|
||||||
|
|
||||||
|
override fun onCreateView(
|
||||||
|
inflater: LayoutInflater,
|
||||||
|
container: ViewGroup?,
|
||||||
|
savedInstanceState: Bundle?,
|
||||||
|
): View {
|
||||||
|
_binding = FragmentNotificationListBinding.inflate(inflater, container, false)
|
||||||
|
return binding.root
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
super.onViewCreated(view, savedInstanceState)
|
||||||
|
(activity as? AppCompatActivity)?.setSupportActionBar(binding.toolbar)
|
||||||
|
binding.toolbar.setNavigationIcon(com.analysis.goldenanalysis.R.drawable.ic_arrow_back)
|
||||||
|
binding.toolbar.setNavigationOnClickListener { findNavController().navigateUp() }
|
||||||
|
binding.toolbar.title = getString(R.string.alerts_menu_all)
|
||||||
|
|
||||||
|
adapter = NotificationHistoryAdapter(
|
||||||
|
onUpbit = { sym, interval ->
|
||||||
|
val url = com.analysis.goldenanalysis.util.UpbitWebUrls.getUpbitWebExchangeUrl(sym, interval)
|
||||||
|
if (url != null) startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
|
||||||
|
else Toast.makeText(requireContext(), "업비트 URL 오류", Toast.LENGTH_SHORT).show()
|
||||||
|
},
|
||||||
|
onAlertListRow = { _, _, _ -> /* 목록 화면 내 동작 */ },
|
||||||
|
onSignalDetail = { id, sym, signal ->
|
||||||
|
SignalDetailBottomSheet.newInstance(id, sym, signal)
|
||||||
|
.show(parentFragmentManager, "signal_detail")
|
||||||
|
},
|
||||||
|
onDelete = { id -> deleteNotification(id) },
|
||||||
|
)
|
||||||
|
binding.recycler.layoutManager = LinearLayoutManager(requireContext())
|
||||||
|
binding.recycler.adapter = adapter
|
||||||
|
|
||||||
|
val filterSym = arguments?.getString("filterSymbol").orEmpty()
|
||||||
|
val filterKr = arguments?.getString("filterKorean").orEmpty()
|
||||||
|
adapter.setFilter(filterSym, filterKr)
|
||||||
|
|
||||||
|
binding.swipeRefresh.setOnRefreshListener { loadPage() }
|
||||||
|
loadPage()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadPage() {
|
||||||
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
binding.progress.visibility = View.VISIBLE
|
||||||
|
try {
|
||||||
|
val api = ApiClientFactory.alertApi(requireContext())
|
||||||
|
val (page, map) = withContext(Dispatchers.IO) {
|
||||||
|
val p = runCatching { api.getNotifications(null, 0, 50) }.getOrNull()
|
||||||
|
val m = runCatching { api.getCryptoList().associate { it.koreanName to it.symbol } }
|
||||||
|
.getOrElse { emptyMap() }
|
||||||
|
p to m
|
||||||
|
}
|
||||||
|
val list = page?.content.orEmpty()
|
||||||
|
adapter.submit(list, map)
|
||||||
|
binding.tvEmpty.visibility = if (adapter.itemCount == 0) View.VISIBLE else View.GONE
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Toast.makeText(requireContext(), "목록 로드 실패: ${e.message}", Toast.LENGTH_LONG).show()
|
||||||
|
} finally {
|
||||||
|
binding.progress.visibility = View.GONE
|
||||||
|
binding.swipeRefresh.isRefreshing = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun deleteNotification(id: Long) {
|
||||||
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
try {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
ApiClientFactory.alertApi(requireContext()).deleteNotification(id)
|
||||||
|
}
|
||||||
|
loadPage()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Toast.makeText(requireContext(), "삭제 실패: ${e.message}", Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroyView() {
|
||||||
|
super.onDestroyView()
|
||||||
|
_binding = null
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
+105
@@ -0,0 +1,105 @@
|
|||||||
|
package com.analysis.goldenanalysis.ui.alert
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.core.os.bundleOf
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import com.analysis.goldenanalysis.data.api.ApiClientFactory
|
||||||
|
import com.analysis.goldenanalysis.data.model.AlertNotificationDetailDto
|
||||||
|
import com.analysis.goldenanalysis.databinding.BottomSheetSignalDetailBinding
|
||||||
|
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
class SignalDetailBottomSheet : BottomSheetDialogFragment() {
|
||||||
|
|
||||||
|
private var _binding: BottomSheetSignalDetailBinding? = null
|
||||||
|
private val binding get() = _binding!!
|
||||||
|
|
||||||
|
override fun onCreateView(
|
||||||
|
inflater: LayoutInflater,
|
||||||
|
container: ViewGroup?,
|
||||||
|
savedInstanceState: Bundle?,
|
||||||
|
): View {
|
||||||
|
_binding = BottomSheetSignalDetailBinding.inflate(inflater, container, false)
|
||||||
|
return binding.root
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
super.onViewCreated(view, savedInstanceState)
|
||||||
|
val id = arguments?.getLong(ARG_ID) ?: return dismiss()
|
||||||
|
val symbol = arguments?.getString(ARG_SYMBOL)
|
||||||
|
val signal = arguments?.getString(ARG_SIGNAL) ?: "BUY"
|
||||||
|
binding.tvTitle.text = if (signal == "SELL") "📉 매도 신호 상세" else "📈 매수 신호 상세"
|
||||||
|
binding.tvBody.text = getString(com.analysis.goldenanalysis.R.string.loading)
|
||||||
|
binding.btnClose.setOnClickListener { dismiss() }
|
||||||
|
|
||||||
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
try {
|
||||||
|
val api = ApiClientFactory.alertApi(requireContext())
|
||||||
|
val detail = withContext(Dispatchers.IO) {
|
||||||
|
api.getNotificationDetail(id, symbol?.trim()?.takeIf { it.isNotEmpty() })
|
||||||
|
}
|
||||||
|
binding.tvBody.text = buildDetailText(detail)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
binding.tvBody.text = e.message ?: "오류"
|
||||||
|
Toast.makeText(requireContext(), "상세를 불러오지 못했습니다.", Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildDetailText(d: AlertNotificationDetailDto): String {
|
||||||
|
val sb = StringBuilder()
|
||||||
|
d.koreanName?.let { sb.appendLine("종목: $it (${d.symbol ?: ""})") }
|
||||||
|
d.message?.let { sb.appendLine().appendLine(it) }
|
||||||
|
d.conditionDescription?.let { sb.appendLine().appendLine("조건: $it") }
|
||||||
|
d.timeInterval?.let { sb.appendLine("시간봉: $it") }
|
||||||
|
val ind = d.indicatorData
|
||||||
|
if (ind != null) {
|
||||||
|
sb.appendLine().appendLine("— 지표 스냅샷 —")
|
||||||
|
ind.time?.let { sb.appendLine("시간: $it") }
|
||||||
|
ind.price?.let { sb.appendLine("가격: $it") }
|
||||||
|
ind.rsi?.let { sb.appendLine("RSI: $it") }
|
||||||
|
ind.macdLine?.let { sb.appendLine("MACD: $it") }
|
||||||
|
ind.signalLine?.let { sb.appendLine("시그널: $it") }
|
||||||
|
ind.cci?.let { sb.appendLine("CCI: $it") }
|
||||||
|
}
|
||||||
|
d.snapshotClose?.let { sb.appendLine().appendLine("봉 종가(스냅): $it") }
|
||||||
|
val conds = d.satisfiedConditions.orEmpty()
|
||||||
|
if (conds.isNotEmpty()) {
|
||||||
|
sb.appendLine().appendLine("— 충족 조건 —")
|
||||||
|
conds.forEach { c ->
|
||||||
|
sb.appendLine(
|
||||||
|
listOfNotNull(c.indicatorType, c.conditionType, c.description)
|
||||||
|
.joinToString(" ")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sb.toString().trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroyView() {
|
||||||
|
super.onDestroyView()
|
||||||
|
_binding = null
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val ARG_ID = "id"
|
||||||
|
private const val ARG_SYMBOL = "symbol"
|
||||||
|
private const val ARG_SIGNAL = "signal"
|
||||||
|
|
||||||
|
fun newInstance(notificationId: Long, rowSymbol: String?, signal: String): SignalDetailBottomSheet {
|
||||||
|
val f = SignalDetailBottomSheet()
|
||||||
|
f.arguments = bundleOf(
|
||||||
|
ARG_ID to notificationId,
|
||||||
|
ARG_SYMBOL to rowSymbol,
|
||||||
|
ARG_SIGNAL to signal,
|
||||||
|
)
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
package com.analysis.goldenanalysis.ui.chart
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import android.net.http.SslError
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.util.Log
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.webkit.JavascriptInterface
|
||||||
|
import android.webkit.SslErrorHandler
|
||||||
|
import android.webkit.WebChromeClient
|
||||||
|
import android.webkit.WebSettings
|
||||||
|
import android.webkit.WebView
|
||||||
|
import android.webkit.WebViewClient
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import com.analysis.goldenanalysis.BuildConfig
|
||||||
|
import com.analysis.goldenanalysis.databinding.FragmentChartBinding
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 차트 Fragment
|
||||||
|
* - Frontend의 모바일 차트 페이지를 WebView로 표시
|
||||||
|
*/
|
||||||
|
class ChartFragment : Fragment() {
|
||||||
|
|
||||||
|
private var _binding: FragmentChartBinding? = null
|
||||||
|
private val binding get() = _binding!!
|
||||||
|
|
||||||
|
private val TAG = "ChartFragment"
|
||||||
|
private val JS_TAG = "ChartFragment-JS"
|
||||||
|
|
||||||
|
private fun getFrontendBaseUrl(): String {
|
||||||
|
val prefs = requireActivity().getSharedPreferences("app_prefs", Context.MODE_PRIVATE)
|
||||||
|
return prefs.getString("frontend_url", "http://exdev.co.kr/mobile/chart")
|
||||||
|
?: "http://exdev.co.kr/mobile/chart"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 알림에서 저장한 심볼이 있으면 쿼리 gaSymbol 붙이고 prefs 에서 제거 (프론트 연동 시 사용). */
|
||||||
|
private fun buildUrlConsumingPendingChartSymbol(): String {
|
||||||
|
val prefs = requireActivity().getSharedPreferences("app_prefs", Context.MODE_PRIVATE)
|
||||||
|
val base = getFrontendBaseUrl()
|
||||||
|
val sym = prefs.getString("pending_chart_symbol", null)?.trim().orEmpty()
|
||||||
|
if (sym.isNotEmpty()) {
|
||||||
|
prefs.edit()
|
||||||
|
.remove("pending_chart_symbol")
|
||||||
|
.remove("pending_chart_korean")
|
||||||
|
.apply()
|
||||||
|
val sep = if (base.contains("?")) "&" else "?"
|
||||||
|
return "$base${sep}gaSymbol=${Uri.encode(sym)}"
|
||||||
|
}
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateView(
|
||||||
|
inflater: LayoutInflater,
|
||||||
|
container: ViewGroup?,
|
||||||
|
savedInstanceState: Bundle?
|
||||||
|
): View {
|
||||||
|
Log.d(TAG, "onCreateView")
|
||||||
|
_binding = FragmentChartBinding.inflate(inflater, container, false)
|
||||||
|
return binding.root
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("SetJavaScriptEnabled")
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
super.onViewCreated(view, savedInstanceState)
|
||||||
|
Log.d(TAG, "onViewCreated")
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Frontend 모바일 페이지를 WebView로 로드
|
||||||
|
setupWebView()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Error in onViewCreated", e)
|
||||||
|
Toast.makeText(context, "초기화 오류: ${e.message}", Toast.LENGTH_LONG).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("SetJavaScriptEnabled")
|
||||||
|
private fun setupWebView() {
|
||||||
|
binding.webView.apply {
|
||||||
|
webViewClient = object : WebViewClient() {
|
||||||
|
override fun onPageStarted(view: WebView?, url: String?, favicon: android.graphics.Bitmap?) {
|
||||||
|
super.onPageStarted(view, url, favicon)
|
||||||
|
Log.d(TAG, "Page started loading: $url")
|
||||||
|
binding.progressBar.visibility = View.VISIBLE
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPageFinished(view: WebView?, url: String?) {
|
||||||
|
super.onPageFinished(view, url)
|
||||||
|
Log.d(TAG, "Page finished loading: $url")
|
||||||
|
|
||||||
|
// JavaScript로 페이지 내용 확인
|
||||||
|
view?.evaluateJavascript("document.body.innerHTML.length") { result ->
|
||||||
|
Log.d(TAG, "Page content length: $result")
|
||||||
|
if (result == "0" || result == "null") {
|
||||||
|
Log.e(TAG, "Page loaded but content is empty!")
|
||||||
|
Toast.makeText(context, "페이지가 비어있습니다", Toast.LENGTH_LONG).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.progressBar.visibility = View.GONE
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onReceivedError(
|
||||||
|
view: WebView?,
|
||||||
|
errorCode: Int,
|
||||||
|
description: String?,
|
||||||
|
failingUrl: String?
|
||||||
|
) {
|
||||||
|
super.onReceivedError(view, errorCode, description, failingUrl)
|
||||||
|
Log.e(TAG, "WebView Error: code=$errorCode, desc=$description, url=$failingUrl")
|
||||||
|
Toast.makeText(context, "페이지 로딩 오류 ($errorCode): $description", Toast.LENGTH_LONG).show()
|
||||||
|
binding.progressBar.visibility = View.GONE
|
||||||
|
}
|
||||||
|
|
||||||
|
@Deprecated("Deprecated in API level 23")
|
||||||
|
override fun onReceivedError(
|
||||||
|
view: WebView?,
|
||||||
|
request: android.webkit.WebResourceRequest?,
|
||||||
|
error: android.webkit.WebResourceError?
|
||||||
|
) {
|
||||||
|
super.onReceivedError(view, request, error)
|
||||||
|
if (request?.isForMainFrame == true) {
|
||||||
|
Log.e(TAG, "WebView Resource Error: ${error?.description}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTPS 오류 무시 (개발 환경에서만 사용 권장)
|
||||||
|
override fun onReceivedSslError(view: WebView?, handler: SslErrorHandler?, error: SslError?) {
|
||||||
|
Log.w(TAG, "SSL Error: ${error?.primaryError} on ${error?.url}. Proceeding anyway.")
|
||||||
|
handler?.proceed() // 인증서 무시
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
webChromeClient = object : WebChromeClient() {
|
||||||
|
override fun onConsoleMessage(consoleMessage: android.webkit.ConsoleMessage?): Boolean {
|
||||||
|
Log.d(JS_TAG, "${consoleMessage?.message()} -- From ${consoleMessage?.sourceId()} : ${consoleMessage?.lineNumber()}")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onProgressChanged(view: WebView?, newProgress: Int) {
|
||||||
|
super.onProgressChanged(view, newProgress)
|
||||||
|
if (newProgress < 100) {
|
||||||
|
binding.progressBar.visibility = View.VISIBLE
|
||||||
|
} else {
|
||||||
|
binding.progressBar.visibility = View.GONE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
settings.apply {
|
||||||
|
javaScriptEnabled = true
|
||||||
|
domStorageEnabled = true
|
||||||
|
cacheMode = WebSettings.LOAD_NO_CACHE // 캐시 비활성화 (디버깅용)
|
||||||
|
loadWithOverviewMode = true
|
||||||
|
useWideViewPort = true
|
||||||
|
builtInZoomControls = false // 모바일에서는 줌 컨트롤 비활성화
|
||||||
|
displayZoomControls = false
|
||||||
|
setSupportZoom(true)
|
||||||
|
allowFileAccess = true
|
||||||
|
allowContentAccess = true
|
||||||
|
databaseEnabled = true
|
||||||
|
|
||||||
|
// 네트워크 설정
|
||||||
|
mixedContentMode = android.webkit.WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
|
||||||
|
|
||||||
|
// User Agent 설정 (모바일 명시)
|
||||||
|
userAgentString = userAgentString + " GoldenAnalysisMobile/1.0"
|
||||||
|
|
||||||
|
// WebView 디버깅 활성화 (크롬 개발자 도구에서 확인 가능)
|
||||||
|
if (BuildConfig.DEBUG) {
|
||||||
|
WebView.setWebContentsDebuggingEnabled(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// JavaScript 인터페이스 추가
|
||||||
|
addJavascriptInterface(WebAppInterface(requireContext()), "Android")
|
||||||
|
|
||||||
|
// Frontend 모바일 페이지 로드
|
||||||
|
val frontendUrl = buildUrlConsumingPendingChartSymbol()
|
||||||
|
Log.d(TAG, "Loading Frontend URL: $frontendUrl")
|
||||||
|
binding.progressBar.visibility = View.VISIBLE
|
||||||
|
|
||||||
|
// WebView 강제 재개
|
||||||
|
onResume()
|
||||||
|
resumeTimers()
|
||||||
|
|
||||||
|
// 약간의 지연 후 로드 (WebView 초기화 대기)
|
||||||
|
post {
|
||||||
|
Log.d(TAG, "Starting to load URL...")
|
||||||
|
loadUrl(frontendUrl)
|
||||||
|
Log.d(TAG, "loadUrl() called")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
Log.d(TAG, "Fragment onResume")
|
||||||
|
try {
|
||||||
|
binding.webView.onResume()
|
||||||
|
binding.webView.resumeTimers()
|
||||||
|
// 알림 탭 등에서 pending_chart_symbol 저장 후 차트 탭으로 온 경우
|
||||||
|
val prefs = requireActivity().getSharedPreferences("app_prefs", Context.MODE_PRIVATE)
|
||||||
|
if (!prefs.getString("pending_chart_symbol", null).isNullOrBlank()) {
|
||||||
|
val url = buildUrlConsumingPendingChartSymbol()
|
||||||
|
Log.d(TAG, "Reload chart URL: $url")
|
||||||
|
binding.webView.loadUrl(url)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Error in onResume", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPause() {
|
||||||
|
super.onPause()
|
||||||
|
Log.d(TAG, "Fragment onPause")
|
||||||
|
try {
|
||||||
|
binding.webView.onPause()
|
||||||
|
binding.webView.pauseTimers()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Error in onPause", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroyView() {
|
||||||
|
super.onDestroyView()
|
||||||
|
binding.webView.destroy()
|
||||||
|
_binding = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// JavaScript에서 호출할 인터페이스
|
||||||
|
class WebAppInterface(private val mContext: Context) {
|
||||||
|
@JavascriptInterface
|
||||||
|
fun showToast(toast: String) {
|
||||||
|
Toast.makeText(mContext, toast, Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
|
||||||
|
@JavascriptInterface
|
||||||
|
fun log(message: String) {
|
||||||
|
Log.d("ChartFragment-JS", message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+53
@@ -0,0 +1,53 @@
|
|||||||
|
package com.analysis.goldenanalysis.ui.dashboard
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.util.Log
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import com.analysis.goldenanalysis.R
|
||||||
|
import com.analysis.goldenanalysis.databinding.FragmentDashboardBinding
|
||||||
|
|
||||||
|
class DashboardFragment : Fragment() {
|
||||||
|
|
||||||
|
private var _binding: FragmentDashboardBinding? = null
|
||||||
|
private val binding get() = _binding!!
|
||||||
|
|
||||||
|
override fun onCreateView(
|
||||||
|
inflater: LayoutInflater,
|
||||||
|
container: ViewGroup?,
|
||||||
|
savedInstanceState: Bundle?
|
||||||
|
): View {
|
||||||
|
Log.d("DashboardFragment", "onCreateView")
|
||||||
|
_binding = FragmentDashboardBinding.inflate(inflater, container, false)
|
||||||
|
return binding.root
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
super.onViewCreated(view, savedInstanceState)
|
||||||
|
Log.d("DashboardFragment", "onViewCreated")
|
||||||
|
try {
|
||||||
|
setupViews()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("DashboardFragment", "Error in setupViews", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setupViews() {
|
||||||
|
// TODO: Implement dashboard data loading from API
|
||||||
|
try {
|
||||||
|
binding.tvTotalAssets.text = "0원"
|
||||||
|
binding.tvProfitLoss.text = "0.00%"
|
||||||
|
binding.tvTodayChange.text = "0원"
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("DashboardFragment", "Error setting text", e)
|
||||||
|
// Views might not exist in layout
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroyView() {
|
||||||
|
super.onDestroyView()
|
||||||
|
_binding = null
|
||||||
|
}
|
||||||
|
}
|
||||||
+42
@@ -0,0 +1,42 @@
|
|||||||
|
package com.analysis.goldenanalysis.ui.portfolio
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import com.analysis.goldenanalysis.databinding.FragmentPortfolioBinding
|
||||||
|
|
||||||
|
class PortfolioFragment : Fragment() {
|
||||||
|
|
||||||
|
private var _binding: FragmentPortfolioBinding? = null
|
||||||
|
private val binding get() = _binding!!
|
||||||
|
|
||||||
|
override fun onCreateView(
|
||||||
|
inflater: LayoutInflater,
|
||||||
|
container: ViewGroup?,
|
||||||
|
savedInstanceState: Bundle?
|
||||||
|
): View {
|
||||||
|
_binding = FragmentPortfolioBinding.inflate(inflater, container, false)
|
||||||
|
return binding.root
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
super.onViewCreated(view, savedInstanceState)
|
||||||
|
setupViews()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setupViews() {
|
||||||
|
binding.fabAdd.setOnClickListener {
|
||||||
|
// TODO: Open add portfolio item dialog
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Load portfolio items from API
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroyView() {
|
||||||
|
super.onDestroyView()
|
||||||
|
_binding = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
+135
@@ -0,0 +1,135 @@
|
|||||||
|
package com.analysis.goldenanalysis.ui.scheduler
|
||||||
|
|
||||||
|
import android.app.Dialog
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.widget.ArrayAdapter
|
||||||
|
import androidx.fragment.app.DialogFragment
|
||||||
|
import com.analysis.goldenanalysis.R
|
||||||
|
import com.analysis.goldenanalysis.data.model.Schedule
|
||||||
|
import com.analysis.goldenanalysis.databinding.DialogScheduleBinding
|
||||||
|
|
||||||
|
class ScheduleDialogFragment : DialogFragment() {
|
||||||
|
|
||||||
|
private var _binding: DialogScheduleBinding? = null
|
||||||
|
private val binding get() = _binding!!
|
||||||
|
|
||||||
|
private var schedule: Schedule? = null
|
||||||
|
private var onSaveListener: ((Schedule) -> Unit)? = null
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
setStyle(STYLE_NORMAL, R.style.Theme_GoldenAnalysis)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateView(
|
||||||
|
inflater: LayoutInflater,
|
||||||
|
container: ViewGroup?,
|
||||||
|
savedInstanceState: Bundle?
|
||||||
|
): View {
|
||||||
|
_binding = DialogScheduleBinding.inflate(inflater, container, false)
|
||||||
|
return binding.root
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
super.onViewCreated(view, savedInstanceState)
|
||||||
|
setupSpinners()
|
||||||
|
setupListeners()
|
||||||
|
|
||||||
|
// If editing existing schedule
|
||||||
|
schedule?.let { populateFields(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setupSpinners() {
|
||||||
|
// Interval spinner
|
||||||
|
val intervals = arrayOf("1분", "5분", "15분", "30분", "1시간", "4시간", "1일")
|
||||||
|
val intervalAdapter = ArrayAdapter(requireContext(), android.R.layout.simple_spinner_item, intervals)
|
||||||
|
intervalAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
|
||||||
|
binding.spinnerInterval.adapter = intervalAdapter
|
||||||
|
|
||||||
|
// Notification method spinner
|
||||||
|
val methods = arrayOf("앱 알림", "이메일", "SMS")
|
||||||
|
val methodAdapter = ArrayAdapter(requireContext(), android.R.layout.simple_spinner_item, methods)
|
||||||
|
methodAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
|
||||||
|
binding.spinnerNotification.adapter = methodAdapter
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setupListeners() {
|
||||||
|
binding.btnSave.setOnClickListener {
|
||||||
|
saveSchedule()
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.btnCancel.setOnClickListener {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun populateFields(schedule: Schedule) {
|
||||||
|
binding.apply {
|
||||||
|
etScheduleName.setText(schedule.name)
|
||||||
|
|
||||||
|
// Set spinner selections
|
||||||
|
val intervalPos = (spinnerInterval.adapter as ArrayAdapter<String>)
|
||||||
|
.getPosition(schedule.interval)
|
||||||
|
if (intervalPos >= 0) spinnerInterval.setSelection(intervalPos)
|
||||||
|
|
||||||
|
val methodPos = (spinnerNotification.adapter as ArrayAdapter<String>)
|
||||||
|
.getPosition(schedule.notificationMethod)
|
||||||
|
if (methodPos >= 0) spinnerNotification.setSelection(methodPos)
|
||||||
|
|
||||||
|
etIndicators.setText(schedule.indicators)
|
||||||
|
etConditions.setText(schedule.conditions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveSchedule() {
|
||||||
|
val name = binding.etScheduleName.text.toString()
|
||||||
|
if (name.isBlank()) {
|
||||||
|
binding.etScheduleName.error = "스케쥴 이름을 입력하세요"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val newSchedule = Schedule(
|
||||||
|
id = schedule?.id ?: 0,
|
||||||
|
name = name,
|
||||||
|
interval = binding.spinnerInterval.selectedItem.toString(),
|
||||||
|
indicators = binding.etIndicators.text.toString(),
|
||||||
|
notificationMethod = binding.spinnerNotification.selectedItem.toString(),
|
||||||
|
conditions = binding.etConditions.text.toString(),
|
||||||
|
isEnabled = schedule?.isEnabled ?: true,
|
||||||
|
createdAt = schedule?.createdAt ?: System.currentTimeMillis(),
|
||||||
|
lastExecutedAt = schedule?.lastExecutedAt ?: 0
|
||||||
|
)
|
||||||
|
|
||||||
|
onSaveListener?.invoke(newSchedule)
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStart() {
|
||||||
|
super.onStart()
|
||||||
|
dialog?.window?.setLayout(
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
|
ViewGroup.LayoutParams.WRAP_CONTENT
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroyView() {
|
||||||
|
super.onDestroyView()
|
||||||
|
_binding = null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setOnSaveListener(listener: (Schedule) -> Unit) {
|
||||||
|
onSaveListener = listener
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun newInstance(schedule: Schedule? = null): ScheduleDialogFragment {
|
||||||
|
val fragment = ScheduleDialogFragment()
|
||||||
|
fragment.schedule = schedule
|
||||||
|
return fragment
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package com.analysis.goldenanalysis.ui.scheduler
|
||||||
|
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import androidx.recyclerview.widget.DiffUtil
|
||||||
|
import androidx.recyclerview.widget.ListAdapter
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import com.analysis.goldenanalysis.data.model.Schedule
|
||||||
|
import com.analysis.goldenanalysis.databinding.ItemScheduleBinding
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
class SchedulerAdapter(
|
||||||
|
private val onEdit: (Schedule) -> Unit,
|
||||||
|
private val onDelete: (Schedule) -> Unit,
|
||||||
|
private val onToggle: (Schedule, Boolean) -> Unit
|
||||||
|
) : ListAdapter<Schedule, SchedulerAdapter.ScheduleViewHolder>(ScheduleDiffCallback()) {
|
||||||
|
|
||||||
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ScheduleViewHolder {
|
||||||
|
val binding = ItemScheduleBinding.inflate(
|
||||||
|
LayoutInflater.from(parent.context),
|
||||||
|
parent,
|
||||||
|
false
|
||||||
|
)
|
||||||
|
return ScheduleViewHolder(binding)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBindViewHolder(holder: ScheduleViewHolder, position: Int) {
|
||||||
|
holder.bind(getItem(position))
|
||||||
|
}
|
||||||
|
|
||||||
|
inner class ScheduleViewHolder(
|
||||||
|
private val binding: ItemScheduleBinding
|
||||||
|
) : RecyclerView.ViewHolder(binding.root) {
|
||||||
|
|
||||||
|
fun bind(schedule: Schedule) {
|
||||||
|
binding.apply {
|
||||||
|
tvScheduleName.text = schedule.name
|
||||||
|
tvInterval.text = schedule.interval
|
||||||
|
tvIndicators.text = schedule.indicators
|
||||||
|
switchEnabled.isChecked = schedule.isEnabled
|
||||||
|
|
||||||
|
// Format last executed time
|
||||||
|
if (schedule.lastExecutedAt > 0) {
|
||||||
|
val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault())
|
||||||
|
tvLastExecuted.text = "마지막 실행: ${sdf.format(Date(schedule.lastExecutedAt))}"
|
||||||
|
} else {
|
||||||
|
tvLastExecuted.text = "실행 기록 없음"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Click listeners
|
||||||
|
root.setOnClickListener { onEdit(schedule) }
|
||||||
|
btnEdit.setOnClickListener { onEdit(schedule) }
|
||||||
|
btnDelete.setOnClickListener { onDelete(schedule) }
|
||||||
|
switchEnabled.setOnCheckedChangeListener { _, isChecked ->
|
||||||
|
onToggle(schedule, isChecked)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class ScheduleDiffCallback : DiffUtil.ItemCallback<Schedule>() {
|
||||||
|
override fun areItemsTheSame(oldItem: Schedule, newItem: Schedule): Boolean {
|
||||||
|
return oldItem.id == newItem.id
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun areContentsTheSame(oldItem: Schedule, newItem: Schedule): Boolean {
|
||||||
|
return oldItem == newItem
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
+87
@@ -0,0 +1,87 @@
|
|||||||
|
package com.analysis.goldenanalysis.ui.scheduler
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import androidx.fragment.app.viewModels
|
||||||
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
|
import com.analysis.goldenanalysis.data.model.Schedule
|
||||||
|
import com.analysis.goldenanalysis.databinding.FragmentSchedulerBinding
|
||||||
|
|
||||||
|
class SchedulerFragment : Fragment() {
|
||||||
|
|
||||||
|
private var _binding: FragmentSchedulerBinding? = null
|
||||||
|
private val binding get() = _binding!!
|
||||||
|
|
||||||
|
private val viewModel: SchedulerViewModel by viewModels()
|
||||||
|
private lateinit var adapter: SchedulerAdapter
|
||||||
|
|
||||||
|
override fun onCreateView(
|
||||||
|
inflater: LayoutInflater,
|
||||||
|
container: ViewGroup?,
|
||||||
|
savedInstanceState: Bundle?
|
||||||
|
): View {
|
||||||
|
_binding = FragmentSchedulerBinding.inflate(inflater, container, false)
|
||||||
|
return binding.root
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
super.onViewCreated(view, savedInstanceState)
|
||||||
|
setupRecyclerView()
|
||||||
|
setupObservers()
|
||||||
|
setupListeners()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setupRecyclerView() {
|
||||||
|
adapter = SchedulerAdapter(
|
||||||
|
onEdit = { schedule -> showEditDialog(schedule) },
|
||||||
|
onDelete = { schedule -> viewModel.delete(schedule) },
|
||||||
|
onToggle = { schedule, enabled ->
|
||||||
|
viewModel.update(schedule.copy(isEnabled = enabled))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
binding.rvScheduler.apply {
|
||||||
|
layoutManager = LinearLayoutManager(context)
|
||||||
|
adapter = this@SchedulerFragment.adapter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setupObservers() {
|
||||||
|
viewModel.allSchedules.observe(viewLifecycleOwner) { schedules ->
|
||||||
|
adapter.submitList(schedules)
|
||||||
|
binding.tvEmptyState.visibility =
|
||||||
|
if (schedules.isEmpty()) View.VISIBLE else View.GONE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setupListeners() {
|
||||||
|
binding.fabAdd.setOnClickListener {
|
||||||
|
showAddDialog()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showAddDialog() {
|
||||||
|
val dialog = ScheduleDialogFragment.newInstance()
|
||||||
|
dialog.setOnSaveListener { schedule ->
|
||||||
|
viewModel.insert(schedule)
|
||||||
|
}
|
||||||
|
dialog.show(childFragmentManager, "AddScheduleDialog")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showEditDialog(schedule: Schedule) {
|
||||||
|
val dialog = ScheduleDialogFragment.newInstance(schedule)
|
||||||
|
dialog.setOnSaveListener { updatedSchedule ->
|
||||||
|
viewModel.update(updatedSchedule)
|
||||||
|
}
|
||||||
|
dialog.show(childFragmentManager, "EditScheduleDialog")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroyView() {
|
||||||
|
super.onDestroyView()
|
||||||
|
_binding = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
+35
@@ -0,0 +1,35 @@
|
|||||||
|
package com.analysis.goldenanalysis.ui.scheduler
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import androidx.lifecycle.AndroidViewModel
|
||||||
|
import androidx.lifecycle.LiveData
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.analysis.goldenanalysis.data.database.AppDatabase
|
||||||
|
import com.analysis.goldenanalysis.data.model.Schedule
|
||||||
|
import com.analysis.goldenanalysis.data.repository.ScheduleRepository
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
class SchedulerViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
|
|
||||||
|
private val repository: ScheduleRepository
|
||||||
|
val allSchedules: LiveData<List<Schedule>>
|
||||||
|
|
||||||
|
init {
|
||||||
|
val scheduleDao = AppDatabase.getDatabase(application).scheduleDao()
|
||||||
|
repository = ScheduleRepository(scheduleDao)
|
||||||
|
allSchedules = repository.allSchedules
|
||||||
|
}
|
||||||
|
|
||||||
|
fun insert(schedule: Schedule) = viewModelScope.launch {
|
||||||
|
repository.insert(schedule)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun update(schedule: Schedule) = viewModelScope.launch {
|
||||||
|
repository.update(schedule)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun delete(schedule: Schedule) = viewModelScope.launch {
|
||||||
|
repository.delete(schedule)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package com.analysis.goldenanalysis.ui.settings
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import androidx.appcompat.app.AppCompatDelegate
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import com.analysis.goldenanalysis.data.api.ApiClientFactory
|
||||||
|
import com.analysis.goldenanalysis.databinding.FragmentSettingsBinding
|
||||||
|
|
||||||
|
class SettingsFragment : Fragment() {
|
||||||
|
|
||||||
|
private var _binding: FragmentSettingsBinding? = null
|
||||||
|
private val binding get() = _binding!!
|
||||||
|
|
||||||
|
private lateinit var prefs: SharedPreferences
|
||||||
|
|
||||||
|
override fun onCreateView(
|
||||||
|
inflater: LayoutInflater,
|
||||||
|
container: ViewGroup?,
|
||||||
|
savedInstanceState: Bundle?
|
||||||
|
): View {
|
||||||
|
_binding = FragmentSettingsBinding.inflate(inflater, container, false)
|
||||||
|
return binding.root
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
super.onViewCreated(view, savedInstanceState)
|
||||||
|
prefs = requireContext().getSharedPreferences("app_prefs", Context.MODE_PRIVATE)
|
||||||
|
setupViews()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setupViews() {
|
||||||
|
// Load saved settings
|
||||||
|
binding.switchNotification.isChecked = prefs.getBoolean("notifications_enabled", true)
|
||||||
|
binding.switchDarkMode.isChecked = prefs.getBoolean("dark_mode", false)
|
||||||
|
// Frontend URL 설정 (차트 페이지)
|
||||||
|
// 로컬 Docker: http://192.168.219.106/mobile/chart
|
||||||
|
// 운영: https://exdev.co.kr/mobile/chart
|
||||||
|
val defaultFrontendUrl = "https://exdev.co.kr/mobile/chart"
|
||||||
|
binding.etApiServer.setText(prefs.getString("frontend_url", defaultFrontendUrl))
|
||||||
|
binding.etApiBackend.setText(prefs.getString("api_base_url", ""))
|
||||||
|
|
||||||
|
// Version info
|
||||||
|
binding.tvVersionValue.text = "1.0.0"
|
||||||
|
|
||||||
|
// Setup listeners
|
||||||
|
binding.switchNotification.setOnCheckedChangeListener { _, isChecked ->
|
||||||
|
prefs.edit().putBoolean("notifications_enabled", isChecked).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.switchDarkMode.setOnCheckedChangeListener { _, isChecked ->
|
||||||
|
prefs.edit().putBoolean("dark_mode", isChecked).apply()
|
||||||
|
applyTheme(isChecked)
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.btnSaveServer.setOnClickListener {
|
||||||
|
val frontendUrl = binding.etApiServer.text.toString()
|
||||||
|
val apiBackend = binding.etApiBackend.text.toString().trim()
|
||||||
|
val ed = prefs.edit().putString("frontend_url", frontendUrl)
|
||||||
|
if (apiBackend.isEmpty()) ed.remove("api_base_url") else ed.putString("api_base_url", apiBackend)
|
||||||
|
ed.apply()
|
||||||
|
ApiClientFactory.reset()
|
||||||
|
android.widget.Toast.makeText(context, "저장되었습니다", android.widget.Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyTheme(isDarkMode: Boolean) {
|
||||||
|
AppCompatDelegate.setDefaultNightMode(
|
||||||
|
if (isDarkMode) AppCompatDelegate.MODE_NIGHT_YES
|
||||||
|
else AppCompatDelegate.MODE_NIGHT_NO
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroyView() {
|
||||||
|
super.onDestroyView()
|
||||||
|
_binding = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
package com.analysis.goldenanalysis.util
|
||||||
|
|
||||||
|
import com.analysis.goldenanalysis.data.model.AlertNotificationDto
|
||||||
|
|
||||||
|
data class StockStrategyPair(
|
||||||
|
val koreanName: String,
|
||||||
|
val strategyName: String,
|
||||||
|
val symbol: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ResolvedStockRow(
|
||||||
|
val koreanName: String,
|
||||||
|
val strategyName: String,
|
||||||
|
val displaySymbol: String,
|
||||||
|
val navSymbol: String?,
|
||||||
|
)
|
||||||
|
|
||||||
|
object AlertMessageParser {
|
||||||
|
|
||||||
|
private val marketCodeRe = Regex("^(KRW|USDT|BTC)-[A-Z0-9]+$", RegexOption.IGNORE_CASE)
|
||||||
|
|
||||||
|
fun extractStrategyNames(message: String): List<String>? {
|
||||||
|
if (!message.contains("알림 조건 충족") || !message.contains("개)")) return null
|
||||||
|
val lines = message.split("\n").map { it.trim() }.filter { it.isNotEmpty() }
|
||||||
|
val names = mutableListOf<String>()
|
||||||
|
for (i in 1 until lines.size) {
|
||||||
|
val sep = listOf(" × ", " X ").firstOrNull { lines[i].contains(it) } ?: continue
|
||||||
|
val idx = lines[i].indexOf(sep)
|
||||||
|
if (idx >= 0) {
|
||||||
|
val name = lines[i].substring(idx + sep.length).trim()
|
||||||
|
if (name.isNotEmpty()) names.add(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return if (names.isEmpty()) null else names.distinct()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun extractStockStrategyPairs(message: String): List<StockStrategyPair>? {
|
||||||
|
if (!message.contains("알림 조건 충족") || !message.contains("개)")) return null
|
||||||
|
val lines = message.split("\n").map { it.trim() }.filter { it.isNotEmpty() }
|
||||||
|
val pairs = mutableListOf<StockStrategyPair>()
|
||||||
|
for (i in 1 until lines.size) {
|
||||||
|
val sep = listOf(" × ", " X ").firstOrNull { lines[i].contains(it) } ?: continue
|
||||||
|
val idx = lines[i].indexOf(sep)
|
||||||
|
if (idx >= 0) {
|
||||||
|
val kn = lines[i].substring(0, idx).trim()
|
||||||
|
val sn = lines[i].substring(idx + sep.length).trim()
|
||||||
|
if (kn.isNotEmpty()) pairs.add(StockStrategyPair(kn, sn))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pairs.ifEmpty { null }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun resolveSymbols(koreanNames: List<String>, cryptoMap: Map<String, String>): List<Pair<String, String>> {
|
||||||
|
val entries = cryptoMap.entries.toList()
|
||||||
|
fun norm(s: String) = s.replace(Regex("\\s+"), " ").trim()
|
||||||
|
val out = mutableListOf<Pair<String, String>>()
|
||||||
|
for (kn in koreanNames) {
|
||||||
|
val n = norm(kn)
|
||||||
|
var matched = entries.find { norm(it.key) == n || it.key == kn }
|
||||||
|
if (matched == null) {
|
||||||
|
matched = entries.find {
|
||||||
|
norm(it.key).contains(n) || n.contains(norm(it.key))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (matched != null) out.add(matched.key to matched.value)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun koreanNameForMarketSymbol(symbol: String, cryptoMap: Map<String, String>, fallback: String): String {
|
||||||
|
val s = symbol.trim().uppercase()
|
||||||
|
cryptoMap.entries.forEach { (k, v) ->
|
||||||
|
if (v.uppercase() == s) return k
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
fun resolveStockStrategyPairRow(pair: StockStrategyPair, cryptoMap: Map<String, String>): ResolvedStockRow {
|
||||||
|
if (!pair.symbol.isNullOrBlank()) {
|
||||||
|
val nav = pair.symbol.trim().uppercase()
|
||||||
|
val left = pair.koreanName.trim()
|
||||||
|
val kn = if (left.isNotEmpty() && !marketCodeRe.matches(left)) pair.koreanName
|
||||||
|
else koreanNameForMarketSymbol(nav, cryptoMap, left.ifEmpty { nav })
|
||||||
|
return ResolvedStockRow(kn, pair.strategyName, nav, nav)
|
||||||
|
}
|
||||||
|
val raw = pair.koreanName.trim()
|
||||||
|
if (marketCodeRe.matches(raw)) {
|
||||||
|
val nav = raw.uppercase()
|
||||||
|
val kn = koreanNameForMarketSymbol(nav, cryptoMap, raw)
|
||||||
|
return ResolvedStockRow(kn, pair.strategyName, nav, nav)
|
||||||
|
}
|
||||||
|
val resolved = resolveSymbols(listOf(pair.koreanName), cryptoMap).firstOrNull()
|
||||||
|
return ResolvedStockRow(
|
||||||
|
pair.koreanName,
|
||||||
|
pair.strategyName,
|
||||||
|
resolved?.second ?: pair.strategyName,
|
||||||
|
resolved?.second,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun titleAndPairs(notification: AlertNotificationDto): Pair<String, List<StockStrategyPair>> {
|
||||||
|
val msg = notification.message.orEmpty()
|
||||||
|
if (msg.contains("알림 조건 충족") && msg.contains("개)")) {
|
||||||
|
val strategyNames = extractStrategyNames(msg).orEmpty()
|
||||||
|
val title = when {
|
||||||
|
strategyNames.isEmpty() -> "🎯 통합 알림"
|
||||||
|
strategyNames.size <= 3 -> strategyNames.joinToString(", ")
|
||||||
|
else -> "${strategyNames.take(2).joinToString(", ")} 외 ${strategyNames.size - 2}개 전략"
|
||||||
|
}
|
||||||
|
val pairs = extractStockStrategyPairs(msg).orEmpty()
|
||||||
|
return title to pairs
|
||||||
|
}
|
||||||
|
if (!notification.koreanName.isNullOrBlank()) {
|
||||||
|
val firstLine = msg.split("\n").firstOrNull()?.trim().orEmpty()
|
||||||
|
val strategyName = firstLine.replace(Regex("^[\\s🧪🎯✅]*"), "").trim().ifEmpty { "알림" }
|
||||||
|
return strategyName to listOf(
|
||||||
|
StockStrategyPair(notification.koreanName!!, strategyName, notification.symbol)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (notification.symbol ?: "알림") to emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package com.analysis.goldenanalysis.util
|
||||||
|
|
||||||
|
import com.analysis.goldenanalysis.data.model.AlertNotificationDto
|
||||||
|
|
||||||
|
object AlertSignalInference {
|
||||||
|
|
||||||
|
fun inferFromStrategyName(strategyName: String): String? {
|
||||||
|
val s = strategyName.trim()
|
||||||
|
if (s.isEmpty()) return null
|
||||||
|
val hint = s.replace(Regex("\\s"), "")
|
||||||
|
if (Regex("매도$").containsMatchIn(hint) && !Regex("과매도$").containsMatchIn(hint)) return "SELL"
|
||||||
|
if (Regex("매수$").containsMatchIn(hint) && !Regex("과매수$").containsMatchIn(hint)) return "BUY"
|
||||||
|
if (s.contains("데드크로스")) return "SELL"
|
||||||
|
if (s.contains("골든크로스")) return "BUY"
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun inferFromNotification(n: AlertNotificationDto, koreanNameForRow: String?): String? {
|
||||||
|
val row = koreanNameForRow?.trim()
|
||||||
|
if (!row.isNullOrEmpty()) {
|
||||||
|
val pairs = AlertMessageParser.extractStockStrategyPairs(n.message.orEmpty()).orEmpty()
|
||||||
|
val pair = pairs.find { it.koreanName.trim() == row }
|
||||||
|
pair?.strategyName?.let { inferFromStrategyName(it) }?.let { return it }
|
||||||
|
}
|
||||||
|
val raw = "${n.message.orEmpty()}\n${n.conditionDescription.orEmpty()}"
|
||||||
|
val pairsAll = AlertMessageParser.extractStockStrategyPairs(n.message.orEmpty()).orEmpty()
|
||||||
|
if (pairsAll.size == 1) {
|
||||||
|
inferFromStrategyName(pairsAll[0].strategyName)?.let { return it }
|
||||||
|
}
|
||||||
|
val hint = raw.replace(Regex("\\s"), "")
|
||||||
|
if (Regex("매도$").containsMatchIn(hint) && !Regex("과매도$").containsMatchIn(hint)) return "SELL"
|
||||||
|
if (Regex("매수$").containsMatchIn(hint) && !Regex("과매수$").containsMatchIn(hint)) return "BUY"
|
||||||
|
if (raw.contains("데드크로스")) return "SELL"
|
||||||
|
if (raw.contains("골든크로스")) return "BUY"
|
||||||
|
val masked = raw.replace("과매도", "").replace("과매수", "")
|
||||||
|
if (Regex("매도조건|매도신호").containsMatchIn(masked) &&
|
||||||
|
!Regex("매수조건|매수신호").containsMatchIn(masked)
|
||||||
|
) return "SELL"
|
||||||
|
if (Regex("매수조건|매수신호").containsMatchIn(masked) &&
|
||||||
|
!Regex("매도조건|매도신호").containsMatchIn(masked)
|
||||||
|
) return "BUY"
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package com.analysis.goldenanalysis.util
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import android.net.Uri
|
||||||
|
|
||||||
|
object ApiPrefs {
|
||||||
|
private const val PREFS = "app_prefs"
|
||||||
|
private const val KEY_API_BASE = "api_base_url"
|
||||||
|
private const val KEY_FRONTEND = "frontend_url"
|
||||||
|
|
||||||
|
fun prefs(context: Context): SharedPreferences =
|
||||||
|
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 백엔드 REST 베이스 (…/api/ 형태, Retrofit용).
|
||||||
|
* [api_base_url]이 있으면 사용, 없으면 [frontend_url]의 origin + /api/
|
||||||
|
*/
|
||||||
|
fun getApiBaseUrl(context: Context): String {
|
||||||
|
val p = prefs(context)
|
||||||
|
val explicit = p.getString(KEY_API_BASE, null)?.trim().orEmpty()
|
||||||
|
if (explicit.isNotEmpty()) {
|
||||||
|
var b = explicit.trimEnd('/')
|
||||||
|
if (!b.endsWith("/api")) b = "$b/api"
|
||||||
|
return "$b/"
|
||||||
|
}
|
||||||
|
val front = p.getString(KEY_FRONTEND, "https://exdev.co.kr/mobile/chart")
|
||||||
|
?: "https://exdev.co.kr/mobile/chart"
|
||||||
|
val uri = Uri.parse(front)
|
||||||
|
val scheme = uri.scheme ?: "https"
|
||||||
|
val host = uri.host ?: "exdev.co.kr"
|
||||||
|
val port = if (uri.port == -1 || uri.port == 80 || uri.port == 443) "" else ":${uri.port}"
|
||||||
|
return "$scheme://$host$port/api/"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package com.analysis.goldenanalysis.util
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.work.*
|
||||||
|
import com.analysis.goldenanalysis.data.model.Schedule
|
||||||
|
import com.analysis.goldenanalysis.worker.ScheduleWorker
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
object ScheduleManager {
|
||||||
|
|
||||||
|
fun scheduleWork(context: Context, schedule: Schedule) {
|
||||||
|
val workManager = WorkManager.getInstance(context)
|
||||||
|
|
||||||
|
if (!schedule.isEnabled) {
|
||||||
|
cancelWork(context, schedule.id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val intervalMinutes = getIntervalInMinutes(schedule.interval)
|
||||||
|
val inputData = Data.Builder()
|
||||||
|
.putLong("schedule_id", schedule.id)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val workRequest = PeriodicWorkRequestBuilder<ScheduleWorker>(
|
||||||
|
intervalMinutes,
|
||||||
|
TimeUnit.MINUTES
|
||||||
|
)
|
||||||
|
.setInputData(inputData)
|
||||||
|
.setConstraints(
|
||||||
|
Constraints.Builder()
|
||||||
|
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
.addTag("schedule_${schedule.id}")
|
||||||
|
.build()
|
||||||
|
|
||||||
|
workManager.enqueueUniquePeriodicWork(
|
||||||
|
"schedule_${schedule.id}",
|
||||||
|
ExistingPeriodicWorkPolicy.REPLACE,
|
||||||
|
workRequest
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancelWork(context: Context, scheduleId: Long) {
|
||||||
|
val workManager = WorkManager.getInstance(context)
|
||||||
|
workManager.cancelUniqueWork("schedule_$scheduleId")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getIntervalInMinutes(interval: String): Long {
|
||||||
|
return when (interval) {
|
||||||
|
"1분" -> 15 // Minimum 15 minutes for WorkManager
|
||||||
|
"5분" -> 15
|
||||||
|
"15분" -> 15
|
||||||
|
"30분" -> 30
|
||||||
|
"1시간" -> 60
|
||||||
|
"4시간" -> 240
|
||||||
|
"1일" -> 1440
|
||||||
|
else -> 60
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package com.analysis.goldenanalysis.util
|
||||||
|
|
||||||
|
import java.net.URLEncoder
|
||||||
|
import java.nio.charset.StandardCharsets
|
||||||
|
|
||||||
|
object UpbitWebUrls {
|
||||||
|
|
||||||
|
fun mapAlertTimeIntervalToResolution(timeInterval: String?): String? {
|
||||||
|
if (timeInterval.isNullOrBlank()) return null
|
||||||
|
val raw = timeInterval.trim()
|
||||||
|
if (Regex("^\\d+M$").matches(raw)) {
|
||||||
|
return mapOf("1M" to "1M", "3M" to "3M", "6M" to "6M", "12M" to "12M")[raw]
|
||||||
|
}
|
||||||
|
val key = raw.lowercase()
|
||||||
|
val map = mapOf(
|
||||||
|
"1m" to "1", "3m" to "3", "5m" to "5", "10m" to "10",
|
||||||
|
"15m" to "15", "30m" to "30", "60m" to "60", "1h" to "60",
|
||||||
|
"4h" to "240", "240m" to "240", "1d" to "1D", "1w" to "1W",
|
||||||
|
)
|
||||||
|
return map[key]
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getUpbitWebExchangeUrl(market: String?, timeInterval: String?): String? {
|
||||||
|
if (market.isNullOrBlank()) return null
|
||||||
|
var m = market.trim().uppercase()
|
||||||
|
if (m.startsWith("CRIX.UPBIT.")) m = m.removePrefix("CRIX.UPBIT.")
|
||||||
|
if (!Regex("^(KRW|USDT|BTC)-[A-Z0-9]+$", RegexOption.IGNORE_CASE).matches(m)) return null
|
||||||
|
val code = "CRIX.UPBIT.$m"
|
||||||
|
val base = "https://www.upbit.com/exchange?code=" + URLEncoder.encode(code, StandardCharsets.UTF_8.name())
|
||||||
|
val res = mapAlertTimeIntervalToResolution(timeInterval)
|
||||||
|
return if (res != null) "$base&resolution=" + URLEncoder.encode(res, StandardCharsets.UTF_8.name()) else base
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package com.analysis.goldenanalysis.worker
|
||||||
|
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.work.CoroutineWorker
|
||||||
|
import androidx.work.WorkerParameters
|
||||||
|
import com.analysis.goldenanalysis.R
|
||||||
|
import com.analysis.goldenanalysis.data.database.AppDatabase
|
||||||
|
import com.analysis.goldenanalysis.data.repository.ScheduleRepository
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
class ScheduleWorker(
|
||||||
|
context: Context,
|
||||||
|
params: WorkerParameters
|
||||||
|
) : CoroutineWorker(context, params) {
|
||||||
|
|
||||||
|
private val repository: ScheduleRepository by lazy {
|
||||||
|
val dao = AppDatabase.getDatabase(context).scheduleDao()
|
||||||
|
ScheduleRepository(dao)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun doWork(): Result {
|
||||||
|
return withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val scheduleId = inputData.getLong("schedule_id", -1)
|
||||||
|
if (scheduleId == -1L) return@withContext Result.failure()
|
||||||
|
|
||||||
|
val schedule = repository.getScheduleById(scheduleId)
|
||||||
|
if (schedule == null || !schedule.isEnabled) {
|
||||||
|
return@withContext Result.failure()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Call API to check indicators
|
||||||
|
// For now, just show notification
|
||||||
|
showNotification(schedule.name, "스케쥴이 실행되었습니다")
|
||||||
|
|
||||||
|
// Update last executed timestamp
|
||||||
|
repository.updateLastExecutedAt(scheduleId, System.currentTimeMillis())
|
||||||
|
|
||||||
|
Result.success()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showNotification(title: String, message: String) {
|
||||||
|
val notificationManager = applicationContext.getSystemService(Context.NOTIFICATION_SERVICE)
|
||||||
|
as NotificationManager
|
||||||
|
|
||||||
|
val channelId = "schedule_notifications"
|
||||||
|
|
||||||
|
// Create notification channel for Android O and above
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
val channel = NotificationChannel(
|
||||||
|
channelId,
|
||||||
|
"스케쥴 알림",
|
||||||
|
NotificationManager.IMPORTANCE_DEFAULT
|
||||||
|
)
|
||||||
|
notificationManager.createNotificationChannel(channel)
|
||||||
|
}
|
||||||
|
|
||||||
|
val notification = NotificationCompat.Builder(applicationContext, channelId)
|
||||||
|
.setSmallIcon(R.drawable.ic_logo)
|
||||||
|
.setContentTitle(title)
|
||||||
|
.setContentText(message)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
notificationManager.notify(System.currentTimeMillis().toInt(), notification)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:pathData="M19,3H5C3.9,3 3,3.9 3,5v14c0,1.1 0.9,2 2,2h14c1.1,0 2,-0.9 2,-2V5C21,3.9 20.1,3 19,3zM9,17H7v-7h2V17zM13,17h-2V7h2V17zM17,17h-2v-4h2V17z"/>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:pathData="M20,11H7.83l5.59,-5.59L12,4l-8,8 8,8 1.41,-1.41L7.83,13H20v-2z"/>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/darker_gray"
|
||||||
|
android:pathData="M3.5,18.49l6,-6.01 4,4L22,6.92l-1.41,-1.41 -7.09,7.97 -4,-4L2,16.99z"/>
|
||||||
|
</vector>
|
||||||
|
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/darker_gray"
|
||||||
|
android:pathData="M3,13h8L11,3L3,3v10zM3,21h8v-6L3,15v6zM13,21h8L21,11h-8v10zM13,3v6h8L21,3h-8z"/>
|
||||||
|
</vector>
|
||||||
|
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:pathData="M3,14h4v-4H3V14zM3,19h4v-4H3V19zM3,9h4V5H3V9zM8,14h13v-4H8V14zM8,19h13v-4H8V19zM8,5v4h13V5H8z"/>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
<group android:scaleX="0.5"
|
||||||
|
android:scaleY="0.5"
|
||||||
|
android:translateX="27"
|
||||||
|
android:translateY="27">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:pathData="M12,2L2,7v10c0,5.55 3.84,10.74 9,12 5.16,-1.26 9,-6.45 9,-12L20,7l-10,-5zM12,12c-1.1,0 -2,-0.9 -2,-2s0.9,-2 2,-2 2,0.9 2,2 -0.9,2 -2,2zM18,17h-12v-0.5c0,-2 4,-3.5 6,-3.5s6,1.5 6,3.5L18,17z"/>
|
||||||
|
</group>
|
||||||
|
</vector>
|
||||||
|
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="120dp"
|
||||||
|
android:height="120dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="@color/white"
|
||||||
|
android:pathData="M12,2L2,7v10c0,5.55 3.84,10.74 9,12 5.16,-1.26 9,-6.45 9,-12L20,7l-10,-5zM12,12c-1.1,0 -2,-0.9 -2,-2s0.9,-2 2,-2 2,0.9 2,2 -0.9,2 -2,2zM18,17h-12v-0.5c0,-2 4,-3.5 6,-3.5s6,1.5 6,3.5L18,17z"/>
|
||||||
|
</vector>
|
||||||
|
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:pathData="M12,22c1.1,0 2,-0.9 2,-2h-4c0,1.1 0.89,2 2,2zM18,16v-5c0,-3.07 -1.64,-5.64 -4.5,-6.32V4c0,-0.83 -0.67,-1.5 -1.5,-1.5s-1.5,0.67 -1.5,1.5v0.68C7.63,5.36 6,7.92 6,11v5l-2,2v1h16v-1l-2,-2z"/>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="#8BC34A"
|
||||||
|
android:pathData="M12,22c1.1,0 2,-0.9 2,-2h-4c0,1.1 0.89,2 2,2zM18,16v-5c0,-3.07 -1.64,-5.64 -4.5,-6.32V4c0,-0.83 -0.67,-1.5 -1.5,-1.5s-1.5,0.67 -1.5,1.5v0.68C7.63,5.36 6,7.92 6,11v5l-2,2v1h16v-1l-2,-2z"/>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:pathData="M19,19H5V5h7V3H5c-1.11,0 -2,0.9 -2,2v14c0,1.1 0.89,2 2,2h14c1.1,0 2,-0.9 2,-2v-7h-2v7zM14,3v2h3.59l-9.83,9.83 1.41,1.41L19,6.41V10h2V3h-7z"/>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/darker_gray"
|
||||||
|
android:pathData="M20,6h-2.18c0.11,-0.31 0.18,-0.65 0.18,-1 0,-1.66 -1.34,-3 -3,-3 -1.05,0 -1.96,0.54 -2.5,1.35l-0.5,0.67 -0.5,-0.68C10.96,2.54 10.05,2 9,2 7.34,2 6,3.34 6,5c0,0.35 0.07,0.69 0.18,1L4,6c-1.11,0 -1.99,0.89 -1.99,2L2,19c0,1.11 0.89,2 2,2h16c1.11,0 2,-0.89 2,-2L22,8c0,-1.11 -0.89,-2 -2,-2zM15,4c0.55,0 1,0.45 1,1s-0.45,1 -1,1 -1,-0.45 -1,-1 0.45,-1 1,-1zM9,4c0.55,0 1,0.45 1,1s-0.45,1 -1,1 -1,-0.45 -1,-1 0.45,-1 1,-1zM20,19L4,19L4,8h5.08L7,10.83 8.62,12 11,8.76l1,-1.36 1,1.36L15.38,12 17,10.83 14.92,8L20,8v11z"/>
|
||||||
|
</vector>
|
||||||
|
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/darker_gray"
|
||||||
|
android:pathData="M11.99,2C6.47,2 2,6.48 2,12s4.47,10 9.99,10C17.52,22 22,17.52 22,12S17.52,2 11.99,2zM12,20c-4.42,0 -8,-3.58 -8,-8s3.58,-8 8,-8 8,3.58 8,8 -3.58,8 -8,8zM12.5,7H11v6l5.25,3.15 0.75,-1.23 -4.5,-2.67z"/>
|
||||||
|
</vector>
|
||||||
|
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/darker_gray"
|
||||||
|
android:pathData="M19.14,12.94c0.04,-0.3 0.06,-0.61 0.06,-0.94 0,-0.32 -0.02,-0.64 -0.07,-0.94l2.03,-1.58c0.18,-0.14 0.23,-0.41 0.12,-0.61l-1.92,-3.32c-0.12,-0.22 -0.37,-0.29 -0.59,-0.22l-2.39,0.96c-0.5,-0.38 -1.03,-0.7 -1.62,-0.94L14.4,2.81c-0.04,-0.24 -0.24,-0.41 -0.48,-0.41h-3.84c-0.24,0 -0.43,0.17 -0.47,0.41L9.25,5.35C8.66,5.59 8.12,5.92 7.63,6.29L5.24,5.33c-0.22,-0.08 -0.47,0 -0.59,0.22L2.74,8.87C2.62,9.08 2.66,9.34 2.86,9.48l2.03,1.58C4.84,11.36 4.8,11.69 4.8,12s0.02,0.64 0.07,0.94l-2.03,1.58c-0.18,0.14 -0.23,0.41 -0.12,0.61l1.92,3.32c0.12,0.22 0.37,0.29 0.59,0.22l2.39,-0.96c0.5,0.38 1.03,0.7 1.62,0.94l0.36,2.54c0.05,0.24 0.24,0.41 0.48,0.41h3.84c0.24,0 0.44,-0.17 0.47,-0.41l0.36,-2.54c0.59,-0.24 1.13,-0.56 1.62,-0.94l2.39,0.96c0.22,0.08 0.47,0 0.59,-0.22l1.92,-3.32c0.12,-0.22 0.07,-0.47 -0.12,-0.61L19.14,12.94zM12,15.6c-1.98,0 -3.6,-1.62 -3.6,-3.6s1.62,-3.6 3.6,-3.6 3.6,1.62 3.6,3.6S13.98,15.6 12,15.6z"/>
|
||||||
|
</vector>
|
||||||
|
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<item android:drawable="@color/primary"/>
|
||||||
|
<item>
|
||||||
|
<bitmap
|
||||||
|
android:gravity="center"
|
||||||
|
android:src="@drawable/ic_logo"/>
|
||||||
|
</item>
|
||||||
|
</layer-list>
|
||||||
|
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<androidx.constraintlayout.widget.ConstraintLayout
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
android:id="@+id/container"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent">
|
||||||
|
|
||||||
|
<com.google.android.material.bottomnavigation.BottomNavigationView
|
||||||
|
android:id="@+id/nav_view"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:background="?android:attr/windowBackground"
|
||||||
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
|
app:layout_constraintLeft_toLeftOf="parent"
|
||||||
|
app:layout_constraintRight_toRightOf="parent"
|
||||||
|
app:menu="@menu/bottom_nav_menu" />
|
||||||
|
|
||||||
|
<fragment
|
||||||
|
android:id="@+id/nav_host_fragment_activity_main"
|
||||||
|
android:name="androidx.navigation.fragment.NavHostFragment"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="0dp"
|
||||||
|
app:defaultNavHost="true"
|
||||||
|
app:layout_constraintBottom_toTopOf="@id/nav_view"
|
||||||
|
app:layout_constraintLeft_toLeftOf="parent"
|
||||||
|
app:layout_constraintRight_toRightOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
|
app:navGraph="@navigation/mobile_navigation" />
|
||||||
|
|
||||||
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
|
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<androidx.constraintlayout.widget.ConstraintLayout
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:background="@color/primary">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/ivLogo"
|
||||||
|
android:layout_width="120dp"
|
||||||
|
android:layout_height="120dp"
|
||||||
|
android:src="@drawable/ic_logo"
|
||||||
|
android:contentDescription="@string/app_name"
|
||||||
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintVertical_bias="0.4"/>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvAppName"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/app_name"
|
||||||
|
android:textSize="28sp"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:textColor="@color/white"
|
||||||
|
android:layout_marginTop="24dp"
|
||||||
|
app:layout_constraintTop_toBottomOf="@id/ivLogo"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"/>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="투자 분석의 새로운 기준"
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:textColor="@color/white"
|
||||||
|
android:alpha="0.8"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
app:layout_constraintTop_toBottomOf="@id/tvAppName"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"/>
|
||||||
|
|
||||||
|
<ProgressBar
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:indeterminateTint="@color/white"
|
||||||
|
android:layout_marginBottom="48dp"
|
||||||
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"/>
|
||||||
|
|
||||||
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
|
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="16dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvTitle"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textSize="18sp"
|
||||||
|
android:textStyle="bold"
|
||||||
|
tools:text="📈 매수 신호 상세" />
|
||||||
|
|
||||||
|
<ScrollView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="12dp"
|
||||||
|
android:maxHeight="400dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvBody"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textIsSelectable="true"
|
||||||
|
android:textSize="13sp"
|
||||||
|
tools:text="내용" />
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/btnClose"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="16dp"
|
||||||
|
android:text="@string/ok" />
|
||||||
|
</LinearLayout>
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<LinearLayout
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="24dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="스케쥴 추가/수정"
|
||||||
|
android:textSize="20sp"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:textColor="@color/text_primary"
|
||||||
|
android:layout_marginBottom="16dp"/>
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:hint="@string/scheduler_name"
|
||||||
|
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox">
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputEditText
|
||||||
|
android:id="@+id/etScheduleName"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:inputType="text"
|
||||||
|
android:maxLines="1"/>
|
||||||
|
|
||||||
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/scheduler_interval"
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:textColor="@color/text_primary"
|
||||||
|
android:layout_marginTop="16dp"
|
||||||
|
android:layout_marginBottom="8dp"/>
|
||||||
|
|
||||||
|
<Spinner
|
||||||
|
android:id="@+id/spinnerInterval"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:minHeight="48dp"/>
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:hint="@string/scheduler_indicators"
|
||||||
|
android:layout_marginTop="16dp"
|
||||||
|
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox">
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputEditText
|
||||||
|
android:id="@+id/etIndicators"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:inputType="text"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:hint="예: MACD, RSI, Stochastic"/>
|
||||||
|
|
||||||
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/scheduler_notification"
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:textColor="@color/text_primary"
|
||||||
|
android:layout_marginTop="16dp"
|
||||||
|
android:layout_marginBottom="8dp"/>
|
||||||
|
|
||||||
|
<Spinner
|
||||||
|
android:id="@+id/spinnerNotification"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:minHeight="48dp"/>
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:hint="@string/scheduler_condition"
|
||||||
|
android:layout_marginTop="16dp"
|
||||||
|
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox">
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputEditText
|
||||||
|
android:id="@+id/etConditions"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:inputType="textMultiLine"
|
||||||
|
android:minLines="2"
|
||||||
|
android:hint="예: MACD > 0, RSI < 30"/>
|
||||||
|
|
||||||
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:layout_marginTop="24dp"
|
||||||
|
android:gravity="end">
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/btnCancel"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/scheduler_cancel"
|
||||||
|
style="@style/Widget.MaterialComponents.Button.TextButton"/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/btnSave"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/scheduler_save"
|
||||||
|
android:layout_marginStart="8dp"/>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<?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"
|
||||||
|
android:background="@color/background_dark">
|
||||||
|
|
||||||
|
<com.google.android.material.appbar.AppBarLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content">
|
||||||
|
|
||||||
|
<com.google.android.material.appbar.MaterialToolbar
|
||||||
|
android:id="@+id/toolbar"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="?attr/actionBarSize"
|
||||||
|
android:background="@color/alert_popup_header"
|
||||||
|
app:title="@string/nav_alerts"
|
||||||
|
app:titleTextColor="@color/alert_popup_text_primary" />
|
||||||
|
</com.google.android.material.appbar.AppBarLayout>
|
||||||
|
|
||||||
|
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||||
|
android:id="@+id/swipeRefresh"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
app:layout_behavior="@string/appbar_scrolling_view_behavior">
|
||||||
|
|
||||||
|
<androidx.core.widget.NestedScrollView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:fillViewport="true"
|
||||||
|
android:padding="12dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/alerts_unread_section"
|
||||||
|
android:textColor="@color/alert_popup_text_secondary"
|
||||||
|
android:textSize="12sp"
|
||||||
|
android:layout_marginBottom="8dp" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/cardsContainer"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvEmpty"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:gravity="center"
|
||||||
|
android:padding="24dp"
|
||||||
|
android:text="@string/alerts_empty"
|
||||||
|
android:textColor="@color/alert_popup_text_secondary"
|
||||||
|
android:visibility="gone"
|
||||||
|
tools:visibility="visible" />
|
||||||
|
|
||||||
|
<ProgressBar
|
||||||
|
android:id="@+id/progress"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_gravity="center"
|
||||||
|
android:visibility="gone" />
|
||||||
|
</LinearLayout>
|
||||||
|
</androidx.core.widget.NestedScrollView>
|
||||||
|
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
|
||||||
|
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:background="@color/background"
|
||||||
|
tools:context=".ui.chart.ChartFragment">
|
||||||
|
|
||||||
|
<WebView
|
||||||
|
android:id="@+id/webView"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"/>
|
||||||
|
|
||||||
|
<ProgressBar
|
||||||
|
android:id="@+id/progressBar"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_gravity="center"
|
||||||
|
android:indeterminateTint="@color/primary"
|
||||||
|
android:visibility="gone"/>
|
||||||
|
|
||||||
|
</FrameLayout>
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<androidx.core.widget.NestedScrollView
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:fillViewport="true">
|
||||||
|
|
||||||
|
<androidx.constraintlayout.widget.ConstraintLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:padding="16dp">
|
||||||
|
|
||||||
|
<!-- Total Assets Card -->
|
||||||
|
<com.google.android.material.card.MaterialCardView
|
||||||
|
android:id="@+id/cardTotalAssets"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
app:cardCornerRadius="12dp"
|
||||||
|
app:cardElevation="4dp"
|
||||||
|
app:layout_constraintTop_toTopOf="parent">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="20dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/dashboard_total_assets"
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:textColor="@color/text_secondary"/>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvTotalAssets"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="0원"
|
||||||
|
android:textSize="32sp"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:textColor="@color/text_primary"
|
||||||
|
android:layout_marginTop="8dp"/>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
</com.google.android.material.card.MaterialCardView>
|
||||||
|
|
||||||
|
<!-- Profit/Loss and Today Change Row -->
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/layoutStats"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:layout_marginTop="16dp"
|
||||||
|
android:weightSum="2"
|
||||||
|
app:layout_constraintTop_toBottomOf="@id/cardTotalAssets">
|
||||||
|
|
||||||
|
<!-- Profit/Loss Card -->
|
||||||
|
<com.google.android.material.card.MaterialCardView
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:layout_marginEnd="8dp"
|
||||||
|
app:cardCornerRadius="12dp"
|
||||||
|
app:cardElevation="4dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="16dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/dashboard_profit_loss"
|
||||||
|
android:textSize="12sp"
|
||||||
|
android:textColor="@color/text_secondary"/>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvProfitLoss"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="0.00%"
|
||||||
|
android:textSize="20sp"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:textColor="@color/success"
|
||||||
|
android:layout_marginTop="4dp"/>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
</com.google.android.material.card.MaterialCardView>
|
||||||
|
|
||||||
|
<!-- Today Change Card -->
|
||||||
|
<com.google.android.material.card.MaterialCardView
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:layout_marginStart="8dp"
|
||||||
|
app:cardCornerRadius="12dp"
|
||||||
|
app:cardElevation="4dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="16dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/dashboard_today_change"
|
||||||
|
android:textSize="12sp"
|
||||||
|
android:textColor="@color/text_secondary"/>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvTodayChange"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="0원"
|
||||||
|
android:textSize="20sp"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:textColor="@color/chart_up"
|
||||||
|
android:layout_marginTop="4dp"/>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
</com.google.android.material.card.MaterialCardView>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- Recent Activity Section -->
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvRecentActivity"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="최근 활동"
|
||||||
|
android:textSize="18sp"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:textColor="@color/text_primary"
|
||||||
|
android:layout_marginTop="24dp"
|
||||||
|
app:layout_constraintTop_toBottomOf="@id/layoutStats"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"/>
|
||||||
|
|
||||||
|
<androidx.recyclerview.widget.RecyclerView
|
||||||
|
android:id="@+id/rvRecentActivity"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="12dp"
|
||||||
|
app:layout_constraintTop_toBottomOf="@id/tvRecentActivity"/>
|
||||||
|
|
||||||
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
|
</androidx.core.widget.NestedScrollView>
|
||||||
|
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:background="@color/background_dark">
|
||||||
|
|
||||||
|
<com.google.android.material.appbar.MaterialToolbar
|
||||||
|
android:id="@+id/toolbar"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="?attr/actionBarSize"
|
||||||
|
android:background="@color/alert_popup_header"
|
||||||
|
android:theme="@style/ThemeOverlay.MaterialComponents.Dark.ActionBar" />
|
||||||
|
|
||||||
|
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||||
|
android:id="@+id/swipeRefresh"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="0dp"
|
||||||
|
android:layout_weight="1">
|
||||||
|
|
||||||
|
<androidx.recyclerview.widget.RecyclerView
|
||||||
|
android:id="@+id/recycler"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:clipToPadding="false"
|
||||||
|
android:padding="8dp"
|
||||||
|
tools:listitem="@layout/item_notification_history" />
|
||||||
|
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvEmpty"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:gravity="center"
|
||||||
|
android:padding="24dp"
|
||||||
|
android:text="@string/alerts_empty"
|
||||||
|
android:textColor="@color/alert_popup_text_secondary"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
|
<ProgressBar
|
||||||
|
android:id="@+id/progress"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_gravity="center"
|
||||||
|
android:visibility="gone" />
|
||||||
|
</LinearLayout>
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
<?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"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent">
|
||||||
|
|
||||||
|
<androidx.core.widget.NestedScrollView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="16dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/portfolio_title"
|
||||||
|
android:textSize="24sp"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:textColor="@color/text_primary"/>
|
||||||
|
|
||||||
|
<androidx.recyclerview.widget.RecyclerView
|
||||||
|
android:id="@+id/rvPortfolio"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="16dp"
|
||||||
|
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"/>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvEmptyState"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/portfolio_no_items"
|
||||||
|
android:textSize="16sp"
|
||||||
|
android:textColor="@color/text_secondary"
|
||||||
|
android:layout_gravity="center"
|
||||||
|
android:layout_marginTop="48dp"
|
||||||
|
android:visibility="gone"/>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
</androidx.core.widget.NestedScrollView>
|
||||||
|
|
||||||
|
<com.google.android.material.floatingactionbutton.FloatingActionButton
|
||||||
|
android:id="@+id/fabAdd"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_gravity="bottom|end"
|
||||||
|
android:layout_margin="16dp"
|
||||||
|
android:contentDescription="@string/portfolio_add"
|
||||||
|
android:src="@android:drawable/ic_input_add"
|
||||||
|
app:tint="@color/white"/>
|
||||||
|
|
||||||
|
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||||
|
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<?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"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent">
|
||||||
|
|
||||||
|
<androidx.core.widget.NestedScrollView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="16dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/scheduler_title"
|
||||||
|
android:textSize="24sp"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:textColor="@color/text_primary"/>
|
||||||
|
|
||||||
|
<androidx.recyclerview.widget.RecyclerView
|
||||||
|
android:id="@+id/rvScheduler"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="16dp"/>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvEmptyState"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/scheduler_no_items"
|
||||||
|
android:textSize="16sp"
|
||||||
|
android:textColor="@color/text_secondary"
|
||||||
|
android:layout_gravity="center"
|
||||||
|
android:layout_marginTop="48dp"
|
||||||
|
android:visibility="gone"/>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
</androidx.core.widget.NestedScrollView>
|
||||||
|
|
||||||
|
<com.google.android.material.floatingactionbutton.FloatingActionButton
|
||||||
|
android:id="@+id/fabAdd"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_gravity="bottom|end"
|
||||||
|
android:layout_margin="16dp"
|
||||||
|
android:contentDescription="@string/scheduler_add"
|
||||||
|
android:src="@android:drawable/ic_input_add"
|
||||||
|
app:tint="@color/white"/>
|
||||||
|
|
||||||
|
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||||
|
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<androidx.core.widget.NestedScrollView
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="16dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/settings_title"
|
||||||
|
android:textSize="24sp"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:textColor="@color/text_primary"
|
||||||
|
android:layout_marginBottom="24dp"/>
|
||||||
|
|
||||||
|
<!-- Notifications Section -->
|
||||||
|
<com.google.android.material.card.MaterialCardView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
app:cardCornerRadius="8dp"
|
||||||
|
app:cardElevation="2dp"
|
||||||
|
android:layout_marginBottom="16dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="16dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/settings_notification"
|
||||||
|
android:textSize="16sp"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:textColor="@color/text_primary"
|
||||||
|
android:layout_marginBottom="12dp"/>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:text="알림 활성화"
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:textColor="@color/text_secondary"/>
|
||||||
|
|
||||||
|
<com.google.android.material.switchmaterial.SwitchMaterial
|
||||||
|
android:id="@+id/switchNotification"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"/>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
</com.google.android.material.card.MaterialCardView>
|
||||||
|
|
||||||
|
<!-- Theme Section -->
|
||||||
|
<com.google.android.material.card.MaterialCardView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
app:cardCornerRadius="8dp"
|
||||||
|
app:cardElevation="2dp"
|
||||||
|
android:layout_marginBottom="16dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="16dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/settings_theme"
|
||||||
|
android:textSize="16sp"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:textColor="@color/text_primary"
|
||||||
|
android:layout_marginBottom="12dp"/>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:text="다크 모드"
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:textColor="@color/text_secondary"/>
|
||||||
|
|
||||||
|
<com.google.android.material.switchmaterial.SwitchMaterial
|
||||||
|
android:id="@+id/switchDarkMode"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"/>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
</com.google.android.material.card.MaterialCardView>
|
||||||
|
|
||||||
|
<!-- API Server Section -->
|
||||||
|
<com.google.android.material.card.MaterialCardView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
app:cardCornerRadius="8dp"
|
||||||
|
app:cardElevation="2dp"
|
||||||
|
android:layout_marginBottom="16dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="16dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/settings_api_server"
|
||||||
|
android:textSize="16sp"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:textColor="@color/text_primary"
|
||||||
|
android:layout_marginBottom="12dp"/>
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:hint="Frontend (모바일 차트) URL"
|
||||||
|
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox">
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputEditText
|
||||||
|
android:id="@+id/etApiServer"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:inputType="textUri"
|
||||||
|
android:maxLines="1"/>
|
||||||
|
|
||||||
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="12dp"
|
||||||
|
android:hint="백엔드 API (선택, 예: http://IP:8083/api/)"
|
||||||
|
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox">
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputEditText
|
||||||
|
android:id="@+id/etApiBackend"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:inputType="textUri"
|
||||||
|
android:maxLines="1" />
|
||||||
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/btnSaveServer"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/save"
|
||||||
|
android:layout_gravity="end"
|
||||||
|
android:layout_marginTop="8dp"/>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
</com.google.android.material.card.MaterialCardView>
|
||||||
|
|
||||||
|
<!-- Version Info Section -->
|
||||||
|
<com.google.android.material.card.MaterialCardView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
app:cardCornerRadius="8dp"
|
||||||
|
app:cardElevation="2dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="16dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/settings_version"
|
||||||
|
android:textSize="16sp"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:textColor="@color/text_primary"
|
||||||
|
android:layout_marginBottom="12dp"/>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:text="앱 버전"
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:textColor="@color/text_secondary"/>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvVersionValue"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="1.0.0"
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:textColor="@color/text_primary"/>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
</com.google.android.material.card.MaterialCardView>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
</androidx.core.widget.NestedScrollView>
|
||||||
|
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<com.google.android.material.card.MaterialCardView 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="wrap_content"
|
||||||
|
android:layout_marginBottom="12dp"
|
||||||
|
app:cardBackgroundColor="@color/alert_popup_bg"
|
||||||
|
app:strokeColor="@color/alert_popup_border"
|
||||||
|
app:strokeWidth="1dp"
|
||||||
|
app:cardCornerRadius="8dp"
|
||||||
|
app:cardElevation="6dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/headerBar"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:paddingHorizontal="12dp"
|
||||||
|
android:paddingVertical="10dp"
|
||||||
|
android:background="@color/alert_popup_header">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:layout_width="20dp"
|
||||||
|
android:layout_height="20dp"
|
||||||
|
android:src="@drawable/ic_notifications_filled"
|
||||||
|
android:contentDescription="@null" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvCardTitle"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:layout_marginStart="8dp"
|
||||||
|
android:textColor="@color/alert_popup_text_primary"
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:maxLines="2"
|
||||||
|
android:ellipsize="end"
|
||||||
|
tools:text="이동평균선5일 1, 알람 CCI 외 2개 전략" />
|
||||||
|
|
||||||
|
<ImageButton
|
||||||
|
android:id="@+id/btnInfo"
|
||||||
|
android:layout_width="40dp"
|
||||||
|
android:layout_height="40dp"
|
||||||
|
android:background="?attr/selectableItemBackgroundBorderless"
|
||||||
|
android:contentDescription="@string/alert_detail_info"
|
||||||
|
android:src="@android:drawable/ic_menu_info_details"
|
||||||
|
android:tint="@color/alert_popup_text_primary" />
|
||||||
|
|
||||||
|
<ImageButton
|
||||||
|
android:id="@+id/btnClose"
|
||||||
|
android:layout_width="40dp"
|
||||||
|
android:layout_height="40dp"
|
||||||
|
android:background="?attr/selectableItemBackgroundBorderless"
|
||||||
|
android:contentDescription="@string/alert_close"
|
||||||
|
android:src="@android:drawable/ic_menu_close_clear_cancel"
|
||||||
|
android:tint="@color/alert_popup_text_primary" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<View
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="1dp"
|
||||||
|
android:background="#3D4558" />
|
||||||
|
|
||||||
|
<androidx.core.widget.NestedScrollView
|
||||||
|
android:id="@+id/scrollRows"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:maxHeight="220dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/containerRows"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical" />
|
||||||
|
</androidx.core.widget.NestedScrollView>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvSingleLineMessage"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:padding="12dp"
|
||||||
|
android:textColor="@color/alert_popup_text_secondary"
|
||||||
|
android:textSize="12sp"
|
||||||
|
android:visibility="gone"
|
||||||
|
tools:text="메시지" />
|
||||||
|
</LinearLayout>
|
||||||
|
</com.google.android.material.card.MaterialCardView>
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:paddingStart="12dp"
|
||||||
|
android:paddingEnd="4dp"
|
||||||
|
android:paddingTop="8dp"
|
||||||
|
android:paddingBottom="8dp"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:background="?attr/selectableItemBackground">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvKoreanName"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textColor="@color/alert_popup_text_primary"
|
||||||
|
android:textSize="13sp"
|
||||||
|
android:textStyle="bold"
|
||||||
|
tools:text="게임빌드" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvSymbol"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textColor="@color/alert_popup_text_secondary"
|
||||||
|
android:textSize="11sp"
|
||||||
|
tools:text="KRW-GAME2" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<ImageButton
|
||||||
|
android:id="@+id/btnAlertList"
|
||||||
|
android:layout_width="40dp"
|
||||||
|
android:layout_height="40dp"
|
||||||
|
android:background="?attr/selectableItemBackgroundBorderless"
|
||||||
|
android:contentDescription="@string/alert_action_list"
|
||||||
|
android:src="@drawable/ic_format_list"
|
||||||
|
android:tint="@color/alert_popup_text_primary" />
|
||||||
|
|
||||||
|
<ImageButton
|
||||||
|
android:id="@+id/btnUpbit"
|
||||||
|
android:layout_width="40dp"
|
||||||
|
android:layout_height="40dp"
|
||||||
|
android:background="?attr/selectableItemBackgroundBorderless"
|
||||||
|
android:contentDescription="@string/alert_action_upbit"
|
||||||
|
android:src="@drawable/ic_open_in_new"
|
||||||
|
android:tint="@color/success" />
|
||||||
|
|
||||||
|
<ImageButton
|
||||||
|
android:id="@+id/btnSignalDetail"
|
||||||
|
android:layout_width="40dp"
|
||||||
|
android:layout_height="40dp"
|
||||||
|
android:background="?attr/selectableItemBackgroundBorderless"
|
||||||
|
android:contentDescription="@string/alert_action_signal_detail"
|
||||||
|
android:src="@drawable/ic_analytics"
|
||||||
|
android:tint="@color/primary_light" />
|
||||||
|
</LinearLayout>
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<com.google.android.material.card.MaterialCardView 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="wrap_content"
|
||||||
|
android:layout_marginBottom="8dp"
|
||||||
|
app:cardBackgroundColor="@color/alert_popup_bg"
|
||||||
|
app:strokeColor="#3D4558"
|
||||||
|
app:strokeWidth="1dp"
|
||||||
|
app:cardCornerRadius="6dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="12dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvTitle"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textColor="@color/alert_popup_text_primary"
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:textStyle="bold"
|
||||||
|
tools:text="전략명" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvTime"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textColor="@color/alert_popup_text_secondary"
|
||||||
|
android:textSize="11sp"
|
||||||
|
tools:text="2025-01-01" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/rowActions"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:gravity="end"
|
||||||
|
android:layout_marginTop="8dp">
|
||||||
|
|
||||||
|
<ImageButton
|
||||||
|
android:id="@+id/btnDelete"
|
||||||
|
android:layout_width="40dp"
|
||||||
|
android:layout_height="40dp"
|
||||||
|
android:background="?attr/selectableItemBackgroundBorderless"
|
||||||
|
android:contentDescription="@string/delete"
|
||||||
|
android:src="@android:drawable/ic_menu_delete"
|
||||||
|
android:tint="@color/alert_popup_text_secondary" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/containerRows"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:visibility="gone" />
|
||||||
|
</LinearLayout>
|
||||||
|
</com.google.android.material.card.MaterialCardView>
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<com.google.android.material.card.MaterialCardView
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginVertical="8dp"
|
||||||
|
app:cardCornerRadius="8dp"
|
||||||
|
app:cardElevation="2dp">
|
||||||
|
|
||||||
|
<androidx.constraintlayout.widget.ConstraintLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:padding="16dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvScheduleName"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="스케쥴 이름"
|
||||||
|
android:textSize="16sp"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:textColor="@color/text_primary"
|
||||||
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintEnd_toStartOf="@id/switchEnabled"/>
|
||||||
|
|
||||||
|
<com.google.android.material.switchmaterial.SwitchMaterial
|
||||||
|
android:id="@+id/switchEnabled"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"/>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvInterval"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="간격: 1시간"
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:textColor="@color/text_secondary"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
app:layout_constraintTop_toBottomOf="@id/tvScheduleName"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"/>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvIndicators"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="MACD, RSI"
|
||||||
|
android:textSize="12sp"
|
||||||
|
android:textColor="@color/text_secondary"
|
||||||
|
android:layout_marginTop="4dp"
|
||||||
|
app:layout_constraintTop_toBottomOf="@id/tvInterval"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"/>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvLastExecuted"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="마지막 실행: 2024-01-25 10:00"
|
||||||
|
android:textSize="11sp"
|
||||||
|
android:textColor="@color/text_hint"
|
||||||
|
android:layout_marginTop="4dp"
|
||||||
|
app:layout_constraintTop_toBottomOf="@id/tvIndicators"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"/>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:layout_marginTop="12dp"
|
||||||
|
app:layout_constraintTop_toBottomOf="@id/tvLastExecuted"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent">
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/btnEdit"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/scheduler_edit"
|
||||||
|
android:textSize="12sp"
|
||||||
|
style="@style/Widget.MaterialComponents.Button.TextButton"/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/btnDelete"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/scheduler_delete"
|
||||||
|
android:textSize="12sp"
|
||||||
|
android:textColor="@color/error"
|
||||||
|
style="@style/Widget.MaterialComponents.Button.TextButton"/>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
|
|
||||||
|
</com.google.android.material.card.MaterialCardView>
|
||||||
|
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<menu xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<item
|
||||||
|
android:id="@+id/navigation_dashboard"
|
||||||
|
android:icon="@drawable/ic_dashboard"
|
||||||
|
android:title="@string/nav_dashboard" />
|
||||||
|
|
||||||
|
<item
|
||||||
|
android:id="@+id/navigation_portfolio"
|
||||||
|
android:icon="@drawable/ic_portfolio"
|
||||||
|
android:title="@string/nav_portfolio" />
|
||||||
|
|
||||||
|
<item
|
||||||
|
android:id="@+id/navigation_chart"
|
||||||
|
android:icon="@drawable/ic_chart"
|
||||||
|
android:title="@string/nav_chart" />
|
||||||
|
|
||||||
|
<item
|
||||||
|
android:id="@+id/navigation_alerts"
|
||||||
|
android:icon="@drawable/ic_nav_notifications"
|
||||||
|
android:title="@string/nav_alerts" />
|
||||||
|
|
||||||
|
<item
|
||||||
|
android:id="@+id/navigation_scheduler"
|
||||||
|
android:icon="@drawable/ic_scheduler"
|
||||||
|
android:title="@string/nav_scheduler" />
|
||||||
|
|
||||||
|
<item
|
||||||
|
android:id="@+id/navigation_settings"
|
||||||
|
android:icon="@drawable/ic_settings"
|
||||||
|
android:title="@string/nav_settings" />
|
||||||
|
</menu>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<menu xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||||
|
<item
|
||||||
|
android:id="@+id/action_all_notifications"
|
||||||
|
android:title="@string/alerts_menu_all"
|
||||||
|
app:showAsAction="ifRoom" />
|
||||||
|
</menu>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/primary"/>
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||||
|
</adaptive-icon>
|
||||||
|
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/primary"/>
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||||
|
</adaptive-icon>
|
||||||
|
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<navigation 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:id="@+id/mobile_navigation"
|
||||||
|
app:startDestination="@+id/navigation_dashboard">
|
||||||
|
|
||||||
|
<fragment
|
||||||
|
android:id="@+id/navigation_dashboard"
|
||||||
|
android:name="com.analysis.goldenanalysis.ui.dashboard.DashboardFragment"
|
||||||
|
android:label="@string/nav_dashboard"
|
||||||
|
tools:layout="@layout/fragment_dashboard" />
|
||||||
|
|
||||||
|
<fragment
|
||||||
|
android:id="@+id/navigation_portfolio"
|
||||||
|
android:name="com.analysis.goldenanalysis.ui.portfolio.PortfolioFragment"
|
||||||
|
android:label="@string/nav_portfolio"
|
||||||
|
tools:layout="@layout/fragment_portfolio" />
|
||||||
|
|
||||||
|
<fragment
|
||||||
|
android:id="@+id/navigation_chart"
|
||||||
|
android:name="com.analysis.goldenanalysis.ui.chart.ChartFragment"
|
||||||
|
android:label="@string/nav_chart"
|
||||||
|
tools:layout="@layout/fragment_chart" />
|
||||||
|
|
||||||
|
<fragment
|
||||||
|
android:id="@+id/navigation_alerts"
|
||||||
|
android:name="com.analysis.goldenanalysis.ui.alert.AlertsFragment"
|
||||||
|
android:label="@string/nav_alerts"
|
||||||
|
tools:layout="@layout/fragment_alerts">
|
||||||
|
<action
|
||||||
|
android:id="@+id/action_alerts_to_notification_list"
|
||||||
|
app:destination="@id/navigation_notification_list" />
|
||||||
|
</fragment>
|
||||||
|
|
||||||
|
<fragment
|
||||||
|
android:id="@+id/navigation_notification_list"
|
||||||
|
android:name="com.analysis.goldenanalysis.ui.alert.NotificationListFragment"
|
||||||
|
android:label="@string/alerts_menu_all"
|
||||||
|
tools:layout="@layout/fragment_notification_list">
|
||||||
|
<argument
|
||||||
|
android:name="filterSymbol"
|
||||||
|
app:argType="string"
|
||||||
|
android:defaultValue="" />
|
||||||
|
<argument
|
||||||
|
android:name="filterKorean"
|
||||||
|
app:argType="string"
|
||||||
|
android:defaultValue="" />
|
||||||
|
<argument
|
||||||
|
android:name="highlightId"
|
||||||
|
app:argType="long"
|
||||||
|
android:defaultValue="0L" />
|
||||||
|
</fragment>
|
||||||
|
|
||||||
|
<fragment
|
||||||
|
android:id="@+id/navigation_scheduler"
|
||||||
|
android:name="com.analysis.goldenanalysis.ui.scheduler.SchedulerFragment"
|
||||||
|
android:label="@string/nav_scheduler"
|
||||||
|
tools:layout="@layout/fragment_scheduler" />
|
||||||
|
|
||||||
|
<fragment
|
||||||
|
android:id="@+id/navigation_settings"
|
||||||
|
android:name="com.analysis.goldenanalysis.ui.settings.SettingsFragment"
|
||||||
|
android:label="@string/nav_settings"
|
||||||
|
tools:layout="@layout/fragment_settings" />
|
||||||
|
|
||||||
|
</navigation>
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="purple_200">#FFBB86FC</color>
|
||||||
|
<color name="purple_500">#FF6200EE</color>
|
||||||
|
<color name="purple_700">#FF3700B3</color>
|
||||||
|
<color name="teal_200">#FF03DAC5</color>
|
||||||
|
<color name="teal_700">#FF018786</color>
|
||||||
|
<color name="black">#FF000000</color>
|
||||||
|
<color name="white">#FFFFFFFF</color>
|
||||||
|
|
||||||
|
<!-- App Colors -->
|
||||||
|
<color name="primary">#1976D2</color>
|
||||||
|
<color name="primary_dark">#115293</color>
|
||||||
|
<color name="primary_light">#63A4FF</color>
|
||||||
|
<color name="accent">#FF9800</color>
|
||||||
|
<color name="accent_dark">#F57C00</color>
|
||||||
|
<color name="accent_light">#FFB74D</color>
|
||||||
|
|
||||||
|
<!-- Text Colors -->
|
||||||
|
<color name="text_primary">#212121</color>
|
||||||
|
<color name="text_secondary">#757575</color>
|
||||||
|
<color name="text_hint">#BDBDBD</color>
|
||||||
|
|
||||||
|
<!-- Background Colors -->
|
||||||
|
<color name="background">#FAFAFA</color>
|
||||||
|
<color name="background_dark">#303030</color>
|
||||||
|
<color name="card_background">#FFFFFF</color>
|
||||||
|
|
||||||
|
<!-- Status Colors -->
|
||||||
|
<color name="success">#4CAF50</color>
|
||||||
|
<color name="warning">#FFC107</color>
|
||||||
|
<color name="error">#F44336</color>
|
||||||
|
<color name="info">#2196F3</color>
|
||||||
|
|
||||||
|
<!-- Chart Colors -->
|
||||||
|
<color name="chart_up">#E53935</color>
|
||||||
|
<color name="chart_down">#1E88E5</color>
|
||||||
|
|
||||||
|
<!-- 알림 팝업(프론트 유사 다크 톤) -->
|
||||||
|
<color name="alert_popup_bg">#1E2433</color>
|
||||||
|
<color name="alert_popup_header">#252B3D</color>
|
||||||
|
<color name="alert_popup_border">#829EF1</color>
|
||||||
|
<color name="alert_popup_text_primary">#E8EAF0</color>
|
||||||
|
<color name="alert_popup_text_secondary">#9AA3B5</color>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">Golden Analysis</string>
|
||||||
|
|
||||||
|
<!-- Navigation -->
|
||||||
|
<string name="nav_dashboard">대시보드</string>
|
||||||
|
<string name="nav_portfolio">투자종목</string>
|
||||||
|
<string name="nav_chart">차트</string>
|
||||||
|
<string name="nav_alerts">알림</string>
|
||||||
|
<string name="nav_scheduler">스케쥴러</string>
|
||||||
|
<string name="nav_settings">설정</string>
|
||||||
|
|
||||||
|
<!-- Dashboard -->
|
||||||
|
<string name="dashboard_title">투자 대시보드</string>
|
||||||
|
<string name="dashboard_total_assets">총 자산</string>
|
||||||
|
<string name="dashboard_profit_loss">수익률</string>
|
||||||
|
<string name="dashboard_today_change">오늘 변동</string>
|
||||||
|
|
||||||
|
<!-- Portfolio -->
|
||||||
|
<string name="portfolio_title">투자 종목</string>
|
||||||
|
<string name="portfolio_no_items">투자 종목이 없습니다</string>
|
||||||
|
<string name="portfolio_add">종목 추가</string>
|
||||||
|
|
||||||
|
<!-- Chart -->
|
||||||
|
<string name="chart_title">차트 분석</string>
|
||||||
|
<string name="chart_loading">차트를 불러오는 중...</string>
|
||||||
|
|
||||||
|
<!-- Scheduler -->
|
||||||
|
<string name="scheduler_title">스케쥴러</string>
|
||||||
|
<string name="scheduler_no_items">등록된 스케쥴이 없습니다</string>
|
||||||
|
<string name="scheduler_add">스케쥴 추가</string>
|
||||||
|
<string name="scheduler_name">스케쥴 이름</string>
|
||||||
|
<string name="scheduler_interval">스케쥴 간격</string>
|
||||||
|
<string name="scheduler_indicators">보조지표</string>
|
||||||
|
<string name="scheduler_notification">알림 방법</string>
|
||||||
|
<string name="scheduler_condition">조건</string>
|
||||||
|
<string name="scheduler_enabled">활성화</string>
|
||||||
|
<string name="scheduler_edit">수정</string>
|
||||||
|
<string name="scheduler_delete">삭제</string>
|
||||||
|
<string name="scheduler_save">저장</string>
|
||||||
|
<string name="scheduler_cancel">취소</string>
|
||||||
|
|
||||||
|
<!-- Settings -->
|
||||||
|
<string name="settings_title">설정</string>
|
||||||
|
<string name="settings_notification">알림 설정</string>
|
||||||
|
<string name="settings_theme">테마</string>
|
||||||
|
<string name="settings_api_server">API 서버</string>
|
||||||
|
<string name="settings_version">버전 정보</string>
|
||||||
|
<string name="settings_about">앱 정보</string>
|
||||||
|
|
||||||
|
<!-- Common -->
|
||||||
|
<string name="ok">확인</string>
|
||||||
|
<string name="cancel">취소</string>
|
||||||
|
<string name="save">저장</string>
|
||||||
|
<string name="delete">삭제</string>
|
||||||
|
<string name="edit">수정</string>
|
||||||
|
<string name="search">검색</string>
|
||||||
|
<string name="loading">로딩 중...</string>
|
||||||
|
<string name="error">오류가 발생했습니다</string>
|
||||||
|
<string name="retry">재시도</string>
|
||||||
|
|
||||||
|
<!-- Alerts -->
|
||||||
|
<string name="alerts_unread_section">새 알림 (미읽음)</string>
|
||||||
|
<string name="alerts_empty">표시할 알림이 없습니다.</string>
|
||||||
|
<string name="alerts_menu_all">전체 알림</string>
|
||||||
|
<string name="alert_close">닫기</string>
|
||||||
|
<string name="alert_detail_info">상세 정보</string>
|
||||||
|
<string name="alert_action_list">알림목록으로 이동</string>
|
||||||
|
<string name="alert_action_upbit">업비트에서 차트 열기</string>
|
||||||
|
<string name="alert_action_signal_detail">매수·매도 신호 상세</string>
|
||||||
|
</resources>
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<!-- Base application theme -->
|
||||||
|
<style name="Theme.GoldenAnalysis" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
|
||||||
|
<!-- Primary brand color -->
|
||||||
|
<item name="colorPrimary">@color/primary</item>
|
||||||
|
<item name="colorPrimaryVariant">@color/primary_dark</item>
|
||||||
|
<item name="colorOnPrimary">@color/white</item>
|
||||||
|
<!-- Secondary brand color -->
|
||||||
|
<item name="colorSecondary">@color/accent</item>
|
||||||
|
<item name="colorSecondaryVariant">@color/accent_dark</item>
|
||||||
|
<item name="colorOnSecondary">@color/black</item>
|
||||||
|
<!-- Status bar color -->
|
||||||
|
<item name="android:statusBarColor">?attr/colorPrimaryVariant</item>
|
||||||
|
<!-- Customize your theme here -->
|
||||||
|
<item name="android:windowBackground">@color/background</item>
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<!-- Splash theme -->
|
||||||
|
<style name="Theme.GoldenAnalysis.Splash" parent="Theme.MaterialComponents.DayNight.NoActionBar">
|
||||||
|
<item name="android:windowBackground">@color/primary</item>
|
||||||
|
<item name="android:windowNoTitle">true</item>
|
||||||
|
<item name="android:windowFullscreen">true</item>
|
||||||
|
<item name="android:windowContentOverlay">@null</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
|
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<full-backup-content>
|
||||||
|
<include domain="sharedpref" path="."/>
|
||||||
|
<exclude domain="sharedpref" path="device.xml"/>
|
||||||
|
</full-backup-content>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<data-extraction-rules>
|
||||||
|
<cloud-backup>
|
||||||
|
<include domain="sharedpref" path="."/>
|
||||||
|
<exclude domain="sharedpref" path="device.xml"/>
|
||||||
|
</cloud-backup>
|
||||||
|
</data-extraction-rules>
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<network-security-config>
|
||||||
|
<!-- 모든 도메인에 대해 cleartext traffic 허용 (개발용) -->
|
||||||
|
<base-config cleartextTrafficPermitted="true">
|
||||||
|
<trust-anchors>
|
||||||
|
<!-- 시스템 인증서 신뢰 -->
|
||||||
|
<certificates src="system" />
|
||||||
|
<!-- 사용자 인증서 신뢰 -->
|
||||||
|
<certificates src="user" />
|
||||||
|
</trust-anchors>
|
||||||
|
</base-config>
|
||||||
|
|
||||||
|
<!-- exdev.co.kr 도메인 특별 설정 -->
|
||||||
|
<domain-config cleartextTrafficPermitted="true">
|
||||||
|
<domain includeSubdomains="true">exdev.co.kr</domain>
|
||||||
|
<domain includeSubdomains="true">aidev.iptime.org</domain>
|
||||||
|
<domain includeSubdomains="true">10.0.2.2</domain>
|
||||||
|
<domain includeSubdomains="true">192.168.0.0</domain>
|
||||||
|
<domain includeSubdomains="true">192.168.1.0</domain>
|
||||||
|
<domain includeSubdomains="true">localhost</domain>
|
||||||
|
<trust-anchors>
|
||||||
|
<certificates src="system" />
|
||||||
|
<certificates src="user" />
|
||||||
|
</trust-anchors>
|
||||||
|
</domain-config>
|
||||||
|
</network-security-config>
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# Backend FCM — 전략 시그널 → Android 푸시
|
||||||
|
|
||||||
|
## 흐름
|
||||||
|
|
||||||
|
```
|
||||||
|
BarBuilder / LiveStrategyScheduler
|
||||||
|
→ TradeSignalService.save(userId, deviceId, …)
|
||||||
|
→ FcmPushService.sendTradeSignalIfEnabled(…)
|
||||||
|
→ Firebase Cloud Messaging → Android 앱
|
||||||
|
```
|
||||||
|
|
||||||
|
## 발송 조건 (모두 필요)
|
||||||
|
|
||||||
|
1. `firebase.enabled=true` + `firebase-service-account.json` 유효
|
||||||
|
2. 해당 사용자/기기 `gc_app_settings.fcm_push_enabled = true`
|
||||||
|
3. `gc_fcm_token` 에 활성 토큰 등록 (앱 로그인 후 자동 등록)
|
||||||
|
4. 로그인 사용자: `userId` 기준 토큰 조회 (모바일·웹 deviceId 달라도 OK)
|
||||||
|
|
||||||
|
## exdev 서버 설정
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# .env 또는 docker-compose 환경
|
||||||
|
FIREBASE_ENABLED=true
|
||||||
|
FIREBASE_SERVICE_ACCOUNT=/app/firebase-service-account.json
|
||||||
|
```
|
||||||
|
|
||||||
|
`docker-compose.yml` 볼륨 (주석 해제):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- ${FIREBASE_SERVICE_ACCOUNT_PATH}:/app/firebase-service-account.json:ro
|
||||||
|
```
|
||||||
|
|
||||||
|
goldenApp과 동일 프로젝트 **goldenanalysisapp** 서비스 계정 JSON 사용.
|
||||||
|
|
||||||
|
## 확인
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://exdev.co.kr/api/fcm/status
|
||||||
|
# {"available":true}
|
||||||
|
|
||||||
|
# 앱에서 FCM 푸시 ON 후
|
||||||
|
curl -X POST http://exdev.co.kr/api/fcm/test \
|
||||||
|
-H "x-user-id: 1" \
|
||||||
|
-H "x-device-id: <앱 device id>"
|
||||||
|
```
|
||||||
|
|
||||||
|
로그: `[FCM] trade_signal sent N/M tokens market=KRW-BTC BUY`
|
||||||
@@ -5,6 +5,10 @@ public enum VerificationIssueStage {
|
|||||||
REGISTERED,
|
REGISTERED,
|
||||||
IN_FIX,
|
IN_FIX,
|
||||||
FIX_COMPLETE,
|
FIX_COMPLETE,
|
||||||
|
/** 확인 요청 — 검증 담당자 확인 대기 */
|
||||||
|
CONFIRM_REQUEST,
|
||||||
|
/** 재검증 요청 — 수정 후 재검증 필요 */
|
||||||
|
RE_VERIFY_REQUEST,
|
||||||
RE_REGISTERED,
|
RE_REGISTERED,
|
||||||
COMPLETE
|
COMPLETE
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,5 +14,7 @@ public interface GcFcmTokenRepository extends JpaRepository<GcFcmToken, Long> {
|
|||||||
|
|
||||||
List<GcFcmToken> findByDeviceIdAndActiveTrue(String deviceId);
|
List<GcFcmToken> findByDeviceIdAndActiveTrue(String deviceId);
|
||||||
|
|
||||||
|
List<GcFcmToken> findByUserIdAndActiveTrue(Long userId);
|
||||||
|
|
||||||
void deleteByDeviceId(String deviceId);
|
void deleteByDeviceId(String deviceId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,13 @@ package com.goldenchart.service;
|
|||||||
import com.goldenchart.entity.GcFcmToken;
|
import com.goldenchart.entity.GcFcmToken;
|
||||||
import com.goldenchart.repository.GcFcmTokenRepository;
|
import com.goldenchart.repository.GcFcmTokenRepository;
|
||||||
import com.google.firebase.FirebaseApp;
|
import com.google.firebase.FirebaseApp;
|
||||||
import com.google.firebase.messaging.*;
|
import com.google.firebase.messaging.AndroidConfig;
|
||||||
|
import com.google.firebase.messaging.AndroidNotification;
|
||||||
|
import com.google.firebase.messaging.FirebaseMessaging;
|
||||||
|
import com.google.firebase.messaging.FirebaseMessagingException;
|
||||||
|
import com.google.firebase.messaging.Message;
|
||||||
|
import com.google.firebase.messaging.MessagingErrorCode;
|
||||||
|
import com.google.firebase.messaging.Notification;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
@@ -19,6 +25,9 @@ import java.util.Optional;
|
|||||||
@Slf4j
|
@Slf4j
|
||||||
public class FcmPushService {
|
public class FcmPushService {
|
||||||
|
|
||||||
|
/** Capacitor Android 앱 알림 채널 (app/android strings.xml 과 동일) */
|
||||||
|
private static final String ANDROID_CHANNEL_ID = "goldenchart_trade_signals";
|
||||||
|
|
||||||
private final GcFcmTokenRepository tokenRepo;
|
private final GcFcmTokenRepository tokenRepo;
|
||||||
private final AppSettingsService appSettingsService;
|
private final AppSettingsService appSettingsService;
|
||||||
|
|
||||||
@@ -59,50 +68,89 @@ public class FcmPushService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 매매 시그널 FCM — deviceId 일치 토큰 + fcm_push_enabled 설정 시.
|
* 전략 조건 일치(매매 시그널) 시 FCM 푸시 — fcm_push_enabled + 등록된 토큰 대상.
|
||||||
*/
|
*/
|
||||||
public void sendTradeSignalIfEnabled(String deviceId, Long userId, String market,
|
public void sendTradeSignalIfEnabled(String deviceId, Long userId, String market,
|
||||||
String signalType, double price,
|
String signalType, double price,
|
||||||
String strategyName, Long signalId) {
|
String strategyName, Long signalId) {
|
||||||
if (!isAvailable() || deviceId == null) return;
|
if (!isAvailable()) {
|
||||||
var app = appSettingsService.getEntity(userId, deviceId);
|
log.debug("[FCM] skip trade_signal {} {} — Firebase unavailable", market, signalType);
|
||||||
if (!Boolean.TRUE.equals(app.getFcmPushEnabled())) return;
|
return;
|
||||||
|
}
|
||||||
List<GcFcmToken> tokens = tokenRepo.findByDeviceIdAndActiveTrue(deviceId);
|
|
||||||
if (tokens.isEmpty()) {
|
var app = appSettingsService.getEntity(userId, deviceId);
|
||||||
tokens = tokenRepo.findByActiveTrue();
|
if (!Boolean.TRUE.equals(app.getFcmPushEnabled())) {
|
||||||
|
log.debug("[FCM] skip trade_signal {} — fcm_push_enabled=false userId={} deviceId={}",
|
||||||
|
market, userId, deviceId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<GcFcmToken> tokens = resolveTokens(userId, deviceId);
|
||||||
|
if (tokens.isEmpty()) {
|
||||||
|
log.info("[FCM] no active tokens for trade_signal market={} userId={} deviceId={}",
|
||||||
|
market, userId, deviceId);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
if (tokens.isEmpty()) return;
|
|
||||||
|
|
||||||
String title = "BUY".equals(signalType) ? "🟢 매수 시그널" : "🔴 매도 시그널";
|
String title = "BUY".equals(signalType) ? "🟢 매수 시그널" : "🔴 매도 시그널";
|
||||||
String body = String.format("%s · ₩%,.0f%s",
|
String body = String.format("%s · ₩%,.0f%s",
|
||||||
market, price,
|
market, price,
|
||||||
strategyName != null ? " · " + strategyName : "");
|
strategyName != null && !strategyName.isBlank() ? " · " + strategyName : "");
|
||||||
|
|
||||||
|
int sent = 0;
|
||||||
for (GcFcmToken t : tokens) {
|
for (GcFcmToken t : tokens) {
|
||||||
|
if (sendTradeSignalMessage(t, title, body, market, signalType, price, signalId)) {
|
||||||
|
sent++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.info("[FCM] trade_signal sent {}/{} tokens market={} {}", sent, tokens.size(), market, signalType);
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<GcFcmToken> resolveTokens(Long userId, String deviceId) {
|
||||||
|
if (userId != null) {
|
||||||
|
List<GcFcmToken> byUser = tokenRepo.findByUserIdAndActiveTrue(userId);
|
||||||
|
if (!byUser.isEmpty()) return byUser;
|
||||||
|
}
|
||||||
|
if (deviceId != null && !deviceId.isBlank()) {
|
||||||
|
return tokenRepo.findByDeviceIdAndActiveTrue(deviceId);
|
||||||
|
}
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean sendTradeSignalMessage(GcFcmToken t, String title, String body,
|
||||||
|
String market, String signalType,
|
||||||
|
double price, Long signalId) {
|
||||||
try {
|
try {
|
||||||
Message msg = Message.builder()
|
Message msg = Message.builder()
|
||||||
.setToken(t.getToken())
|
.setToken(t.getToken())
|
||||||
.setNotification(Notification.builder().setTitle(title).setBody(body).build())
|
.setNotification(Notification.builder().setTitle(title).setBody(body).build())
|
||||||
|
.setAndroidConfig(AndroidConfig.builder()
|
||||||
|
.setPriority(AndroidConfig.Priority.HIGH)
|
||||||
|
.setNotification(AndroidNotification.builder()
|
||||||
|
.setChannelId(ANDROID_CHANNEL_ID)
|
||||||
|
.build())
|
||||||
|
.build())
|
||||||
.putData("type", "trade_signal")
|
.putData("type", "trade_signal")
|
||||||
.putData("signalId", signalId != null ? String.valueOf(signalId) : "")
|
.putData("signalId", signalId != null ? String.valueOf(signalId) : "")
|
||||||
.putData("market", market)
|
.putData("market", market != null ? market : "")
|
||||||
.putData("signalType", signalType)
|
.putData("signalType", signalType != null ? signalType : "")
|
||||||
.putData("price", String.valueOf(price))
|
.putData("price", String.valueOf(price))
|
||||||
.build();
|
.build();
|
||||||
FirebaseMessaging.getInstance().send(msg);
|
FirebaseMessaging.getInstance().send(msg);
|
||||||
t.setLastUsedAt(LocalDateTime.now());
|
t.setLastUsedAt(LocalDateTime.now());
|
||||||
tokenRepo.save(t);
|
tokenRepo.save(t);
|
||||||
|
return true;
|
||||||
} catch (FirebaseMessagingException e) {
|
} catch (FirebaseMessagingException e) {
|
||||||
log.warn("[FCM] send failed: {}", e.getMessagingErrorCode());
|
log.warn("[FCM] send failed tokenId={} code={}", t.getId(), e.getMessagingErrorCode());
|
||||||
if (e.getMessagingErrorCode() == MessagingErrorCode.UNREGISTERED
|
if (e.getMessagingErrorCode() == MessagingErrorCode.UNREGISTERED
|
||||||
|| e.getMessagingErrorCode() == MessagingErrorCode.INVALID_ARGUMENT) {
|
|| e.getMessagingErrorCode() == MessagingErrorCode.INVALID_ARGUMENT) {
|
||||||
t.setActive(false);
|
t.setActive(false);
|
||||||
tokenRepo.save(t);
|
tokenRepo.save(t);
|
||||||
}
|
}
|
||||||
|
return false;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.warn("[FCM] send error: {}", e.getMessage());
|
log.warn("[FCM] send error: {}", e.getMessage());
|
||||||
}
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,9 +159,11 @@ public class FcmPushService {
|
|||||||
log.warn("[FCM] test skipped — Firebase not initialized");
|
log.warn("[FCM] test skipped — Firebase not initialized");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
List<GcFcmToken> tokens = deviceId != null
|
List<GcFcmToken> tokens = resolveTokens(userId, deviceId);
|
||||||
? tokenRepo.findByDeviceIdAndActiveTrue(deviceId)
|
if (tokens.isEmpty()) {
|
||||||
: tokenRepo.findByActiveTrue();
|
log.warn("[FCM] test skipped — no tokens userId={} deviceId={}", userId, deviceId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
for (GcFcmToken t : tokens) {
|
for (GcFcmToken t : tokens) {
|
||||||
try {
|
try {
|
||||||
Message msg = Message.builder()
|
Message msg = Message.builder()
|
||||||
|
|||||||
@@ -44,8 +44,10 @@ public class TradeSignalService {
|
|||||||
.executionType(executionType)
|
.executionType(executionType)
|
||||||
.build();
|
.build();
|
||||||
GcTradeSignal saved = repo.save(entity);
|
GcTradeSignal saved = repo.save(entity);
|
||||||
log.info("[TradeSignal] saved {} {} @ {} market={}", signalType, candleType, price, market);
|
log.info("[TradeSignal] saved {} {} @ {} market={} userId={} deviceId={}",
|
||||||
if (deviceId != null) {
|
signalType, candleType, price, market, userId, deviceId);
|
||||||
|
// 로그인 사용자는 live 설정에 userId만 있고 deviceId가 null — FCM은 userId 기준으로 발송
|
||||||
|
if (userId != null || (deviceId != null && !deviceId.isBlank())) {
|
||||||
fcmPushService.sendTradeSignalIfEnabled(
|
fcmPushService.sendTradeSignalIfEnabled(
|
||||||
deviceId, userId, market, signalType, price, strategyName, saved.getId());
|
deviceId, userId, market, signalType, price, strategyName, saved.getId());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,12 @@ goldenchart:
|
|||||||
allowed-origins:
|
allowed-origins:
|
||||||
- "*"
|
- "*"
|
||||||
|
|
||||||
|
# FCM — exdev: FIREBASE_ENABLED=true + 서비스 계정 JSON 마운트
|
||||||
|
firebase:
|
||||||
|
enabled: ${FIREBASE_ENABLED:false}
|
||||||
|
# Docker 볼륨 마운트 시: FIREBASE_SERVICE_ACCOUNT=/app/firebase-service-account.json
|
||||||
|
service-account-file: ${FIREBASE_SERVICE_ACCOUNT:firebase-service-account.json}
|
||||||
|
|
||||||
logging:
|
logging:
|
||||||
level:
|
level:
|
||||||
com.goldenchart: INFO
|
com.goldenchart: INFO
|
||||||
|
|||||||
+2
@@ -0,0 +1,2 @@
|
|||||||
|
-- 검증 이슈 단계: 확인요청(CONFIRM_REQUEST), 재검증 요청(RE_VERIFY_REQUEST)
|
||||||
|
-- stage 컬럼은 VARCHAR 이므로 기존 행 마이그레이션 불필요. 신규 enum 값만 애플리케이션에서 사용.
|
||||||
@@ -69,11 +69,15 @@ services:
|
|||||||
GC_MOBILE_APP_RELEASE_DIR: /app/data/mobile-releases
|
GC_MOBILE_APP_RELEASE_DIR: /app/data/mobile-releases
|
||||||
GC_MOBILE_APP_VERSION: ${GC_MOBILE_APP_VERSION:-}
|
GC_MOBILE_APP_VERSION: ${GC_MOBILE_APP_VERSION:-}
|
||||||
GC_MOBILE_APP_PUBLIC_BASE_URL: ${GC_MOBILE_APP_PUBLIC_BASE_URL:-http://exdev.co.kr}
|
GC_MOBILE_APP_PUBLIC_BASE_URL: ${GC_MOBILE_APP_PUBLIC_BASE_URL:-http://exdev.co.kr}
|
||||||
|
FIREBASE_ENABLED: ${FIREBASE_ENABLED:-false}
|
||||||
|
FIREBASE_SERVICE_ACCOUNT: ${FIREBASE_SERVICE_ACCOUNT:-firebase-service-account.json}
|
||||||
ports:
|
ports:
|
||||||
- "${BACKEND_PORT:-8080}:8080"
|
- "${BACKEND_PORT:-8080}:8080"
|
||||||
volumes:
|
volumes:
|
||||||
- verification-images:/app/data/verification-images
|
- verification-images:/app/data/verification-images
|
||||||
- ./data/mobile-releases:/app/data/mobile-releases:ro
|
- ./data/mobile-releases:/app/data/mobile-releases:ro
|
||||||
|
# FCM: .env 에 FIREBASE_SERVICE_ACCOUNT_PATH=/path/to/firebase-service-account.json 설정 시 주석 해제
|
||||||
|
# - ${FIREBASE_SERVICE_ACCOUNT_PATH}:/app/firebase-service-account.json:ro
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/api/indicators/list || exit 1"]
|
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/api/indicators/list || exit 1"]
|
||||||
interval: 15s
|
interval: 15s
|
||||||
|
|||||||
@@ -31,6 +31,23 @@ function StageGlyph({ stage }: { stage: VerificationIssueStage }) {
|
|||||||
<path d="M5.5 8l1.8 1.8L10.8 6.2" />
|
<path d="M5.5 8l1.8 1.8L10.8 6.2" />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
case 'CONFIRM_REQUEST':
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||||
|
<circle cx="8" cy="8" r="5.5" />
|
||||||
|
<path d="M8 5.5v3" />
|
||||||
|
<circle cx="8" cy="11" r="0.75" fill="currentColor" stroke="none" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
case 'RE_VERIFY_REQUEST':
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||||
|
<path d="M11 2.5h2.5V5" />
|
||||||
|
<path d="M4.5 13.5H2V11" />
|
||||||
|
<path d="M12.8 5.2A4.5 4.5 0 1 0 6.5 11.5" />
|
||||||
|
<path d="M8 7v2l1.2 1.2" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
case 'RE_REGISTERED':
|
case 'RE_REGISTERED':
|
||||||
return (
|
return (
|
||||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
} from '../utils/tradeAlertSound';
|
} from '../utils/tradeAlertSound';
|
||||||
|
|
||||||
import { getUiPreferences, patchUiPreferences } from '../utils/uiPreferencesDb';
|
import { getUiPreferences, patchUiPreferences } from '../utils/uiPreferencesDb';
|
||||||
|
import { useAppSettings } from '../hooks/useAppSettings';
|
||||||
const MAX_HISTORY = 300;
|
const MAX_HISTORY = 300;
|
||||||
/** 팝업 대기열 최대 건수 (페이지 슬라이딩) */
|
/** 팝업 대기열 최대 건수 (페이지 슬라이딩) */
|
||||||
const MAX_TOAST_QUEUE = 50;
|
const MAX_TOAST_QUEUE = 50;
|
||||||
@@ -60,7 +61,7 @@ interface TradeNotificationContextValue {
|
|||||||
deleteUnreadNotifications: () => Promise<void>;
|
deleteUnreadNotifications: () => Promise<void>;
|
||||||
/** 알림 목록 전체 삭제 */
|
/** 알림 목록 전체 삭제 */
|
||||||
deleteAllNotifications: () => Promise<void>;
|
deleteAllNotifications: () => Promise<void>;
|
||||||
refreshHistory: () => Promise<void>;
|
refreshHistory: (opts?: { serverOnly?: boolean; rawServerList?: boolean }) => Promise<void>;
|
||||||
detailSignal: TradeSignalInfo | null;
|
detailSignal: TradeSignalInfo | null;
|
||||||
/** 상세 모달을 화면 중앙에 표시 (알림 목록에서 열 때) */
|
/** 상세 모달을 화면 중앙에 표시 (알림 목록에서 열 때) */
|
||||||
detailCentered: boolean;
|
detailCentered: boolean;
|
||||||
@@ -126,6 +127,8 @@ interface ProviderProps {
|
|||||||
soundEnabled?: boolean;
|
soundEnabled?: boolean;
|
||||||
/** 알림음 ID */
|
/** 알림음 ID */
|
||||||
soundId?: string;
|
soundId?: string;
|
||||||
|
/** 로그인·게스트 전환 시 app-settings 재로드 키 */
|
||||||
|
settingsSessionKey?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TradeNotificationProvider: React.FC<ProviderProps> = ({
|
export const TradeNotificationProvider: React.FC<ProviderProps> = ({
|
||||||
@@ -133,7 +136,9 @@ export const TradeNotificationProvider: React.FC<ProviderProps> = ({
|
|||||||
popupEnabled,
|
popupEnabled,
|
||||||
soundEnabled = true,
|
soundEnabled = true,
|
||||||
soundId = 'bell',
|
soundId = 'bell',
|
||||||
|
settingsSessionKey = 0,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { isLoaded: settingsLoaded } = useAppSettings(settingsSessionKey);
|
||||||
const alertSoundId = normalizeTradeAlertSoundId(soundId) as TradeAlertSoundId;
|
const alertSoundId = normalizeTradeAlertSoundId(soundId) as TradeAlertSoundId;
|
||||||
const [readIds, setReadIds] = useState<Set<string>>(() => loadReadIds());
|
const [readIds, setReadIds] = useState<Set<string>>(() => loadReadIds());
|
||||||
const readIdsRef = useRef(readIds);
|
const readIdsRef = useRef(readIds);
|
||||||
@@ -150,15 +155,15 @@ export const TradeNotificationProvider: React.FC<ProviderProps> = ({
|
|||||||
[allNotifications],
|
[allNotifications],
|
||||||
);
|
);
|
||||||
|
|
||||||
const refreshHistory = useCallback(async () => {
|
const refreshHistory = useCallback(async (opts?: { serverOnly?: boolean; rawServerList?: boolean }) => {
|
||||||
try {
|
try {
|
||||||
const dtos = await loadTradeSignals();
|
const dtos = await loadTradeSignals();
|
||||||
/** dismiss 직후 비동기 완료 시 state readIds가 늦게 반영되는 것 방지 */
|
/** dismiss 직후 비동기 완료 시 state readIds가 늦게 반영되는 것 방지 */
|
||||||
const read = loadReadIds();
|
const read = opts?.rawServerList ? new Set<string>() : loadReadIds();
|
||||||
const hidden = loadHiddenIds();
|
const hidden = opts?.rawServerList ? new Set<string>() : loadHiddenIds();
|
||||||
const items = dtos
|
const items = dtos
|
||||||
.map(d => dtoToItem(d, read))
|
.map(d => dtoToItem(d, read))
|
||||||
.filter(item => !hidden.has(item.id));
|
.filter(item => opts?.rawServerList || !hidden.has(item.id));
|
||||||
|
|
||||||
let newlyArrived: TradeNotificationItem[] = [];
|
let newlyArrived: TradeNotificationItem[] = [];
|
||||||
|
|
||||||
@@ -167,6 +172,12 @@ export const TradeNotificationProvider: React.FC<ProviderProps> = ({
|
|||||||
item => !item.isRead && !prev.some(p => p.id === item.id),
|
item => !item.isRead && !prev.some(p => p.id === item.id),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (opts?.serverOnly || opts?.rawServerList) {
|
||||||
|
return items
|
||||||
|
.sort((a, b) => b.receivedAt - a.receivedAt)
|
||||||
|
.slice(0, MAX_HISTORY);
|
||||||
|
}
|
||||||
|
|
||||||
const map = new Map<string, TradeNotificationItem>();
|
const map = new Map<string, TradeNotificationItem>();
|
||||||
for (const n of items) {
|
for (const n of items) {
|
||||||
const wasRead = read.has(n.id) || prev.find(p => p.id === n.id)?.isRead;
|
const wasRead = read.has(n.id) || prev.find(p => p.id === n.id)?.isRead;
|
||||||
@@ -199,8 +210,9 @@ export const TradeNotificationProvider: React.FC<ProviderProps> = ({
|
|||||||
}, [popupEnabled, soundEnabled, alertSoundId]);
|
}, [popupEnabled, soundEnabled, alertSoundId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void refreshHistory();
|
if (!settingsLoaded) return;
|
||||||
}, [refreshHistory]);
|
void refreshHistory({ serverOnly: true, rawServerList: true });
|
||||||
|
}, [refreshHistory, settingsLoaded, settingsSessionKey]);
|
||||||
|
|
||||||
const markAsRead = useCallback((id: string) => {
|
const markAsRead = useCallback((id: string) => {
|
||||||
setReadIds(prev => {
|
setReadIds(prev => {
|
||||||
|
|||||||
@@ -98,6 +98,12 @@ export function invalidateAppSettingsCache() {
|
|||||||
_loadPromise = null;
|
_loadPromise = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 서버에서 app-settings 재로드 후 캐시 갱신 (모바일 ↻ 동기화 등) */
|
||||||
|
export function reloadAppSettingsCache(): Promise<AppSettingsDto> {
|
||||||
|
invalidateAppSettingsCache();
|
||||||
|
return ensureLoaded();
|
||||||
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** DB 또는 fallback 기본값 접근 헬퍼 */
|
/** DB 또는 fallback 기본값 접근 헬퍼 */
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* 가상매매 공통 상태·동기화 (웹 VirtualTradingPage · 모바일 앱 공유)
|
* 가상매매 공통 상태·동기화 (웹 VirtualTradingPage · 모바일 앱 공유)
|
||||||
*/
|
*/
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import type { TickerData } from './useMarketTicker';
|
import type { TickerData } from './useMarketTicker';
|
||||||
import {
|
import {
|
||||||
loadLiveStrategySettings,
|
loadLiveStrategySettings,
|
||||||
@@ -47,7 +47,13 @@ import {
|
|||||||
virtualTargetLimitMessage,
|
virtualTargetLimitMessage,
|
||||||
} from '../utils/virtualTargetLimits';
|
} from '../utils/virtualTargetLimits';
|
||||||
import { resolveVirtualTargetNames } from '../utils/virtualTargetNames';
|
import { resolveVirtualTargetNames } from '../utils/virtualTargetNames';
|
||||||
import { useAppSettings, resolveAppDefaults } from './useAppSettings';
|
import {
|
||||||
|
useAppSettings,
|
||||||
|
resolveAppDefaults,
|
||||||
|
subscribeAppSettings,
|
||||||
|
reloadAppSettingsCache,
|
||||||
|
} from './useAppSettings';
|
||||||
|
import { hydrateVirtualTargetsIfEmpty } from '../utils/virtualTargetsHydrate';
|
||||||
|
|
||||||
export interface UseVirtualTradingCoreOptions {
|
export interface UseVirtualTradingCoreOptions {
|
||||||
defaultMarket?: string;
|
defaultMarket?: string;
|
||||||
@@ -71,7 +77,7 @@ export function useVirtualTradingCore(options: UseVirtualTradingCoreOptions = {}
|
|||||||
settingsSessionKey = 0,
|
settingsSessionKey = 0,
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
const { settings: appSettings } = useAppSettings(settingsSessionKey);
|
const { settings: appSettings, isLoaded: settingsLoaded } = useAppSettings(settingsSessionKey);
|
||||||
const appChartDefaults = resolveAppDefaults(appSettings);
|
const appChartDefaults = resolveAppDefaults(appSettings);
|
||||||
const paperTradingEnabled = paperTradingEnabledProp ?? appChartDefaults.paperTradingEnabled;
|
const paperTradingEnabled = paperTradingEnabledProp ?? appChartDefaults.paperTradingEnabled;
|
||||||
const paperAutoTradeEnabled = paperAutoTradeEnabledProp ?? appChartDefaults.paperAutoTradeEnabled;
|
const paperAutoTradeEnabled = paperAutoTradeEnabledProp ?? appChartDefaults.paperAutoTradeEnabled;
|
||||||
@@ -86,20 +92,53 @@ export function useVirtualTradingCore(options: UseVirtualTradingCoreOptions = {}
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [selectedMarket, setSelectedMarket] = useState(defaultMarket);
|
const [selectedMarket, setSelectedMarket] = useState(defaultMarket);
|
||||||
const [viewMode, setViewMode] = useState<VirtualCardViewMode>(() => loadVirtualCardViewMode());
|
const [viewMode, setViewMode] = useState<VirtualCardViewMode>(() => loadVirtualCardViewMode());
|
||||||
|
/** 서버 ui_preferences 반영 전 빈 목록을 DB에 저장하지 않음 */
|
||||||
|
const preferencesHydratedRef = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
const reloadFromUiPreferences = useCallback(() => {
|
||||||
setLoading(true);
|
|
||||||
setTargets(loadVirtualTargets());
|
setTargets(loadVirtualTargets());
|
||||||
setSession(loadVirtualSession());
|
setSession(loadVirtualSession());
|
||||||
void Promise.all([
|
}, []);
|
||||||
loadStrategies().then(setStrategies).catch(() => setStrategies([])),
|
|
||||||
loadPaperSummary().then(setSummary),
|
useEffect(() => {
|
||||||
loadPaperTrades().then(setTrades),
|
if (!settingsLoaded) return;
|
||||||
]).finally(() => setLoading(false));
|
let cancelled = false;
|
||||||
}, [settingsSessionKey]);
|
preferencesHydratedRef.current = false;
|
||||||
|
setLoading(true);
|
||||||
|
void (async () => {
|
||||||
|
reloadFromUiPreferences();
|
||||||
|
let nextTargets = loadVirtualTargets();
|
||||||
|
if (nextTargets.length === 0) {
|
||||||
|
nextTargets = await hydrateVirtualTargetsIfEmpty();
|
||||||
|
if (!cancelled && nextTargets.length > 0) {
|
||||||
|
setTargets(nextTargets);
|
||||||
|
saveVirtualTargets(nextTargets);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!cancelled) {
|
||||||
|
preferencesHydratedRef.current = true;
|
||||||
|
}
|
||||||
|
await Promise.all([
|
||||||
|
loadStrategies().then(s => { if (!cancelled) setStrategies(s); }).catch(() => { if (!cancelled) setStrategies([]); }),
|
||||||
|
loadPaperSummary().then(s => { if (!cancelled) setSummary(s); }),
|
||||||
|
loadPaperTrades().then(t => { if (!cancelled) setTrades(t); }),
|
||||||
|
]);
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
})();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [settingsSessionKey, settingsLoaded, reloadFromUiPreferences]);
|
||||||
|
|
||||||
|
/** DB ui_preferences 반영(웹·모바일 동일 계정) */
|
||||||
|
useEffect(() => {
|
||||||
|
if (!settingsLoaded) return;
|
||||||
|
return subscribeAppSettings(() => {
|
||||||
|
reloadFromUiPreferences();
|
||||||
|
});
|
||||||
|
}, [settingsLoaded, reloadFromUiPreferences]);
|
||||||
|
|
||||||
/** DB is_pinned → uiPreferences.virtual.targets 동기화 */
|
/** DB is_pinned → uiPreferences.virtual.targets 동기화 */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!settingsLoaded) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const initial = loadVirtualTargets();
|
const initial = loadVirtualTargets();
|
||||||
if (initial.length === 0) return;
|
if (initial.length === 0) return;
|
||||||
@@ -119,10 +158,16 @@ export function useVirtualTradingCore(options: UseVirtualTradingCoreOptions = {}
|
|||||||
if (!cancelled) setTargets(merged);
|
if (!cancelled) setTargets(merged);
|
||||||
})();
|
})();
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, []);
|
}, [settingsLoaded]);
|
||||||
|
|
||||||
useEffect(() => { saveVirtualTargets(targets); }, [targets]);
|
useEffect(() => {
|
||||||
useEffect(() => { saveVirtualSession(session); }, [session]);
|
if (!preferencesHydratedRef.current) return;
|
||||||
|
saveVirtualTargets(targets);
|
||||||
|
}, [targets]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!preferencesHydratedRef.current) return;
|
||||||
|
saveVirtualSession(session);
|
||||||
|
}, [session]);
|
||||||
useEffect(() => { saveVirtualCardViewMode(viewMode); }, [viewMode]);
|
useEffect(() => { saveVirtualCardViewMode(viewMode); }, [viewMode]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -365,11 +410,26 @@ export function useVirtualTradingCore(options: UseVirtualTradingCoreOptions = {}
|
|||||||
void resyncRunningSession(next);
|
void resyncRunningSession(next);
|
||||||
}, [session, resyncRunningSession]);
|
}, [session, resyncRunningSession]);
|
||||||
|
|
||||||
/** 서버·DB에서 targets 다시 로드 (탭 포커스 등) */
|
/** 서버·DB에서 targets 다시 로드 (↻ 동기화) */
|
||||||
const reloadTargetsFromStorage = useCallback(() => {
|
const reloadTargetsFromStorage = useCallback(() => {
|
||||||
setTargets(loadVirtualTargets());
|
setLoading(true);
|
||||||
setSession(loadVirtualSession());
|
preferencesHydratedRef.current = false;
|
||||||
}, []);
|
void reloadAppSettingsCache()
|
||||||
|
.then(async () => {
|
||||||
|
reloadFromUiPreferences();
|
||||||
|
let next = loadVirtualTargets();
|
||||||
|
if (next.length === 0) {
|
||||||
|
next = await hydrateVirtualTargetsIfEmpty();
|
||||||
|
if (next.length > 0) {
|
||||||
|
setTargets(next);
|
||||||
|
saveVirtualTargets(next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
preferencesHydratedRef.current = true;
|
||||||
|
})
|
||||||
|
.catch(() => reloadFromUiPreferences())
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [reloadFromUiPreferences]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
targets,
|
targets,
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ export type VerificationIssueStage =
|
|||||||
| 'REGISTERED'
|
| 'REGISTERED'
|
||||||
| 'IN_FIX'
|
| 'IN_FIX'
|
||||||
| 'FIX_COMPLETE'
|
| 'FIX_COMPLETE'
|
||||||
|
| 'CONFIRM_REQUEST'
|
||||||
|
| 'RE_VERIFY_REQUEST'
|
||||||
| 'RE_REGISTERED'
|
| 'RE_REGISTERED'
|
||||||
| 'COMPLETE';
|
| 'COMPLETE';
|
||||||
|
|
||||||
@@ -16,6 +18,8 @@ export const VERIFICATION_ISSUE_STAGES: VerificationIssueStageMeta[] = [
|
|||||||
{ value: 'REGISTERED', label: '등록', color: '#42a5f5' },
|
{ value: 'REGISTERED', label: '등록', color: '#42a5f5' },
|
||||||
{ value: 'IN_FIX', label: '수정중', color: '#ffa726' },
|
{ value: 'IN_FIX', label: '수정중', color: '#ffa726' },
|
||||||
{ value: 'FIX_COMPLETE', label: '수정완료', color: '#66bb6a' },
|
{ value: 'FIX_COMPLETE', label: '수정완료', color: '#66bb6a' },
|
||||||
|
{ value: 'CONFIRM_REQUEST', label: '확인요청', color: '#29b6f6' },
|
||||||
|
{ value: 'RE_VERIFY_REQUEST', label: '재검증 요청', color: '#ec407a' },
|
||||||
{ value: 'RE_REGISTERED', label: '재등록', color: '#ab47bc' },
|
{ value: 'RE_REGISTERED', label: '재등록', color: '#ab47bc' },
|
||||||
{ value: 'COMPLETE', label: '완료', color: '#78909c' },
|
{ value: 'COMPLETE', label: '완료', color: '#78909c' },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
/**
|
||||||
|
* ui_preferences.virtual.targets 가 비었을 때 서버·레거시에서 투자대상 복구
|
||||||
|
*/
|
||||||
|
import { loadActiveLiveStrategySettings, loadWatchlist } from './backendApi';
|
||||||
|
import {
|
||||||
|
loadVirtualSession,
|
||||||
|
loadVirtualTargets,
|
||||||
|
type VirtualTargetItem,
|
||||||
|
} from './virtualTradingStorage';
|
||||||
|
import { resolveVirtualTargetNames } from './virtualTargetNames';
|
||||||
|
|
||||||
|
function readLegacyTargets(): VirtualTargetItem[] {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem('gc_virtual_targets_v1');
|
||||||
|
if (!raw) return [];
|
||||||
|
const parsed = JSON.parse(raw) as VirtualTargetItem[];
|
||||||
|
return Array.isArray(parsed) ? parsed : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromLiveSettings(
|
||||||
|
list: Awaited<ReturnType<typeof loadActiveLiveStrategySettings>>,
|
||||||
|
globalStrategyId: number | null,
|
||||||
|
): VirtualTargetItem[] {
|
||||||
|
return list.map(s => {
|
||||||
|
const { koreanName, englishName } = resolveVirtualTargetNames(s.market);
|
||||||
|
return {
|
||||||
|
market: s.market,
|
||||||
|
strategyId: s.strategyId ?? globalStrategyId,
|
||||||
|
candleType: s.candleType,
|
||||||
|
koreanName,
|
||||||
|
englishName,
|
||||||
|
pinned: !!s.isPinned,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromWatchlist(
|
||||||
|
list: Awaited<ReturnType<typeof loadWatchlist>>,
|
||||||
|
globalStrategyId: number | null,
|
||||||
|
): VirtualTargetItem[] {
|
||||||
|
return list.map(w => {
|
||||||
|
const market = w.symbol;
|
||||||
|
const { koreanName, englishName } = resolveVirtualTargetNames(
|
||||||
|
market,
|
||||||
|
w.koreanName,
|
||||||
|
w.englishName,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
market,
|
||||||
|
strategyId: globalStrategyId,
|
||||||
|
koreanName,
|
||||||
|
englishName,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** DB·로컬 비어 있을 때 서버 실시간 설정·관심종목에서 목록 구성 */
|
||||||
|
export async function hydrateVirtualTargetsIfEmpty(): Promise<VirtualTargetItem[]> {
|
||||||
|
const existing = loadVirtualTargets();
|
||||||
|
if (existing.length > 0) return existing;
|
||||||
|
|
||||||
|
const legacy = readLegacyTargets();
|
||||||
|
if (legacy.length > 0) return legacy;
|
||||||
|
|
||||||
|
const session = loadVirtualSession();
|
||||||
|
const globalId = session.globalStrategyId;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const active = await loadActiveLiveStrategySettings();
|
||||||
|
if (active.length > 0) return fromLiveSettings(active, globalId);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[virtualTargets] active live settings load failed', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const watchlist = await loadWatchlist();
|
||||||
|
if (watchlist.length > 0) return fromWatchlist(watchlist, globalId);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[virtualTargets] watchlist load failed', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
@@ -1,252 +0,0 @@
|
|||||||
# 이격도 기준선 설정 문제 수정 완료
|
|
||||||
|
|
||||||
## 🔍 문제점
|
|
||||||
|
|
||||||
이격도 보조지표 설정에서 기준선(100) ON/OFF 및 색상, 선 유형, 굵기 변경 시 차트에 제대로 반영되지 않는 문제
|
|
||||||
|
|
||||||
## 🐛 발견된 원인
|
|
||||||
|
|
||||||
### 1. **CandlestickChart.tsx - 기준선이 초단기 시리즈에만 고정**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// ❌ 문제: ultraShortSeries에만 기준선 추가
|
|
||||||
if (indicatorSettings && indicatorSettings.disparityBaseLineEnabled) {
|
|
||||||
ultraShortSeries.createPriceLine({
|
|
||||||
price: 100,
|
|
||||||
color: colorSettings?.disparityBaseLineColor || '#9e9e9e',
|
|
||||||
// ...
|
|
||||||
});
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**문제점:**
|
|
||||||
- 초단기 이격도가 비활성화되면 기준선도 함께 사라짐
|
|
||||||
- 다른 이격도 선(단기, 중기, 장기)만 활성화되어도 기준선이 표시되지 않음
|
|
||||||
|
|
||||||
### 2. **설정 UI 누락**
|
|
||||||
|
|
||||||
`IndicatorSettingsTab.tsx`에 이격도 기준선 설정 UI가 없음:
|
|
||||||
- ✅ Williams %R, TRIX 등 다른 지표는 기준선 설정 UI 존재
|
|
||||||
- ❌ 이격도는 기준선 설정 UI 없음
|
|
||||||
|
|
||||||
## ✅ 수정 사항
|
|
||||||
|
|
||||||
### 1. **CandlestickChart.tsx - 기준선을 visible 시리즈에 동적 할당**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// ✅ 수정: visible인 첫 번째 시리즈에 기준선 추가
|
|
||||||
if (indicatorSettings && (indicatorSettings.disparityBaseLineEnabled ?? true)) {
|
|
||||||
// visible인 시리즈 찾기 (우선순위: 초단기 > 단기 > 중기 > 장기)
|
|
||||||
const visibleSeries =
|
|
||||||
(indicatorSettings.disparityUltraShortEnabled ?? true) ? ultraShortSeries :
|
|
||||||
(indicatorSettings.disparityShortEnabled ?? true) ? shortSeries :
|
|
||||||
(indicatorSettings.disparityMidEnabled ?? true) ? midSeries :
|
|
||||||
longSeries; // 기본값: 장기 (마지막 시리즈)
|
|
||||||
|
|
||||||
visibleSeries.createPriceLine({
|
|
||||||
price: 100,
|
|
||||||
color: colorSettings?.disparityBaseLineColor || '#999999',
|
|
||||||
lineWidth: (colorSettings?.disparityBaseLineWidth || 1) as any,
|
|
||||||
lineStyle: colorSettings?.disparityBaseLineStyle ? getLineStyle(colorSettings.disparityBaseLineStyle) : 2,
|
|
||||||
axisLabelVisible: false,
|
|
||||||
title: ''
|
|
||||||
});
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**개선 효과:**
|
|
||||||
- ✅ 초단기 OFF 시에도 기준선 유지
|
|
||||||
- ✅ 어떤 이격도 선이 활성화되어 있어도 기준선 표시
|
|
||||||
- ✅ 우선순위에 따라 적절한 시리즈에 기준선 추가
|
|
||||||
|
|
||||||
### 2. **IndicatorSettingsTab.tsx - 기준선 설정 UI 추가**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
{/* 기준선 설정 */}
|
|
||||||
<Typography variant="subtitle2" sx={{ mt: 3, mb: 2, fontWeight: 'bold' }}>
|
|
||||||
기준선 스타일 설정
|
|
||||||
</Typography>
|
|
||||||
<Grid container spacing={2}>
|
|
||||||
<Grid item xs={12} md={12}>
|
|
||||||
<Box sx={{ p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 1 }}>
|
|
||||||
<FormControlLabel
|
|
||||||
control={
|
|
||||||
<Switch
|
|
||||||
checked={settings.disparityBaseLineEnabled ?? true}
|
|
||||||
onChange={(e) => handleSettingChange({ disparityBaseLineEnabled: e.target.checked })}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label="기준선 표시 (100)"
|
|
||||||
sx={{ mb: 2 }}
|
|
||||||
/>
|
|
||||||
<Grid container spacing={2}>
|
|
||||||
<Grid item xs={12} md={4}>
|
|
||||||
<TextField
|
|
||||||
fullWidth
|
|
||||||
label="색상"
|
|
||||||
type="color"
|
|
||||||
value={colors.disparityBaseLineColor}
|
|
||||||
onChange={(e) => updateColors({ disparityBaseLineColor: e.target.value })}
|
|
||||||
size="small"
|
|
||||||
disabled={!settings.disparityBaseLineEnabled}
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
<Grid item xs={12} md={4}>
|
|
||||||
<TextField
|
|
||||||
fullWidth
|
|
||||||
label="굵기"
|
|
||||||
type="number"
|
|
||||||
value={colors.disparityBaseLineWidth}
|
|
||||||
onChange={(e) => updateColors({ disparityBaseLineWidth: Number(e.target.value) })}
|
|
||||||
inputProps={{ min: 0.5, max: 5, step: 0.5 }}
|
|
||||||
size="small"
|
|
||||||
disabled={!settings.disparityBaseLineEnabled}
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
<Grid item xs={12} md={4}>
|
|
||||||
<FormControl fullWidth size="small" disabled={!settings.disparityBaseLineEnabled}>
|
|
||||||
<InputLabel>선 스타일</InputLabel>
|
|
||||||
<Select
|
|
||||||
value={colors.disparityBaseLineStyle}
|
|
||||||
label="선 스타일"
|
|
||||||
onChange={(e) => updateColors({ disparityBaseLineStyle: e.target.value as 'solid' | 'dashed' | 'dotted' })}
|
|
||||||
>
|
|
||||||
<MenuItem value="solid">실선</MenuItem>
|
|
||||||
<MenuItem value="dashed">파선</MenuItem>
|
|
||||||
<MenuItem value="dotted">점선</MenuItem>
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
</Grid>
|
|
||||||
</Grid>
|
|
||||||
</Box>
|
|
||||||
</Grid>
|
|
||||||
</Grid>
|
|
||||||
```
|
|
||||||
|
|
||||||
**추가된 기능:**
|
|
||||||
- ✅ 기준선 ON/OFF 스위치
|
|
||||||
- ✅ 색상 선택 (color picker)
|
|
||||||
- ✅ 굵기 조절 (0.5 ~ 5)
|
|
||||||
- ✅ 선 스타일 선택 (실선/파선/점선)
|
|
||||||
- ✅ 기준선 OFF 시 설정 비활성화
|
|
||||||
|
|
||||||
### 3. **필수 import 추가**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import {
|
|
||||||
// ... 기존 imports
|
|
||||||
Switch,
|
|
||||||
FormControlLabel,
|
|
||||||
FormControl,
|
|
||||||
Select,
|
|
||||||
MenuItem,
|
|
||||||
InputLabel,
|
|
||||||
} from '@mui/material';
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🔄 설정 변경 흐름
|
|
||||||
|
|
||||||
### 1. **사용자가 설정 변경**
|
|
||||||
```
|
|
||||||
보조지표 설정 > 이격도 > 기준선 스타일 설정
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. **Context 업데이트**
|
|
||||||
```typescript
|
|
||||||
// IndicatorSettingsContext
|
|
||||||
disparityBaseLineEnabled: true/false
|
|
||||||
|
|
||||||
// ChartColorContext
|
|
||||||
disparityBaseLineColor: string
|
|
||||||
disparityBaseLineWidth: number
|
|
||||||
disparityBaseLineStyle: 'solid' | 'dashed' | 'dotted'
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. **차트 자동 재렌더링**
|
|
||||||
```typescript
|
|
||||||
// CandlestickChart.tsx
|
|
||||||
useEffect(() => {
|
|
||||||
// 서브차트 재생성
|
|
||||||
}, [subPanels, data?.length, indicatorData?.length, themeMode,
|
|
||||||
indicatorSettings, colorSettings]); // ✅ 의존성 배열에 포함
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. **기준선 적용**
|
|
||||||
```typescript
|
|
||||||
// visible인 시리즈에 기준선 추가
|
|
||||||
visibleSeries.createPriceLine({
|
|
||||||
price: 100,
|
|
||||||
color: colorSettings?.disparityBaseLineColor,
|
|
||||||
lineWidth: colorSettings?.disparityBaseLineWidth,
|
|
||||||
lineStyle: getLineStyle(colorSettings?.disparityBaseLineStyle),
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
## ✅ 테스트 시나리오
|
|
||||||
|
|
||||||
### 1. **기준선 ON/OFF**
|
|
||||||
- [x] 기준선 ON → 100 기준선 표시
|
|
||||||
- [x] 기준선 OFF → 100 기준선 숨김
|
|
||||||
|
|
||||||
### 2. **색상 변경**
|
|
||||||
- [x] 색상 변경 → 기준선 색상 즉시 반영
|
|
||||||
|
|
||||||
### 3. **굵기 변경**
|
|
||||||
- [x] 굵기 0.5 ~ 5 조절 → 기준선 굵기 즉시 반영
|
|
||||||
|
|
||||||
### 4. **선 스타일 변경**
|
|
||||||
- [x] 실선 → 기준선 실선으로 표시
|
|
||||||
- [x] 파선 → 기준선 파선으로 표시
|
|
||||||
- [x] 점선 → 기준선 점선으로 표시
|
|
||||||
|
|
||||||
### 5. **이격도 선 ON/OFF 조합**
|
|
||||||
- [x] 초단기만 ON → 기준선 표시
|
|
||||||
- [x] 초단기 OFF, 단기 ON → 기준선 표시 (단기 시리즈에)
|
|
||||||
- [x] 초단기/단기 OFF, 중기 ON → 기준선 표시 (중기 시리즈에)
|
|
||||||
- [x] 초단기/단기/중기 OFF, 장기만 ON → 기준선 표시 (장기 시리즈에)
|
|
||||||
|
|
||||||
## 📊 기본값
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// IndicatorSettingsContext
|
|
||||||
disparityBaseLineEnabled: true
|
|
||||||
|
|
||||||
// ChartColorContext
|
|
||||||
disparityBaseLineColor: '#999999'
|
|
||||||
disparityBaseLineWidth: 1
|
|
||||||
disparityBaseLineStyle: 'solid'
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🎯 결과
|
|
||||||
|
|
||||||
**이제 이격도 보조지표의 기준선 설정이 완벽하게 동작합니다!**
|
|
||||||
|
|
||||||
✅ 기준선 ON/OFF 정상 작동
|
|
||||||
✅ 색상 변경 즉시 반영
|
|
||||||
✅ 굵기 변경 즉시 반영
|
|
||||||
✅ 선 스타일 변경 즉시 반영
|
|
||||||
✅ 이격도 선 ON/OFF와 무관하게 기준선 유지
|
|
||||||
✅ 설정 UI 추가로 사용자 편의성 향상
|
|
||||||
|
|
||||||
## 🚀 배포
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /Users/macbook/dev/goldenAnalysis
|
|
||||||
docker compose down
|
|
||||||
docker compose up --build
|
|
||||||
```
|
|
||||||
|
|
||||||
또는
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd frontend
|
|
||||||
npm run build
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**수정 완료일:** 2026-02-01
|
|
||||||
**수정 파일:**
|
|
||||||
- `frontend/src/components/CandlestickChart.tsx`
|
|
||||||
- `frontend/src/components/settings/IndicatorSettingsTab.tsx`
|
|
||||||
|
|
||||||
@@ -1,281 +0,0 @@
|
|||||||
# 이격도 선 ON/OFF 설정 문제 수정 완료
|
|
||||||
|
|
||||||
## 🔍 문제점
|
|
||||||
|
|
||||||
이격도 설정에서 초단기, 단기, 중기, 장기 중 항목을 OFF 하고 적용해도 차트에는 여전히 모든 그래프가 표시되는 문제
|
|
||||||
|
|
||||||
## 🐛 발견된 원인
|
|
||||||
|
|
||||||
### 1. **CandlestickChart.tsx - visible 속성 누락**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// ❌ 문제: visible 속성이 없어서 설정과 무관하게 모든 선 표시
|
|
||||||
const ultraShortSeries = subChart.addLineSeries({
|
|
||||||
color: colorSettings?.disparityUltraShortColor || '#FF6B35',
|
|
||||||
lineWidth: (colorSettings?.disparityUltraShortWidth || 1.5) as any,
|
|
||||||
lineStyle: colorSettings?.disparityUltraShortLineStyle ? getLineStyle(colorSettings.disparityUltraShortLineStyle) : 0,
|
|
||||||
// visible 속성 없음!
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. **IndicatorSettingsTab.tsx - ON/OFF 스위치 UI 누락**
|
|
||||||
|
|
||||||
- 이격도 각 선(초단기, 단기, 중기, 장기)의 ON/OFF 스위치가 없음
|
|
||||||
- 사용자가 특정 선만 표시하고 싶어도 설정할 방법이 없음
|
|
||||||
|
|
||||||
## ✅ 수정 사항
|
|
||||||
|
|
||||||
### 1. **CandlestickChart.tsx - visible 속성 추가**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// ✅ 수정: visible 속성 추가하여 설정에 따라 표시/숨김
|
|
||||||
// 초단기 이격도 (5일)
|
|
||||||
const ultraShortSeries = subChart.addLineSeries({
|
|
||||||
color: colorSettings?.disparityUltraShortColor || '#FF6B35',
|
|
||||||
lineWidth: (colorSettings?.disparityUltraShortWidth || 1.5) as any,
|
|
||||||
lineStyle: colorSettings?.disparityUltraShortLineStyle ? getLineStyle(colorSettings.disparityUltraShortLineStyle) : 0,
|
|
||||||
visible: indicatorSettings?.disparityUltraShortEnabled ?? true, // ✅ 추가
|
|
||||||
});
|
|
||||||
|
|
||||||
// 단기 이격도 (20일)
|
|
||||||
const shortSeries = subChart.addLineSeries({
|
|
||||||
color: colorSettings?.disparityShortColor || '#4CAF50',
|
|
||||||
lineWidth: (colorSettings?.disparityShortWidth || 2) as any,
|
|
||||||
lineStyle: colorSettings?.disparityShortLineStyle ? getLineStyle(colorSettings.disparityShortLineStyle) : 0,
|
|
||||||
visible: indicatorSettings?.disparityShortEnabled ?? true, // ✅ 추가
|
|
||||||
});
|
|
||||||
|
|
||||||
// 중기 이격도 (60일)
|
|
||||||
const midSeries = subChart.addLineSeries({
|
|
||||||
color: colorSettings?.disparityMidColor || '#2196F3',
|
|
||||||
lineWidth: (colorSettings?.disparityMidWidth || 2) as any,
|
|
||||||
lineStyle: colorSettings?.disparityMidLineStyle ? getLineStyle(colorSettings.disparityMidLineStyle) : 0,
|
|
||||||
visible: indicatorSettings?.disparityMidEnabled ?? true, // ✅ 추가
|
|
||||||
});
|
|
||||||
|
|
||||||
// 장기 이격도 (120일)
|
|
||||||
const longSeries = subChart.addLineSeries({
|
|
||||||
color: colorSettings?.disparityLongColor || '#9C27B0',
|
|
||||||
lineWidth: (colorSettings?.disparityLongWidth || 2) as any,
|
|
||||||
lineStyle: colorSettings?.disparityLongLineStyle ? getLineStyle(colorSettings.disparityLongLineStyle) : 0,
|
|
||||||
visible: indicatorSettings?.disparityLongEnabled ?? true, // ✅ 추가
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. **IndicatorSettingsTab.tsx - ON/OFF 스위치 UI 추가**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
{/* 각 선 활성화 스위치 */}
|
|
||||||
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 'bold' }}>
|
|
||||||
선 표시 설정
|
|
||||||
</Typography>
|
|
||||||
<Grid container spacing={2} sx={{ mb: 3 }}>
|
|
||||||
<Grid item xs={6} md={3}>
|
|
||||||
<FormControlLabel
|
|
||||||
control={
|
|
||||||
<Switch
|
|
||||||
checked={settings.disparityUltraShortEnabled ?? true}
|
|
||||||
onChange={(e) => handleSettingChange({ disparityUltraShortEnabled: e.target.checked })}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label="초단기 (5일)"
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
<Grid item xs={6} md={3}>
|
|
||||||
<FormControlLabel
|
|
||||||
control={
|
|
||||||
<Switch
|
|
||||||
checked={settings.disparityShortEnabled ?? true}
|
|
||||||
onChange={(e) => handleSettingChange({ disparityShortEnabled: e.target.checked })}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label="단기 (20일)"
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
<Grid item xs={6} md={3}>
|
|
||||||
<FormControlLabel
|
|
||||||
control={
|
|
||||||
<Switch
|
|
||||||
checked={settings.disparityMidEnabled ?? true}
|
|
||||||
onChange={(e) => handleSettingChange({ disparityMidEnabled: e.target.checked })}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label="중기 (60일)"
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
<Grid item xs={6} md={3}>
|
|
||||||
<FormControlLabel
|
|
||||||
control={
|
|
||||||
<Switch
|
|
||||||
checked={settings.disparityLongEnabled ?? true}
|
|
||||||
onChange={(e) => handleSettingChange({ disparityLongEnabled: e.target.checked })}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label="장기 (120일)"
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
<Divider sx={{ my: 2 }} />
|
|
||||||
|
|
||||||
{/* 기간 설정 (비활성화된 선은 입력 비활성화) */}
|
|
||||||
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 'bold' }}>
|
|
||||||
기간 설정
|
|
||||||
</Typography>
|
|
||||||
<Grid container spacing={3}>
|
|
||||||
<Grid item xs={12} md={3}>
|
|
||||||
<TextField
|
|
||||||
fullWidth
|
|
||||||
label="초단기 기간"
|
|
||||||
type="number"
|
|
||||||
value={settings.disparityUltraShortPeriod}
|
|
||||||
onChange={(e) => handleSettingChange({ disparityUltraShortPeriod: Number(e.target.value) })}
|
|
||||||
inputProps={{ min: 1, max: 20 }}
|
|
||||||
helperText="기본값: 5일"
|
|
||||||
disabled={!settings.disparityUltraShortEnabled} // ✅ 비활성화 시 입력 불가
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
{/* 나머지 단기, 중기, 장기도 동일하게 disabled 속성 추가 */}
|
|
||||||
</Grid>
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. **IndicatorChart.tsx - 이미 올바르게 구현됨**
|
|
||||||
|
|
||||||
`IndicatorChart.tsx`는 이미 `visible` 속성이 올바르게 설정되어 있었습니다:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
visible: indicatorSettings.disparityUltraShortEnabled ?? true,
|
|
||||||
visible: indicatorSettings.disparityShortEnabled ?? true,
|
|
||||||
visible: indicatorSettings.disparityMidEnabled ?? true,
|
|
||||||
visible: indicatorSettings.disparityLongEnabled ?? true,
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🔄 설정 변경 흐름
|
|
||||||
|
|
||||||
### 1. **사용자가 설정 변경**
|
|
||||||
```
|
|
||||||
보조지표 설정 > 이격도 > 선 표시 설정
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. **Context 업데이트**
|
|
||||||
```typescript
|
|
||||||
// IndicatorSettingsContext
|
|
||||||
disparityUltraShortEnabled: boolean
|
|
||||||
disparityShortEnabled: boolean
|
|
||||||
disparityMidEnabled: boolean
|
|
||||||
disparityLongEnabled: boolean
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. **차트 자동 재렌더링**
|
|
||||||
```typescript
|
|
||||||
// CandlestickChart.tsx
|
|
||||||
useEffect(() => {
|
|
||||||
// 서브차트 재생성
|
|
||||||
}, [subPanels, data?.length, indicatorData?.length, themeMode,
|
|
||||||
indicatorSettings, colorSettings]); // ✅ indicatorSettings 변경 감지
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. **visible 속성 적용**
|
|
||||||
```typescript
|
|
||||||
// 각 시리즈 생성 시 visible 속성 적용
|
|
||||||
const ultraShortSeries = subChart.addLineSeries({
|
|
||||||
// ...
|
|
||||||
visible: indicatorSettings?.disparityUltraShortEnabled ?? true,
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
## ✅ 테스트 시나리오
|
|
||||||
|
|
||||||
### 1. **모든 선 ON (기본값)**
|
|
||||||
- [x] 초단기, 단기, 중기, 장기 모두 표시
|
|
||||||
|
|
||||||
### 2. **초단기만 ON**
|
|
||||||
- [x] 초단기만 표시
|
|
||||||
- [x] 단기, 중기, 장기 숨김
|
|
||||||
- [x] 기준선은 초단기 시리즈에 표시
|
|
||||||
|
|
||||||
### 3. **초단기 OFF, 단기만 ON**
|
|
||||||
- [x] 단기만 표시
|
|
||||||
- [x] 초단기, 중기, 장기 숨김
|
|
||||||
- [x] 기준선은 단기 시리즈에 표시
|
|
||||||
|
|
||||||
### 4. **중기와 장기만 ON**
|
|
||||||
- [x] 중기와 장기만 표시
|
|
||||||
- [x] 초단기, 단기 숨김
|
|
||||||
- [x] 기준선은 중기 시리즈에 표시
|
|
||||||
|
|
||||||
### 5. **장기만 ON**
|
|
||||||
- [x] 장기만 표시
|
|
||||||
- [x] 초단기, 단기, 중기 숨김
|
|
||||||
- [x] 기준선은 장기 시리즈에 표시
|
|
||||||
|
|
||||||
### 6. **설정 변경 시 즉시 반영**
|
|
||||||
- [x] 스위치 ON/OFF 시 차트 즉시 업데이트
|
|
||||||
- [x] 비활성화된 선의 기간 설정 입력 비활성화
|
|
||||||
|
|
||||||
## 📊 기본값
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// IndicatorSettingsContext
|
|
||||||
disparityUltraShortEnabled: true // 초단기 (5일)
|
|
||||||
disparityShortEnabled: true // 단기 (20일)
|
|
||||||
disparityMidEnabled: true // 중기 (60일)
|
|
||||||
disparityLongEnabled: true // 장기 (120일)
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🎯 결과
|
|
||||||
|
|
||||||
**이제 이격도 설정에서 각 선(초단기, 단기, 중기, 장기)의 ON/OFF가 완벽하게 동작합니다!**
|
|
||||||
|
|
||||||
✅ 초단기 ON/OFF 정상 작동
|
|
||||||
✅ 단기 ON/OFF 정상 작동
|
|
||||||
✅ 중기 ON/OFF 정상 작동
|
|
||||||
✅ 장기 ON/OFF 정상 작동
|
|
||||||
✅ 설정 변경 즉시 차트 반영
|
|
||||||
✅ 비활성화된 선의 기간 설정 입력 비활성화
|
|
||||||
✅ 기준선은 활성화된 선 중 하나에 표시
|
|
||||||
✅ 빌드 성공 (0 에러)
|
|
||||||
|
|
||||||
## 🚀 배포
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /Users/macbook/dev/goldenAnalysis
|
|
||||||
docker compose down
|
|
||||||
docker compose up --build
|
|
||||||
```
|
|
||||||
|
|
||||||
또는
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd frontend
|
|
||||||
npm run build
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**수정 완료일:** 2026-02-01
|
|
||||||
**수정 파일:**
|
|
||||||
- `frontend/src/components/CandlestickChart.tsx` - visible 속성 추가
|
|
||||||
- `frontend/src/components/settings/IndicatorSettingsTab.tsx` - ON/OFF 스위치 UI 추가
|
|
||||||
- `frontend/src/components/IndicatorChart.tsx` - 이미 올바르게 구현됨 (수정 불필요)
|
|
||||||
|
|
||||||
## 📝 참고
|
|
||||||
|
|
||||||
### Context 설정 키
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface IndicatorSettings {
|
|
||||||
// ...
|
|
||||||
disparityUltraShortEnabled: boolean; // 초단기 활성화
|
|
||||||
disparityShortEnabled: boolean; // 단기 활성화
|
|
||||||
disparityMidEnabled: boolean; // 중기 활성화
|
|
||||||
disparityLongEnabled: boolean; // 장기 활성화
|
|
||||||
disparityBaseLineEnabled: boolean; // 기준선 활성화
|
|
||||||
// ...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### localStorage 저장
|
|
||||||
|
|
||||||
설정 변경 시 자동으로 localStorage에 저장되어 다음 접속 시에도 유지됩니다.
|
|
||||||
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
# Build stage
|
|
||||||
FROM node:18-alpine AS build
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# package.json 복사 및 의존성 설치
|
|
||||||
COPY package.json package-lock.json* ./
|
|
||||||
RUN npm install --legacy-peer-deps
|
|
||||||
|
|
||||||
# node로 직접 react-scripts 실행 (권한 문제 회피)
|
|
||||||
ENV CI=false
|
|
||||||
ENV GENERATE_SOURCEMAP=false
|
|
||||||
|
|
||||||
# 캐시 무효화 (소스 변경 시 항상 재빌드)
|
|
||||||
ARG CACHE_BUST=1
|
|
||||||
RUN echo "Cache bust: $CACHE_BUST"
|
|
||||||
# 타임스탬프 기반 캐시 무효화 (매 빌드마다 다른 값)
|
|
||||||
ARG BUILD_TIMESTAMP
|
|
||||||
RUN echo "Build timestamp: $BUILD_TIMESTAMP"
|
|
||||||
|
|
||||||
# 소스 코드 복사
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
# ✅ BUILD_TIME을 빌드 시점으로 자동 설정하여 매번 캐시 무효화
|
|
||||||
ARG BUILD_TIME_ARG
|
|
||||||
ARG CACHE_BUST
|
|
||||||
ENV BUILD_TIME=${BUILD_TIME_ARG}
|
|
||||||
ENV REACT_APP_BUILD_TIME=${BUILD_TIME_ARG}
|
|
||||||
ENV REACT_APP_CACHE_BUST=${CACHE_BUST}
|
|
||||||
|
|
||||||
# ✅ 환경별 API URL 설정 (Docker 빌드 시점에 주입)
|
|
||||||
ARG REACT_APP_API_BASE_URL
|
|
||||||
ENV REACT_APP_API_BASE_URL=${REACT_APP_API_BASE_URL}
|
|
||||||
ARG REACT_APP_MARKET_DATA_URL
|
|
||||||
ENV REACT_APP_MARKET_DATA_URL=${REACT_APP_MARKET_DATA_URL}
|
|
||||||
ARG REACT_APP_OLLAMA_URL
|
|
||||||
ENV REACT_APP_OLLAMA_URL=${REACT_APP_OLLAMA_URL}
|
|
||||||
ARG NODE_ENV=production
|
|
||||||
ENV NODE_ENV=${NODE_ENV}
|
|
||||||
|
|
||||||
# 빌드 캐시 완전 무효화
|
|
||||||
RUN echo "🔨 Building at: ${BUILD_TIME}" && \
|
|
||||||
rm -rf node_modules/.cache build .cache && \
|
|
||||||
npm cache clean --force && \
|
|
||||||
node ./node_modules/react-scripts/bin/react-scripts.js build
|
|
||||||
|
|
||||||
# Production stage
|
|
||||||
FROM nginx:alpine
|
|
||||||
|
|
||||||
# nginx 설정 복사
|
|
||||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
|
||||||
|
|
||||||
# SSL 인증서 복사
|
|
||||||
COPY ssl/server.crt /etc/nginx/ssl/server.crt
|
|
||||||
COPY ssl/server.key /etc/nginx/ssl/server.key
|
|
||||||
|
|
||||||
# 빌드된 파일 복사
|
|
||||||
COPY --from=build /app/build /usr/share/nginx/html
|
|
||||||
|
|
||||||
# HTTP 및 HTTPS 포트 노출
|
|
||||||
EXPOSE 80 443
|
|
||||||
|
|
||||||
CMD ["nginx", "-g", "daemon off;"]
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
# Build stage
|
|
||||||
FROM node:18-alpine AS build
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# package.json 복사 및 의존성 설치
|
|
||||||
COPY package.json package-lock.json* ./
|
|
||||||
RUN npm install --legacy-peer-deps
|
|
||||||
|
|
||||||
# 소스 코드 복사
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
# node로 직접 react-scripts 실행 (권한 문제 회피)
|
|
||||||
ENV CI=false
|
|
||||||
ENV GENERATE_SOURCEMAP=false
|
|
||||||
|
|
||||||
# 캐시 무효화 (소스 변경 시 항상 재빌드)
|
|
||||||
ARG CACHE_BUST=1
|
|
||||||
ARG BUILD_TIMESTAMP
|
|
||||||
ARG BUILD_TIME_ARG
|
|
||||||
ENV BUILD_TIME=${BUILD_TIME_ARG}
|
|
||||||
ENV REACT_APP_BUILD_TIME=${BUILD_TIME_ARG}
|
|
||||||
|
|
||||||
# ✅ 환경별 API/WebSocket URL 설정 (Docker 빌드 시점에 주입 - 기본 Dockerfile과 동일)
|
|
||||||
ARG REACT_APP_API_BASE_URL
|
|
||||||
ENV REACT_APP_API_BASE_URL=${REACT_APP_API_BASE_URL}
|
|
||||||
ARG REACT_APP_MARKET_DATA_URL
|
|
||||||
ENV REACT_APP_MARKET_DATA_URL=${REACT_APP_MARKET_DATA_URL}
|
|
||||||
ARG NODE_ENV=production
|
|
||||||
ENV NODE_ENV=${NODE_ENV}
|
|
||||||
|
|
||||||
# 빌드 캐시 완전 무효화 후 빌드
|
|
||||||
RUN echo "🔨 Building at: ${BUILD_TIME}" && \
|
|
||||||
rm -rf node_modules/.cache build .cache && \
|
|
||||||
npm cache clean --force && \
|
|
||||||
node ./node_modules/react-scripts/bin/react-scripts.js build
|
|
||||||
|
|
||||||
# Production stage
|
|
||||||
FROM nginx:alpine
|
|
||||||
|
|
||||||
# envsubst를 위한 gettext, 자체 서명 인증서를 위한 openssl 설치
|
|
||||||
RUN apk add --no-cache gettext openssl
|
|
||||||
|
|
||||||
# nginx 설정 템플릿 복사
|
|
||||||
COPY nginx-letsencrypt.conf /etc/nginx/templates/default.conf.template
|
|
||||||
|
|
||||||
# 초기 설정용 nginx 설정 (인증서 없이 시작하기 위함)
|
|
||||||
COPY nginx-init.conf /etc/nginx/conf.d/default.conf
|
|
||||||
|
|
||||||
# Let's Encrypt webroot 및 SSL 디렉토리 생성
|
|
||||||
RUN mkdir -p /var/www/certbot /etc/nginx/ssl
|
|
||||||
|
|
||||||
# 빌드된 파일 복사
|
|
||||||
COPY --from=build /app/build /usr/share/nginx/html
|
|
||||||
|
|
||||||
# 시작 스크립트 복사
|
|
||||||
COPY docker-entrypoint.sh /docker-entrypoint.sh
|
|
||||||
RUN chmod +x /docker-entrypoint.sh
|
|
||||||
|
|
||||||
# HTTP 및 HTTPS 포트 노출
|
|
||||||
EXPOSE 80 443
|
|
||||||
|
|
||||||
# 환경변수 기본값
|
|
||||||
ENV DOMAIN=exdev.co.kr
|
|
||||||
|
|
||||||
ENTRYPOINT ["/docker-entrypoint.sh"]
|
|
||||||
CMD ["nginx", "-g", "daemon off;"]
|
|
||||||
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
# Production stage only - use local build
|
|
||||||
FROM nginx:alpine
|
|
||||||
|
|
||||||
# nginx 설정 복사
|
|
||||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
|
||||||
|
|
||||||
# SSL 인증서 복사
|
|
||||||
COPY ssl/server.crt /etc/nginx/ssl/server.crt
|
|
||||||
COPY ssl/server.key /etc/nginx/ssl/server.key
|
|
||||||
|
|
||||||
# 로컬에서 빌드된 파일 복사
|
|
||||||
COPY build /usr/share/nginx/html
|
|
||||||
|
|
||||||
# HTTP 및 HTTPS 포트 노출
|
|
||||||
EXPOSE 80 443
|
|
||||||
|
|
||||||
CMD ["nginx", "-g", "daemon off;"]
|
|
||||||
@@ -1,284 +0,0 @@
|
|||||||
# 프론트엔드 환경 설정 가이드
|
|
||||||
|
|
||||||
## 📋 환경별 설정
|
|
||||||
|
|
||||||
### 1. 로컬 개발 환경 (Development)
|
|
||||||
|
|
||||||
**실행 방법:**
|
|
||||||
```bash
|
|
||||||
npm start
|
|
||||||
# 또는 Windows에서
|
|
||||||
npm run start:win
|
|
||||||
```
|
|
||||||
|
|
||||||
**접속 URL:**
|
|
||||||
- 프론트엔드: `http://localhost:3100`
|
|
||||||
|
|
||||||
**자동 연결되는 서비스:**
|
|
||||||
- 백엔드 API: `http://localhost:8083/api` (로컬 Docker)
|
|
||||||
- Market Data WebSocket: `wss://exdev.co.kr/market-data/ws/chart` (서버)
|
|
||||||
- Backend WebSocket: `ws://localhost:8083/ws` (로컬 Docker)
|
|
||||||
|
|
||||||
**환경 변수 파일:** `.env.development`, `.env.local`
|
|
||||||
|
|
||||||
**특징:**
|
|
||||||
- Hot Reload 지원 (코드 수정 시 자동 새로고침)
|
|
||||||
- 소스맵 포함 (디버깅 용이)
|
|
||||||
- 개발자 도구 활성화
|
|
||||||
- **WebSocket Data Service는 서버(exdev.co.kr)에서 실행 중**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2. 로컬 프로덕션 빌드 (Local Production Build)
|
|
||||||
|
|
||||||
**빌드 방법:**
|
|
||||||
```bash
|
|
||||||
npm run build
|
|
||||||
```
|
|
||||||
|
|
||||||
**실행 방법:**
|
|
||||||
```bash
|
|
||||||
# serve 패키지 설치 (한 번만)
|
|
||||||
npm install -g serve
|
|
||||||
|
|
||||||
# 빌드된 파일 서빙 (포트 3100)
|
|
||||||
serve -s build -l 3100
|
|
||||||
```
|
|
||||||
|
|
||||||
**접속 URL:**
|
|
||||||
- 프론트엔드: `http://localhost:3100`
|
|
||||||
|
|
||||||
**자동 연결되는 서비스:**
|
|
||||||
- 백엔드 API: `http://localhost:8083/api` (로컬 Docker)
|
|
||||||
- Market Data WebSocket: `wss://exdev.co.kr/market-data/ws/chart` (서버)
|
|
||||||
- Backend WebSocket: `ws://localhost:8083/ws` (로컬 Docker)
|
|
||||||
|
|
||||||
**환경 변수 파일:** `.env.production.local` (없으면 코드에서 자동 감지)
|
|
||||||
|
|
||||||
**특징:**
|
|
||||||
- 최적화된 프로덕션 빌드
|
|
||||||
- 코드 압축 및 난독화
|
|
||||||
- **WebSocket Data Service는 서버(exdev.co.kr)에서 실행 중**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3. 서버 배포 환경 (Production on Server)
|
|
||||||
|
|
||||||
**빌드 방법:**
|
|
||||||
```bash
|
|
||||||
npm run build
|
|
||||||
```
|
|
||||||
|
|
||||||
**배포 방법:**
|
|
||||||
```bash
|
|
||||||
# Docker 이미지 빌드 및 배포
|
|
||||||
docker-compose build frontend
|
|
||||||
docker-compose up -d frontend
|
|
||||||
```
|
|
||||||
|
|
||||||
**접속 URL:**
|
|
||||||
- 프론트엔드: `https://exdev.co.kr` (HTTPS, 포트 443)
|
|
||||||
- 또는: `http://exdev.co.kr` (HTTP, 포트 80)
|
|
||||||
|
|
||||||
**nginx 프록시를 통해 연결되는 서비스:**
|
|
||||||
- 백엔드 API: `https://exdev.co.kr/api` → `backend:8083/api`
|
|
||||||
- Market Data WebSocket: `wss://exdev.co.kr/market-data/ws/chart` → `market-data-system:8086/ws/chart`
|
|
||||||
- Backend WebSocket: `wss://exdev.co.kr/ws` → `backend:8083/ws`
|
|
||||||
|
|
||||||
**환경 변수 파일:** `.env.production`
|
|
||||||
|
|
||||||
**특징:**
|
|
||||||
- nginx 리버스 프록시 사용
|
|
||||||
- SSL/TLS 암호화 (HTTPS)
|
|
||||||
- 모든 서비스가 443 포트를 통해 접근
|
|
||||||
- 외부 네트워크에서 접근 가능
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔧 환경 감지 로직
|
|
||||||
|
|
||||||
### API Base URL (`apiConfig.ts`)
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
export const getApiBaseUrl = (): string => {
|
|
||||||
const isLocalhost = window.location.hostname === 'localhost' ||
|
|
||||||
window.location.hostname === '127.0.0.1' ||
|
|
||||||
window.location.hostname === '';
|
|
||||||
|
|
||||||
if (isLocalhost) {
|
|
||||||
// 로컬: 직접 연결
|
|
||||||
return 'http://localhost:8083/api';
|
|
||||||
}
|
|
||||||
|
|
||||||
// 서버: nginx 프록시 (상대 경로)
|
|
||||||
return '/api';
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### Market Data WebSocket URL (`useMarketDataWebSocket.ts`)
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const getMarketDataUrl = (): string => {
|
|
||||||
const isLocalhost = window.location.hostname === 'localhost' ||
|
|
||||||
window.location.hostname === '127.0.0.1' ||
|
|
||||||
window.location.hostname === '';
|
|
||||||
|
|
||||||
if (isLocalhost) {
|
|
||||||
// 로컬: 직접 연결
|
|
||||||
return 'http://localhost:8086';
|
|
||||||
}
|
|
||||||
|
|
||||||
// 서버: nginx 프록시
|
|
||||||
const protocol = window.location.protocol === 'https:' ? 'https:' : 'http:';
|
|
||||||
return `${protocol}//${window.location.host}/market-data`;
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
**핵심:** `window.location.hostname`을 기준으로 자동 감지하므로, 환경 변수 없이도 동작합니다!
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📁 환경 변수 파일 우선순위
|
|
||||||
|
|
||||||
React는 다음 순서로 환경 변수 파일을 로드합니다:
|
|
||||||
|
|
||||||
1. `.env.development.local` (개발 환경, git 무시)
|
|
||||||
2. `.env.production.local` (프로덕션 환경, git 무시)
|
|
||||||
3. `.env.local` (모든 환경, git 무시)
|
|
||||||
4. `.env.development` (개발 환경)
|
|
||||||
5. `.env.production` (프로덕션 환경)
|
|
||||||
6. `.env` (모든 환경)
|
|
||||||
|
|
||||||
**현재 프로젝트:**
|
|
||||||
- `.env` - 기본 포트 설정
|
|
||||||
- `.env.local` - 로컬 개발 설정
|
|
||||||
- `.env.development` - 개발 환경 설정
|
|
||||||
- `.env.production` - 프로덕션 환경 설정 (주석만, 실제로는 코드에서 자동 감지)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚀 시나리오별 사용법
|
|
||||||
|
|
||||||
### 시나리오 1: 로컬에서 개발
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. 백엔드 서비스 실행 (Docker)
|
|
||||||
docker-compose up -d backend market-data-system
|
|
||||||
|
|
||||||
# 2. 프론트엔드 개발 서버 실행
|
|
||||||
cd frontend
|
|
||||||
npm start
|
|
||||||
|
|
||||||
# 3. 브라우저에서 접속
|
|
||||||
# http://localhost:3100
|
|
||||||
```
|
|
||||||
|
|
||||||
### 시나리오 2: 로컬에서 프로덕션 빌드 테스트
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. 백엔드 서비스 실행 (Docker)
|
|
||||||
docker-compose up -d backend market-data-system
|
|
||||||
|
|
||||||
# 2. 프론트엔드 빌드
|
|
||||||
cd frontend
|
|
||||||
npm run build
|
|
||||||
|
|
||||||
# 3. 빌드된 파일 서빙
|
|
||||||
serve -s build -l 3100
|
|
||||||
|
|
||||||
# 4. 브라우저에서 접속
|
|
||||||
# http://localhost:3100
|
|
||||||
```
|
|
||||||
|
|
||||||
### 시나리오 3: 서버에 배포
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. 전체 서비스 빌드 및 배포
|
|
||||||
docker-compose build
|
|
||||||
docker-compose up -d
|
|
||||||
|
|
||||||
# 2. 외부에서 접속
|
|
||||||
# https://exdev.co.kr
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔍 트러블슈팅
|
|
||||||
|
|
||||||
### 문제: 로컬에서 API 연결 안 됨
|
|
||||||
|
|
||||||
**확인 사항:**
|
|
||||||
1. 백엔드 서비스가 실행 중인지 확인
|
|
||||||
```bash
|
|
||||||
docker-compose ps
|
|
||||||
```
|
|
||||||
|
|
||||||
2. 포트가 열려있는지 확인
|
|
||||||
```bash
|
|
||||||
# Windows
|
|
||||||
netstat -ano | findstr :8083
|
|
||||||
netstat -ano | findstr :8086
|
|
||||||
```
|
|
||||||
|
|
||||||
3. 브라우저 콘솔에서 URL 확인
|
|
||||||
```
|
|
||||||
[API Config] 로컬 환경 감지: http://localhost:8083/api
|
|
||||||
[Market Data] WebSocket URL: ws://localhost:8086/ws/chart
|
|
||||||
```
|
|
||||||
|
|
||||||
### 문제: 서버에서 WebSocket 연결 안 됨
|
|
||||||
|
|
||||||
**확인 사항:**
|
|
||||||
1. nginx 설정 확인
|
|
||||||
```bash
|
|
||||||
docker-compose exec frontend nginx -t
|
|
||||||
```
|
|
||||||
|
|
||||||
2. nginx 로그 확인
|
|
||||||
```bash
|
|
||||||
docker-compose logs frontend
|
|
||||||
```
|
|
||||||
|
|
||||||
3. 브라우저 콘솔에서 URL 확인
|
|
||||||
```
|
|
||||||
[Market Data] WebSocket URL: wss://exdev.co.kr/market-data/ws/chart
|
|
||||||
```
|
|
||||||
|
|
||||||
### 문제: Mixed Content 오류 (HTTP/HTTPS)
|
|
||||||
|
|
||||||
**원인:** HTTPS 페이지에서 HTTP 리소스 로드 시도
|
|
||||||
|
|
||||||
**해결:**
|
|
||||||
- 로컬: `http://localhost` 사용 (HTTPS 불필요)
|
|
||||||
- 서버: 모든 연결이 HTTPS/WSS로 자동 변환됨
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📝 체크리스트
|
|
||||||
|
|
||||||
### 로컬 개발 시작 전
|
|
||||||
- [ ] Docker Desktop 실행 중
|
|
||||||
- [ ] `docker-compose up -d backend market-data-system` 실행
|
|
||||||
- [ ] `npm install` 완료
|
|
||||||
- [ ] 포트 3100, 8083, 8086 사용 가능
|
|
||||||
|
|
||||||
### 서버 배포 전
|
|
||||||
- [ ] 프로덕션 빌드 테스트 완료
|
|
||||||
- [ ] nginx 설정 확인
|
|
||||||
- [ ] SSL 인증서 유효
|
|
||||||
- [ ] 방화벽 443 포트 오픈
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎯 요약
|
|
||||||
|
|
||||||
| 환경 | 접속 URL | 백엔드 API | Market Data WS | 특징 |
|
|
||||||
|------|---------|-----------|----------------|------|
|
|
||||||
| **로컬 개발** | `http://localhost:3100` | `http://localhost:8083` | `wss://exdev.co.kr/market-data` | Hot Reload, 서버 WS |
|
|
||||||
| **로컬 빌드** | `http://localhost:3100` | `http://localhost:8083` | `wss://exdev.co.kr/market-data` | 최적화 빌드, 서버 WS |
|
|
||||||
| **서버 배포** | `https://exdev.co.kr` | `https://exdev.co.kr/api` | `wss://exdev.co.kr/market-data` | nginx 프록시 |
|
|
||||||
|
|
||||||
**핵심:**
|
|
||||||
- 백엔드 API는 로컬/서버 환경에 따라 자동 감지
|
|
||||||
- **WebSocket Data Service는 항상 서버(exdev.co.kr) 사용** 🚀
|
|
||||||
- 별도 설정 없이 어디서든 동작합니다!
|
|
||||||
@@ -1,386 +0,0 @@
|
|||||||
# Week 3-4: Frontend 구현 완료
|
|
||||||
|
|
||||||
## 📋 구현 완료 목록
|
|
||||||
|
|
||||||
### 1. API 서비스
|
|
||||||
- ✅ **strategyDSLApi.ts** (600+ 라인)
|
|
||||||
- LogicNode, ConditionDSL, StrategyDSLDto 타입 정의
|
|
||||||
- CRUD API 함수 (생성, 조회, 수정, 삭제)
|
|
||||||
- 유틸리티 함수 (유효성 검증, 자연어 변환 등)
|
|
||||||
- 12개 API 엔드포인트 연동
|
|
||||||
|
|
||||||
### 2. 드래그 앤 드롭 컴포넌트 (strategy-tree/)
|
|
||||||
- ✅ **NodePalette.tsx** (300+ 라인)
|
|
||||||
- 드래그 가능한 지표/조건 팔레트
|
|
||||||
- 논리 연산자 (AND, OR, NOT)
|
|
||||||
- 보조 지표 (CCI, MACD, RSI 등 8개)
|
|
||||||
- 사용 가이드 및 예시 표시
|
|
||||||
|
|
||||||
- ✅ **TreeNode.tsx** (280+ 라인)
|
|
||||||
- 재귀적 트리 노드 렌더링
|
|
||||||
- 드래그 앤 드롭 지원 (@dnd-kit)
|
|
||||||
- 노드 타입별 색상 및 아이콘
|
|
||||||
- 편집/삭제/자식 추가 액션
|
|
||||||
|
|
||||||
- ✅ **ConditionEditor.tsx** (350+ 라인)
|
|
||||||
- 조건 설정 다이얼로그
|
|
||||||
- 지표 타입 선택
|
|
||||||
- 조건 타입 선택 (13종류)
|
|
||||||
- 기준값, 기간 입력
|
|
||||||
- 실시간 미리보기
|
|
||||||
|
|
||||||
- ✅ **NaturalLanguagePreview.tsx** (250+ 라인)
|
|
||||||
- DSL → 자연어 변환
|
|
||||||
- 전략 통계 (노드 수, 조건 수, 깊이, 복잡도)
|
|
||||||
- 유효성 검증 알림
|
|
||||||
- 복잡도 경고
|
|
||||||
|
|
||||||
- ✅ **StrategyTreeBuilder.tsx** (280+ 라인)
|
|
||||||
- DndContext 통합
|
|
||||||
- 노드 조작 로직 (찾기, 업데이트, 삭제, 추가)
|
|
||||||
- 드래그 앤 드롭 이벤트 처리
|
|
||||||
- 조건 편집 다이얼로그 통합
|
|
||||||
|
|
||||||
### 3. 메인 페이지
|
|
||||||
- ✅ **StrategyTreeBuilderPage.tsx** (450+ 라인)
|
|
||||||
- 3-컬럼 레이아웃
|
|
||||||
- 왼쪽: 전략 목록 + 팔레트
|
|
||||||
- 중앙: 전략 편집기
|
|
||||||
- 오른쪽: 자연어 미리보기
|
|
||||||
- 전략 CRUD 기능
|
|
||||||
- 저장/삭제 다이얼로그
|
|
||||||
- 스낵바 알림
|
|
||||||
|
|
||||||
### 4. 기존 파일 수정
|
|
||||||
- ✅ **App.tsx** (수정)
|
|
||||||
- StrategyTreeBuilderPage import 추가
|
|
||||||
- 메뉴 항목 추가
|
|
||||||
- "전략(기존)" → StrategyRulePage
|
|
||||||
- "전략(드래그)" → StrategyTreeBuilderPage
|
|
||||||
- renderPage에 'strategy-tree' 케이스 추가
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📊 구현 통계
|
|
||||||
|
|
||||||
| 항목 | 개수 | 라인 수 |
|
|
||||||
|------|------|---------|
|
|
||||||
| API 서비스 | 1개 | 600+ |
|
|
||||||
| 컴포넌트 | 5개 | 1,460+ |
|
|
||||||
| 페이지 | 1개 | 450+ |
|
|
||||||
| App.tsx 수정 | - | +20 |
|
|
||||||
| **총계** | **7개 파일** | **2,530+ 라인** |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎯 핵심 달성 사항
|
|
||||||
|
|
||||||
### ✅ 기존 기능 100% 보존
|
|
||||||
- `StrategyRulePage.tsx` 수정 없음
|
|
||||||
- 기존 메뉴 항목 그대로 유지
|
|
||||||
- 기존 API (`strategyRuleApi.ts`) 영향 없음
|
|
||||||
- 기존 백테스트 화면 정상 동작
|
|
||||||
|
|
||||||
### ✅ 드래그 앤 드롭 UI 완성
|
|
||||||
- @dnd-kit 라이브러리 활용
|
|
||||||
- 직관적인 트리 구조 편집
|
|
||||||
- 실시간 미리보기
|
|
||||||
- 자연어 설명 자동 생성
|
|
||||||
|
|
||||||
### ✅ Material-UI 디자인 일관성
|
|
||||||
- 기존 테마 시스템 활용
|
|
||||||
- 다크 모드 완벽 지원
|
|
||||||
- 반응형 레이아웃 (Grid 시스템)
|
|
||||||
|
|
||||||
### ✅ 사용자 경험 최적화
|
|
||||||
- 드래그 가이드 제공
|
|
||||||
- 실시간 유효성 검증
|
|
||||||
- 복잡도 경고
|
|
||||||
- 스낵바 알림
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔍 코드 검증 완료
|
|
||||||
|
|
||||||
- ✅ **Lint 오류 없음**
|
|
||||||
- ✅ **TypeScript 타입 안정성 보장**
|
|
||||||
- ✅ **재사용 가능한 컴포넌트 구조**
|
|
||||||
- ✅ **명확한 props 인터페이스**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎨 UI 구조
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────────────────────────┐
|
|
||||||
│ 🎨 전략 설정 (드래그 방식) [새로고침] [새 전략] [저장] │
|
|
||||||
├───────────────┬──────────────────────────┬───────────────────────┤
|
|
||||||
│ │ │ │
|
|
||||||
│ 📋 전략 목록 │ ✏️ 전략 편집기 │ 📝 전략 미리보기 │
|
|
||||||
│ │ │ │
|
|
||||||
│ □ CCI 전략 │ ┌─────────────────┐ │ 📊 통계 │
|
|
||||||
│ □ MACD 전략 │ │ AND 노드 │ │ - 노드: 3개 │
|
|
||||||
│ □ RSI 전략 │ └──┬──────────┬───┘ │ - 조건: 2개 │
|
|
||||||
│ │ │ │ │ - 깊이: 2 │
|
|
||||||
│ [+ 새 전략] │ ┌─────┐ ┌─────┐ │ - 복잡도: 간단함 │
|
|
||||||
│ │ │ CCI │ │ RSI │ │ │
|
|
||||||
│ ───────────── │ └─────┘ └─────┘ │ 📝 설명 │
|
|
||||||
│ │ │ "다음 조건을 모두 │
|
|
||||||
│ 🔗 논리 연산자│ │ 만족: │
|
|
||||||
│ [AND] [OR] │ │ CCI(20)가 -100을 │
|
|
||||||
│ [NOT] │ │ 상향 돌파 │
|
|
||||||
│ │ │ RSI(14)가 30 이하" │
|
|
||||||
│ 📊 보조 지표 │ │ │
|
|
||||||
│ [CCI] [MACD] │ │ ✅ 전략이 유효합니다 │
|
|
||||||
│ [RSI] ... │ │ │
|
|
||||||
└───────────────┴──────────────────────────┴───────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚀 주요 기능
|
|
||||||
|
|
||||||
### 1. 드래그 앤 드롭
|
|
||||||
- **팔레트에서 노드 드래그**
|
|
||||||
- 논리 연산자 (AND/OR/NOT)
|
|
||||||
- 보조 지표 (CCI, MACD, RSI 등)
|
|
||||||
- **트리 구조 생성**
|
|
||||||
- 자유로운 계층 구조
|
|
||||||
- 재귀적 렌더링
|
|
||||||
- **드롭 영역 하이라이트**
|
|
||||||
- 드롭 가능한 영역 표시
|
|
||||||
- 실시간 피드백
|
|
||||||
|
|
||||||
### 2. 조건 편집
|
|
||||||
- **지표 선택**
|
|
||||||
- 12종류 보조 지표 지원
|
|
||||||
- 권장 기간 자동 설정
|
|
||||||
- **조건 타입 선택**
|
|
||||||
- 13종류 조건 지원
|
|
||||||
- 조건별 필수 입력 항목 자동 표시
|
|
||||||
- **실시간 미리보기**
|
|
||||||
- 설정 중인 조건 자연어 표시
|
|
||||||
|
|
||||||
### 3. 자연어 변환
|
|
||||||
- **DSL → 한글 변환**
|
|
||||||
- 논리 구조 명확히 표현
|
|
||||||
- 들여쓰기로 계층 표시
|
|
||||||
- **통계 정보**
|
|
||||||
- 노드 개수, 조건 개수
|
|
||||||
- 트리 깊이, 복잡도 점수
|
|
||||||
- **유효성 검증**
|
|
||||||
- 저장 가능 여부 표시
|
|
||||||
- 복잡도 경고
|
|
||||||
|
|
||||||
### 4. 전략 관리
|
|
||||||
- **CRUD 작업**
|
|
||||||
- 생성, 조회, 수정, 삭제
|
|
||||||
- **전략 복제**
|
|
||||||
- 기존 전략 복사하여 새 전략 생성
|
|
||||||
- **활성화 관리**
|
|
||||||
- 개별 전략 활성화/비활성화
|
|
||||||
- **FSM 토글**
|
|
||||||
- Finite State Machine 사용 여부 설정
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📝 사용 예시
|
|
||||||
|
|
||||||
### 예시 1: CCI 침체 탈출 전략
|
|
||||||
|
|
||||||
**구성:**
|
|
||||||
```
|
|
||||||
AND 노드
|
|
||||||
├─ CCI 조건: -100 상향 돌파
|
|
||||||
└─ RSI 조건: 30 이하
|
|
||||||
```
|
|
||||||
|
|
||||||
**자연어:**
|
|
||||||
```
|
|
||||||
다음 조건을 모두 만족:
|
|
||||||
CCI(20)가 -100을 상향 돌파
|
|
||||||
RSI(14)가 30 이하
|
|
||||||
```
|
|
||||||
|
|
||||||
**DSL JSON:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"id": "node-1",
|
|
||||||
"type": "AND",
|
|
||||||
"children": [
|
|
||||||
{
|
|
||||||
"id": "node-2",
|
|
||||||
"type": "CONDITION",
|
|
||||||
"condition": {
|
|
||||||
"indicatorType": "CCI",
|
|
||||||
"conditionType": "CROSS_UP",
|
|
||||||
"targetValue": -100,
|
|
||||||
"period": 20
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "node-3",
|
|
||||||
"type": "CONDITION",
|
|
||||||
"condition": {
|
|
||||||
"indicatorType": "RSI",
|
|
||||||
"conditionType": "BELOW",
|
|
||||||
"targetValue": 30,
|
|
||||||
"period": 14
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 예시 2: 복합 전략 (OR 조건)
|
|
||||||
|
|
||||||
**구성:**
|
|
||||||
```
|
|
||||||
OR 노드
|
|
||||||
├─ MACD 조건: 골든 크로스
|
|
||||||
└─ Stochastic 조건: 과매도
|
|
||||||
```
|
|
||||||
|
|
||||||
**자연어:**
|
|
||||||
```
|
|
||||||
다음 조건 중 하나 이상 만족:
|
|
||||||
MACD(12) 골든 크로스
|
|
||||||
Stochastic(14) 과매도 구간
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔄 데이터 흐름
|
|
||||||
|
|
||||||
### 전략 생성 흐름
|
|
||||||
```
|
|
||||||
1. 사용자: 팔레트에서 AND 노드 드래그
|
|
||||||
↓
|
|
||||||
2. Frontend: LogicNode 생성 (type: "AND")
|
|
||||||
↓
|
|
||||||
3. 사용자: CCI 지표 드래그 → AND 노드에 드롭
|
|
||||||
↓
|
|
||||||
4. Frontend: CONDITION 노드 생성 → 자식으로 추가
|
|
||||||
↓
|
|
||||||
5. 사용자: 조건 편집 (기준값 -100, 기간 20)
|
|
||||||
↓
|
|
||||||
6. Frontend: ConditionDSL 업데이트
|
|
||||||
↓
|
|
||||||
7. 사용자: 저장 버튼 클릭
|
|
||||||
↓
|
|
||||||
8. Frontend: LogicNode → JSON.stringify
|
|
||||||
↓
|
|
||||||
9. Backend API: POST /api/strategies/dsl
|
|
||||||
↓
|
|
||||||
10. Backend: DSL → Legacy 변환 (StrategyConverterService)
|
|
||||||
↓
|
|
||||||
11. Database: strategy_rule 테이블에 저장
|
|
||||||
↓
|
|
||||||
12. Frontend: 스낵바 "전략이 생성되었습니다" 표시
|
|
||||||
```
|
|
||||||
|
|
||||||
### 백테스트 실행 흐름
|
|
||||||
```
|
|
||||||
1. 사용자: 투자분석 화면에서 전략 선택 (DSL 전략)
|
|
||||||
↓
|
|
||||||
2. Frontend: 백테스트 요청
|
|
||||||
↓
|
|
||||||
3. Backend: StrategyRule 조회 (dsl_json 포함)
|
|
||||||
↓
|
|
||||||
4. Backend: 기존 BacktestService 사용 (변경 없음)
|
|
||||||
↓
|
|
||||||
5. Backend: ConditionGroup, StrategyCondition 기반 평가
|
|
||||||
↓
|
|
||||||
6. Frontend: 매수/매도 마커 표시
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎓 기술 스택
|
|
||||||
|
|
||||||
| 항목 | 기술 |
|
|
||||||
|------|------|
|
|
||||||
| 언어 | TypeScript |
|
|
||||||
| UI 프레임워크 | React 18 |
|
|
||||||
| 디자인 시스템 | Material-UI 5 |
|
|
||||||
| 드래그 앤 드롭 | @dnd-kit |
|
|
||||||
| 상태 관리 | React Hooks (useState, useEffect) |
|
|
||||||
| API 통신 | Axios |
|
|
||||||
| 라우팅 | 없음 (단일 페이지 앱 내 페이지 전환) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✅ 검증 항목
|
|
||||||
|
|
||||||
- [x] 드래그 앤 드롭 동작
|
|
||||||
- [x] API 통신 (CRUD)
|
|
||||||
- [x] 자연어 변환
|
|
||||||
- [x] 전략 저장/로드
|
|
||||||
- [x] 기존 화면 정상 동작
|
|
||||||
- [x] 다크 모드 지원
|
|
||||||
- [x] 반응형 레이아웃
|
|
||||||
- [x] TypeScript 타입 안정성
|
|
||||||
- [x] Lint 오류 없음
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📦 파일 구조
|
|
||||||
|
|
||||||
```
|
|
||||||
frontend/src/
|
|
||||||
├── services/
|
|
||||||
│ └── strategyDSLApi.ts (신규)
|
|
||||||
├── components/
|
|
||||||
│ └── strategy-tree/ (신규)
|
|
||||||
│ ├── NodePalette.tsx
|
|
||||||
│ ├── TreeNode.tsx
|
|
||||||
│ ├── ConditionEditor.tsx
|
|
||||||
│ ├── NaturalLanguagePreview.tsx
|
|
||||||
│ └── StrategyTreeBuilder.tsx
|
|
||||||
├── pages/
|
|
||||||
│ ├── StrategyRulePage.tsx (기존, 수정 없음)
|
|
||||||
│ └── StrategyTreeBuilderPage.tsx (신규)
|
|
||||||
└── App.tsx (수정)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔧 다음 단계 (선택사항)
|
|
||||||
|
|
||||||
### Week 5-6: 고급 기능 추가 (선택)
|
|
||||||
1. **전략 템플릿**
|
|
||||||
- 사전 정의된 전략 템플릿 제공
|
|
||||||
- 한 클릭으로 적용
|
|
||||||
2. **전략 시뮬레이션**
|
|
||||||
- 드래그 화면에서 바로 백테스트
|
|
||||||
- 실시간 성과 미리보기
|
|
||||||
3. **전략 공유**
|
|
||||||
- 전략 내보내기/가져오기 (JSON)
|
|
||||||
- 커뮤니티 전략 라이브러리
|
|
||||||
|
|
||||||
### Week 7-8: FSM 엔진 통합 (선택)
|
|
||||||
1. **Indicator FSM 활성화**
|
|
||||||
- useFsm 플래그 활용
|
|
||||||
- 성능 비교 대시보드
|
|
||||||
2. **실시간 상태 추적**
|
|
||||||
- FSM 상태 시각화
|
|
||||||
- 디버깅 도구
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎉 Week 3-4 구현 완료!
|
|
||||||
|
|
||||||
**모든 Frontend 작업이 완료되었습니다.**
|
|
||||||
|
|
||||||
- ✅ 드래그 앤 드롭 UI 완성
|
|
||||||
- ✅ DSL API 통합 완료
|
|
||||||
- ✅ 기존 기능 100% 보존
|
|
||||||
- ✅ 사용자 경험 최적화
|
|
||||||
|
|
||||||
**사용자는 이제:**
|
|
||||||
- 기존 폼 방식 (StrategyRulePage)
|
|
||||||
- 드래그 앤 드롭 방식 (StrategyTreeBuilderPage)
|
|
||||||
|
|
||||||
**두 가지 방법으로 전략을 생성할 수 있습니다!**
|
|
||||||
|
|
||||||
모든 전략은 동일한 테이블에 저장되며, 기존 백테스트 엔진에서 정상 동작합니다.
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user