검증게시판 단계 추가
This commit is contained in:
+11
-2
@@ -34,11 +34,20 @@ npm run cap:android # or cap:ios
|
||||
|
||||
Copy `.env.example` to `.env` and set `VITE_API_BASE_URL`.
|
||||
|
||||
### 로그인 시 "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)
|
||||
|
||||
- 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
|
||||
- Backend: `FIREBASE_ENABLED=true` + service account JSON
|
||||
- Backend: `FIREBASE_ENABLED=true` + `firebase-service-account.json`
|
||||
- 앱: 로그인 후 설정에서 **FCM 푸시** ON + 알림 권한 허용
|
||||
|
||||
## Structure
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
# Android FCM 설정 (goldenApp / goldenanalysisapp)
|
||||
|
||||
기존 **goldenApp** (`~/dev/goldenAnalysis/goldenApp`) 과 동일 Firebase 프로젝트를 사용합니다.
|
||||
|
||||
## 1. Firebase Console
|
||||
|
||||
1. [Firebase Console](https://console.firebase.google.com/) → 프로젝트 **goldenanalysisapp**
|
||||
2. **앱 추가** → Android
|
||||
3. 패키지 이름: `com.goldenchart.app` (Capacitor `applicationId` 와 동일)
|
||||
4. `google-services.json` 다운로드
|
||||
|
||||
## 2. 프로젝트에 배치
|
||||
|
||||
```bash
|
||||
./scripts/setup-android-fcm.sh ~/Downloads/google-services.json
|
||||
# 또는
|
||||
cp ~/Downloads/google-services.json app/android/app/google-services.json
|
||||
```
|
||||
|
||||
## 3. 빌드
|
||||
|
||||
```bash
|
||||
npm run cap:sync
|
||||
cd app/android && ./gradlew assembleRelease
|
||||
```
|
||||
|
||||
`google-services.json` 이 없으면 Gradle 로그에
|
||||
`google-services plugin not applied` 가 나오며 **푸시가 동작하지 않습니다**.
|
||||
|
||||
## 4. 서버 (exdev)
|
||||
|
||||
- `FIREBASE_ENABLED=true`
|
||||
- `firebase-service-account.json` (goldenanalysisapp 서비스 계정)
|
||||
- 앱 설정에서 **FCM 푸시** ON (`fcm_push_enabled`)
|
||||
|
||||
## 5. 앱에서 확인
|
||||
|
||||
1. 로그인 후 **설정 → FCM 푸시** ON
|
||||
2. 알림 권한 허용
|
||||
3. **테스트 발송** 버튼
|
||||
4. 전략 시그널 발생 시 푸시 수신
|
||||
|
||||
## goldenApp 과의 차이
|
||||
|
||||
| 항목 | goldenApp | GoldenChart (Capacitor) |
|
||||
|------|-----------|-------------------------|
|
||||
| 패키지 | `com.golden.app` | `com.goldenchart.app` |
|
||||
| FCM SDK | 직접 Firebase Messaging | `@capacitor/push-notifications` |
|
||||
| 토큰 등록 | `POST /api/fcm/token` | 동일 (`registerFcmToken`) |
|
||||
| 알림 채널 | `golden_alerts` | `goldenchart_trade_signals` |
|
||||
|
||||
로그인 사용자는 **userId** 기준으로 FCM 토큰을 조회하므로, 웹·모바일 deviceId 가 달라도 푸시를 받을 수 있습니다.
|
||||
@@ -7,8 +7,8 @@ android {
|
||||
applicationId "com.goldenchart.app"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 2
|
||||
versionName "1.1"
|
||||
versionCode 7
|
||||
versionName "1.6"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"project_info": {
|
||||
"project_number": "353782909389",
|
||||
"project_id": "goldenanalysisapp",
|
||||
"storage_bucket": "goldenanalysisapp.firebasestorage.app"
|
||||
},
|
||||
"client": [
|
||||
{
|
||||
"client_info": {
|
||||
"mobilesdk_app_id": "1:353782909389:android:45e0218e69611efcc75c84",
|
||||
"android_client_info": {
|
||||
"package_name": "com.golden.app"
|
||||
}
|
||||
},
|
||||
"oauth_client": [],
|
||||
"api_key": [
|
||||
{
|
||||
"current_key": "AIzaSyBZWkVrPvG88_bYnIG94yiml3VXzht157Y"
|
||||
}
|
||||
],
|
||||
"services": {
|
||||
"appinvite_service": {
|
||||
"other_platform_oauth_client": []
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"configuration_version": "1"
|
||||
}
|
||||
@@ -1,6 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<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
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
@@ -10,6 +15,10 @@
|
||||
android:usesCleartextTraffic="true"
|
||||
android:theme="@style/AppTheme">
|
||||
|
||||
<meta-data
|
||||
android:name="com.google.firebase.messaging.default_notification_channel_id"
|
||||
android:value="@string/default_notification_channel_id" />
|
||||
|
||||
<activity
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation"
|
||||
android:name=".MainActivity"
|
||||
@@ -35,8 +44,4 @@
|
||||
android:resource="@xml/file_paths"></meta-data>
|
||||
</provider>
|
||||
</application>
|
||||
|
||||
<!-- Permissions -->
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
</manifest>
|
||||
|
||||
@@ -1,5 +1,41 @@
|
||||
package com.goldenchart.app;
|
||||
|
||||
import android.app.NotificationChannel;
|
||||
import android.app.NotificationManager;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
|
||||
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>
|
||||
<string name="app_name">GoldenChart</string>
|
||||
<string name="title_activity_main">GoldenChart</string>
|
||||
<string name="package_name">com.goldenchart.app</string>
|
||||
<string name="custom_url_scheme">com.goldenchart.app</string>
|
||||
<string name="default_notification_channel_id">goldenchart_trade_signals</string>
|
||||
<string name="default_notification_channel_name">매매 시그널 알림</string>
|
||||
<string name="default_notification_channel_description">전략 조건 일치 시 매수·매도 푸시 알림</string>
|
||||
</resources>
|
||||
|
||||
@@ -5,10 +5,14 @@ const config: CapacitorConfig = {
|
||||
appName: 'GoldenChart',
|
||||
webDir: 'dist',
|
||||
server: {
|
||||
androidScheme: 'https',
|
||||
// https 스킴이면 WebView가 https://localhost → http API 호출이 mixed content로 차단됨 (Failed to fetch)
|
||||
androidScheme: 'http',
|
||||
iosScheme: 'capacitor',
|
||||
},
|
||||
plugins: {
|
||||
CapacitorHttp: {
|
||||
enabled: true,
|
||||
},
|
||||
SplashScreen: {
|
||||
launchAutoHide: true,
|
||||
backgroundColor: '#0f0f23',
|
||||
|
||||
+8
-3
@@ -43,7 +43,7 @@ function MainApp() {
|
||||
const defaults = resolveAppDefaults(settings);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoaded || !defaults.fcmPushEnabled) return;
|
||||
if (!isLoaded) return;
|
||||
void initFcmPush({
|
||||
onForeground: (_title, _body, data) => {
|
||||
if (data?.market && data?.signalType) {
|
||||
@@ -67,7 +67,7 @@ function MainApp() {
|
||||
}
|
||||
},
|
||||
});
|
||||
}, [isLoaded, defaults.fcmPushEnabled, addNotification, refreshHistory, setTab, openVirtualFocus]);
|
||||
}, [isLoaded, addNotification, refreshHistory, setTab, openVirtualFocus]);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-theme', defaults.theme ?? 'dark');
|
||||
@@ -105,6 +105,7 @@ function AppRoot() {
|
||||
isAppEntered,
|
||||
handleLoginSuccess,
|
||||
handleGuestEnter,
|
||||
sessionKey,
|
||||
} = useAuth();
|
||||
const [nativeReady, setNativeReady] = useState(false);
|
||||
|
||||
@@ -136,7 +137,11 @@ function AppRoot() {
|
||||
|
||||
return (
|
||||
<NavigationProvider>
|
||||
<TradeNotificationProvider soundEnabled popupEnabled={false}>
|
||||
<TradeNotificationProvider
|
||||
soundEnabled
|
||||
popupEnabled={false}
|
||||
settingsSessionKey={sessionKey}
|
||||
>
|
||||
<MainApp />
|
||||
</TradeNotificationProvider>
|
||||
</NavigationProvider>
|
||||
|
||||
@@ -6,16 +6,20 @@ import React, {
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
import {
|
||||
clearAuthSession,
|
||||
fetchAuthMe,
|
||||
getAuthSession,
|
||||
initStorage,
|
||||
refreshApiBaseFromStorage,
|
||||
setAuthSession,
|
||||
storageGetSync,
|
||||
storageRemove,
|
||||
type AuthSession,
|
||||
type LoginResponse,
|
||||
} from '../lib/shared';
|
||||
import { invalidateAppSettingsCache } from '../hooks/useAppSettings';
|
||||
import { invalidateAppSettingsCache, reloadAppSettingsCache } from '../hooks/useAppSettings';
|
||||
|
||||
function normalizeRole(role: string): AuthSession['role'] {
|
||||
return role === 'ADMIN' ? 'ADMIN' : 'USER';
|
||||
@@ -53,10 +57,22 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
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();
|
||||
if (stored) {
|
||||
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) {
|
||||
const session = loginToSession(me);
|
||||
setAuthSession(session);
|
||||
@@ -84,7 +100,9 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
setAuthUser(session);
|
||||
setGuestMode(false);
|
||||
invalidateAppSettingsCache();
|
||||
setSessionKey(k => k + 1);
|
||||
void reloadAppSettingsCache().finally(() => {
|
||||
setSessionKey(k => k + 1);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleGuestEnter = useCallback(() => {
|
||||
@@ -92,7 +110,9 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
setAuthUser(null);
|
||||
setGuestMode(true);
|
||||
invalidateAppSettingsCache();
|
||||
setSessionKey(k => k + 1);
|
||||
void reloadAppSettingsCache().finally(() => {
|
||||
setSessionKey(k => k + 1);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleLogout = useCallback(async () => {
|
||||
|
||||
@@ -4,4 +4,5 @@ export {
|
||||
subscribeAppSettings,
|
||||
resolveAppDefaults,
|
||||
invalidateAppSettingsCache,
|
||||
reloadAppSettingsCache,
|
||||
} from '@frontend/hooks/useAppSettings';
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
/** 웹 SplashScreen과 동일한 로그인 진입 화면 */
|
||||
import SplashScreen from '@frontend/components/SplashScreen';
|
||||
import type { LoginResponse } from '../lib/shared';
|
||||
import MobileLoginScreen from './MobileLoginScreen';
|
||||
|
||||
interface Props {
|
||||
onLoginSuccess: (res: LoginResponse) => void;
|
||||
@@ -8,5 +7,5 @@ interface Props {
|
||||
}
|
||||
|
||||
export default function LoginScreen({ onLoginSuccess, onGuest }: Props) {
|
||||
return <SplashScreen onLoginSuccess={onLoginSuccess} onGuest={onGuest} />;
|
||||
return <MobileLoginScreen onLoginSuccess={onLoginSuccess} onGuest={onGuest} />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* 모바일 로그인 — SplashScreen UI + 느린 서버 대응(타임아웃·안내 문구)
|
||||
*/
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { loginUser, type LoginResponse } from '../lib/shared';
|
||||
import '@frontend/styles/splashScreen.css';
|
||||
|
||||
const DEFAULT_USERNAME = 'admin';
|
||||
const DEFAULT_PASSWORD = 'admin';
|
||||
const APP_VERSION = '1.2';
|
||||
|
||||
interface Props {
|
||||
onLoginSuccess: (res: LoginResponse) => void;
|
||||
onGuest: () => void;
|
||||
}
|
||||
|
||||
export default function MobileLoginScreen({ onLoginSuccess, onGuest }: Props) {
|
||||
const [username, setUsername] = useState(DEFAULT_USERNAME);
|
||||
const [password, setPassword] = useState(DEFAULT_PASSWORD);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [slowHint, setSlowHint] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading) {
|
||||
setSlowHint(false);
|
||||
return;
|
||||
}
|
||||
const t = window.setTimeout(() => setSlowHint(true), 5000);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [loading]);
|
||||
|
||||
const submitLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await loginUser(username.trim(), password);
|
||||
onLoginSuccess(res);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '로그인에 실패했습니다.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="splash">
|
||||
<div className="splash-photo-bg" aria-hidden />
|
||||
<div className="splash-photo-overlay" aria-hidden />
|
||||
|
||||
<div className="splash-center">
|
||||
<div className="splash-glass">
|
||||
<h1 className="splash-brand">GoldenChart</h1>
|
||||
|
||||
<form className="splash-form" onSubmit={submitLogin}>
|
||||
<input
|
||||
className="splash-input"
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
value={username}
|
||||
onChange={ev => setUsername(ev.target.value)}
|
||||
disabled={loading}
|
||||
placeholder="ID"
|
||||
/>
|
||||
<input
|
||||
className="splash-input"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={ev => setPassword(ev.target.value)}
|
||||
disabled={loading}
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
{error && <p className="splash-error">{error}</p>}
|
||||
{slowHint && loading && !error && (
|
||||
<p className="splash-error" style={{ opacity: 0.85 }}>
|
||||
서버 응답이 느립니다. Wi‑Fi·모바일 데이터를 확인해 주세요…
|
||||
</p>
|
||||
)}
|
||||
<button type="submit" className="splash-login-btn" disabled={loading}>
|
||||
{loading ? '로그인 중…' : '로그인'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="splash-version">
|
||||
버전: {APP_VERSION} | Core Engine: Ta4j & Spring Boot
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button type="button" className="splash-guest-link" onClick={onGuest} disabled={loading}>
|
||||
게스트로 체험하기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTradeNotification } from '../../contexts/TradeNotificationContext';
|
||||
import { useNavigation } from '../../contexts/NavigationContext';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { useAppSettings, reloadAppSettingsCache } from '../../hooks/useAppSettings';
|
||||
import { useVirtualTradingCore } from '@frontend/hooks/useVirtualTradingCore';
|
||||
import NotificationListRow from './NotificationListRow';
|
||||
import NotificationDetailScreen from './NotificationDetailScreen';
|
||||
@@ -15,7 +16,8 @@ export default function NotificationsScreen() {
|
||||
goNotifyTrade,
|
||||
goVirtualDetail,
|
||||
} = useNavigation();
|
||||
const { sessionKey } = useAuth();
|
||||
const { sessionKey, guestMode, authUser } = useAuth();
|
||||
const { isLoaded: settingsLoaded } = useAppSettings(sessionKey);
|
||||
const {
|
||||
allNotifications,
|
||||
unreadCount,
|
||||
@@ -28,16 +30,48 @@ export default function NotificationsScreen() {
|
||||
const { summary, refreshPaperData } = useVirtualTradingCore({ settingsSessionKey: sessionKey });
|
||||
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [pullY, setPullY] = useState(0);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const touchStartY = useRef(0);
|
||||
|
||||
const selectedItem = useMemo(
|
||||
() => allNotifications.find(n => n.id === notifyNav.notifyId) ?? null,
|
||||
[allNotifications, notifyNav.notifyId],
|
||||
);
|
||||
|
||||
const onRefresh = async () => {
|
||||
const onRefresh = useCallback(async () => {
|
||||
if (refreshing) return;
|
||||
setRefreshing(true);
|
||||
await refreshHistory();
|
||||
setRefreshing(false);
|
||||
try {
|
||||
await reloadAppSettingsCache();
|
||||
await refreshHistory({ serverOnly: true, rawServerList: true });
|
||||
} finally {
|
||||
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) {
|
||||
@@ -70,39 +104,76 @@ export default function NotificationsScreen() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="screen">
|
||||
<div className="screen notify-screen">
|
||||
<header className="screen-header">
|
||||
<h1 className="screen-title">알림 {unreadCount > 0 && `(${unreadCount})`}</h1>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button type="button" className="chip" onClick={() => void onRefresh()}>{refreshing ? '…' : '↻'}</button>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<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>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{allNotifications.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<h3>알림 없음</h3>
|
||||
<p>전략 조건 충족 시 알림이 표시됩니다</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="stack-list" style={{ padding: '0 16px' }}>
|
||||
{allNotifications.map(item => (
|
||||
<NotificationListRow
|
||||
key={item.id}
|
||||
item={item}
|
||||
onDetail={() => {
|
||||
markAsRead(item.id);
|
||||
goNotifyDetail(item.id, item.market);
|
||||
}}
|
||||
onTrade={side => {
|
||||
markAsRead(item.id);
|
||||
goNotifyTrade(item.market, side);
|
||||
}}
|
||||
onDelete={() => void deleteNotification(item.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{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 ? (
|
||||
<div className="empty-state">
|
||||
<h3>알림 없음</h3>
|
||||
<p>전략 조건 충족 시 알림이 표시됩니다</p>
|
||||
<button type="button" className="btn-secondary" style={{ marginTop: 12 }} onClick={() => void onRefresh()}>
|
||||
새로고침
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="stack-list" style={{ padding: '0 16px' }}>
|
||||
{allNotifications.map(item => (
|
||||
<NotificationListRow
|
||||
key={item.id}
|
||||
item={item}
|
||||
onDetail={() => {
|
||||
markAsRead(item.id);
|
||||
goNotifyDetail(item.id, item.market);
|
||||
}}
|
||||
onTrade={side => {
|
||||
markAsRead(item.id);
|
||||
goNotifyTrade(item.market, side);
|
||||
}}
|
||||
onDelete={() => void deleteNotification(item.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{allNotifications.length > 0 && (
|
||||
<div style={{ padding: 16, textAlign: 'center' }}>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { useAppSettings } from '../../hooks/useAppSettings';
|
||||
import { useVirtualTradingCore } from '@frontend/hooks/useVirtualTradingCore';
|
||||
import { resolveVirtualTargetStrategyId } from '@frontend/utils/virtualTargetStrategy';
|
||||
import { useNavigation } from '../../contexts/NavigationContext';
|
||||
@@ -20,6 +21,7 @@ export default function VirtualTradingScreen() {
|
||||
goVirtualHistory,
|
||||
} = useNavigation();
|
||||
const { sessionKey: settingsSessionKey, authUser, guestMode } = useAuth();
|
||||
const { isLoaded: settingsLoaded } = useAppSettings(settingsSessionKey);
|
||||
|
||||
const core = useVirtualTradingCore({ settingsSessionKey });
|
||||
const {
|
||||
@@ -156,15 +158,15 @@ export default function VirtualTradingScreen() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="loading-center">로딩 중…</div>
|
||||
{!settingsLoaded || loading ? (
|
||||
<div className="loading-center">{!settingsLoaded ? '설정 동기화 중…' : '로딩 중…'}</div>
|
||||
) : targets.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<h3>투자 대상 없음</h3>
|
||||
{guestMode ? (
|
||||
<p>게스트 모드입니다. 웹과 같은 목록을 보려면 동일 계정으로 로그인하세요.</p>
|
||||
<p>게스트 모드입니다. 웹과 같은 목록을 보려면 <strong>동일 계정으로 로그인</strong>하세요.</p>
|
||||
) : (
|
||||
<p>웹과 동일 계정({authUser?.username}) · + 버튼으로 종목 추가</p>
|
||||
<p>웹({authUser?.username})과 동일 DB입니다. ↻ 동기화 또는 + 로 종목 추가</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
|
||||
+52
-14
@@ -5,6 +5,9 @@ import {
|
||||
import { PushNotifications } from '@capacitor/push-notifications';
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
|
||||
/** AndroidManifest default_notification_channel_id 와 동일 */
|
||||
export const FCM_CHANNEL_ID = 'goldenchart_trade_signals';
|
||||
|
||||
export interface FcmPayload {
|
||||
type?: string;
|
||||
market?: string;
|
||||
@@ -19,42 +22,77 @@ type FcmHandlers = {
|
||||
};
|
||||
|
||||
let initialized = false;
|
||||
let listenersAttached = false;
|
||||
|
||||
export async function initFcmPush(handlers: FcmHandlers = {}): Promise<boolean> {
|
||||
if (!Capacitor.isNativePlatform()) {
|
||||
console.info('[FCM] Web dev — native push skipped');
|
||||
return false;
|
||||
async function ensureAndroidNotificationChannel(): Promise<void> {
|
||||
if (Capacitor.getPlatform() !== 'android') return;
|
||||
try {
|
||||
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();
|
||||
if (perm.receive !== 'granted') return false;
|
||||
function attachListeners(handlers: FcmHandlers): void {
|
||||
if (listenersAttached) return;
|
||||
listenersAttached = true;
|
||||
|
||||
await PushNotifications.addListener('registration', async token => {
|
||||
void PushNotifications.addListener('registration', async token => {
|
||||
try {
|
||||
await registerFcmToken(token.value);
|
||||
console.info('[FCM] token registered');
|
||||
console.info('[FCM] token registered with server');
|
||||
} catch (e) {
|
||||
console.warn('[FCM] token register failed', e);
|
||||
}
|
||||
});
|
||||
|
||||
await PushNotifications.addListener('registrationError', err => {
|
||||
void PushNotifications.addListener('registrationError', err => {
|
||||
console.warn('[FCM] registration error', err);
|
||||
});
|
||||
|
||||
await PushNotifications.addListener('pushNotificationReceived', notification => {
|
||||
void PushNotifications.addListener('pushNotificationReceived', notification => {
|
||||
const title = notification.title ?? 'GoldenChart';
|
||||
const body = notification.body ?? '';
|
||||
handlers.onForeground?.(title, body, notification.data as FcmPayload);
|
||||
});
|
||||
|
||||
await PushNotifications.addListener('pushNotificationActionPerformed', action => {
|
||||
void PushNotifications.addListener('pushNotificationActionPerformed', action => {
|
||||
handlers.onTap?.(action.notification.data as FcmPayload);
|
||||
});
|
||||
}
|
||||
|
||||
await PushNotifications.register();
|
||||
initialized = true;
|
||||
/**
|
||||
* 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();
|
||||
initialized = true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user