검증게시판 단계 추가

This commit is contained in:
Macbook
2026-05-28 16:24:14 +09:00
parent f64dc1e983
commit 7e3644cb62
375 changed files with 4539 additions and 251294 deletions
+11 -2
View File
@@ -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
+52
View File
@@ -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 가 달라도 푸시를 받을 수 있습니다.
+2 -2
View File
@@ -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"
}
+9 -4
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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>
+22 -2
View File
@@ -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 () => {
+1
View File
@@ -4,4 +4,5 @@ export {
subscribeAppSettings, subscribeAppSettings,
resolveAppDefaults, resolveAppDefaults,
invalidateAppSettingsCache, invalidateAppSettingsCache,
reloadAppSettingsCache,
} from '@frontend/hooks/useAppSettings'; } from '@frontend/hooks/useAppSettings';
+2 -3
View File
@@ -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} />;
} }
+97
View File
@@ -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 }}>
. WiFi·
</p>
)}
<button type="submit" className="splash-login-btn" disabled={loading}>
{loading ? '로그인 중…' : '로그인'}
</button>
</form>
<p className="splash-version">
: {APP_VERSION} | Core Engine: Ta4j &amp; 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
View File
@@ -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;
} }
+112
View File
@@ -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"
}
+18
View File
@@ -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.**
+52
View File
@@ -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
}
}
@@ -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)
}
}
}
}
}
@@ -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
}
}
@@ -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)
}
}
}
@@ -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
}
}
@@ -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
}
}
@@ -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
}
}
}
@@ -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
}
}
@@ -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 &gt; 0, RSI &lt; 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>
+45
View File
@@ -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>
+27
View File
@@ -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>
+47
View File
@@ -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
@@ -0,0 +1,2 @@
-- 검증 이슈 단계: 확인요청(CONFIRM_REQUEST), 재검증 요청(RE_VERIFY_REQUEST)
-- stage 컬럼은 VARCHAR 이므로 기존 행 마이그레이션 불필요. 신규 enum 값만 애플리케이션에서 사용.
+4
View File
@@ -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 => {
+6
View File
@@ -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 기본값 접근 헬퍼 */
+78 -18
View File
@@ -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 [];
}
-252
View File
@@ -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에 저장되어 다음 접속 시에도 유지됩니다.
-63
View File
@@ -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;"]
-68
View File
@@ -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;"]
-17
View File
@@ -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;"]
-284
View File
@@ -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