검증게시판 단계 추가

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
@@ -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)
}
}