검증게시판 단계 추가
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
package com.analysis.goldenanalysis.ui
|
||||
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.navigation.fragment.NavHostFragment
|
||||
import androidx.navigation.ui.AppBarConfiguration
|
||||
import androidx.navigation.ui.setupActionBarWithNavController
|
||||
import androidx.navigation.ui.setupWithNavController
|
||||
import com.analysis.goldenanalysis.R
|
||||
import com.analysis.goldenanalysis.databinding.ActivityMainBinding
|
||||
import com.google.android.material.bottomnavigation.BottomNavigationView
|
||||
|
||||
class MainActivity : AppCompatActivity() {
|
||||
|
||||
private lateinit var binding: ActivityMainBinding
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
try {
|
||||
Log.d("MainActivity", "onCreate started")
|
||||
|
||||
binding = ActivityMainBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
|
||||
Log.d("MainActivity", "Layout inflated")
|
||||
|
||||
// Setup bottom navigation
|
||||
val navView: BottomNavigationView = binding.navView
|
||||
|
||||
Log.d("MainActivity", "Getting NavHostFragment")
|
||||
val navHostFragment = supportFragmentManager
|
||||
.findFragmentById(R.id.nav_host_fragment_activity_main) as? NavHostFragment
|
||||
|
||||
if (navHostFragment == null) {
|
||||
Log.e("MainActivity", "NavHostFragment not found!")
|
||||
Toast.makeText(this, "Navigation 초기화 실패", Toast.LENGTH_LONG).show()
|
||||
return
|
||||
}
|
||||
|
||||
val navController = navHostFragment.navController
|
||||
Log.d("MainActivity", "NavController obtained")
|
||||
|
||||
// Passing each menu ID as a set of Ids because each
|
||||
// menu should be considered as top level destinations.
|
||||
val appBarConfiguration = AppBarConfiguration(
|
||||
setOf(
|
||||
R.id.navigation_dashboard,
|
||||
R.id.navigation_portfolio,
|
||||
R.id.navigation_chart,
|
||||
R.id.navigation_alerts,
|
||||
R.id.navigation_scheduler,
|
||||
R.id.navigation_settings
|
||||
)
|
||||
)
|
||||
|
||||
try {
|
||||
setupActionBarWithNavController(navController, appBarConfiguration)
|
||||
navView.setupWithNavController(navController)
|
||||
Log.d("MainActivity", "Navigation setup complete")
|
||||
} catch (e: Exception) {
|
||||
Log.e("MainActivity", "Error setting up navigation", e)
|
||||
// Continue without action bar
|
||||
navView.setupWithNavController(navController)
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e("MainActivity", "Fatal error in onCreate", e)
|
||||
Toast.makeText(this, "앱 초기화 중 오류 발생: ${e.message}", Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.analysis.goldenanalysis.ui
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import com.analysis.goldenanalysis.R
|
||||
|
||||
class SplashActivity : AppCompatActivity() {
|
||||
|
||||
private val splashTimeOut: Long = 2000 // 2 seconds
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
try {
|
||||
Log.d("SplashActivity", "onCreate started")
|
||||
setContentView(R.layout.activity_splash)
|
||||
Log.d("SplashActivity", "Layout set")
|
||||
|
||||
// Navigate to MainActivity after splash timeout
|
||||
Handler(Looper.getMainLooper()).postDelayed({
|
||||
try {
|
||||
Log.d("SplashActivity", "Starting MainActivity")
|
||||
val intent = Intent(this, MainActivity::class.java)
|
||||
startActivity(intent)
|
||||
finish()
|
||||
} catch (e: Exception) {
|
||||
Log.e("SplashActivity", "Error starting MainActivity", e)
|
||||
}
|
||||
}, splashTimeOut)
|
||||
} catch (e: Exception) {
|
||||
Log.e("SplashActivity", "Error in onCreate", e)
|
||||
// Try to start MainActivity directly
|
||||
try {
|
||||
val intent = Intent(this, MainActivity::class.java)
|
||||
startActivity(intent)
|
||||
finish()
|
||||
} catch (e2: Exception) {
|
||||
Log.e("SplashActivity", "Failed to recover", e2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
package com.analysis.goldenanalysis.ui.alert
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import com.analysis.goldenanalysis.R
|
||||
import com.analysis.goldenanalysis.data.api.ApiClientFactory
|
||||
import com.analysis.goldenanalysis.data.model.AlertNotificationDto
|
||||
import com.analysis.goldenanalysis.databinding.FragmentAlertsBinding
|
||||
import com.analysis.goldenanalysis.databinding.ItemAlertPopupCardBinding
|
||||
import com.analysis.goldenanalysis.databinding.ItemAlertStockRowBinding
|
||||
import com.analysis.goldenanalysis.util.AlertMessageParser
|
||||
import com.analysis.goldenanalysis.util.AlertSignalInference
|
||||
import com.analysis.goldenanalysis.util.UpbitWebUrls
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* 미읽음 알림 — 프론트 AlertSnackbar와 유사한 카드 스택 UI
|
||||
*/
|
||||
class AlertsFragment : Fragment() {
|
||||
|
||||
private var _binding: FragmentAlertsBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
|
||||
private var cryptoMap: Map<String, String> = emptyMap()
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?,
|
||||
): View {
|
||||
_binding = FragmentAlertsBinding.inflate(inflater, container, false)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
(activity as? AppCompatActivity)?.setSupportActionBar(binding.toolbar)
|
||||
binding.toolbar.inflateMenu(R.menu.menu_alerts)
|
||||
binding.toolbar.setOnMenuItemClickListener(::onMenuItem)
|
||||
|
||||
binding.swipeRefresh.setOnRefreshListener { loadAll() }
|
||||
loadAll()
|
||||
}
|
||||
|
||||
private fun onMenuItem(item: MenuItem): Boolean {
|
||||
if (item.itemId == R.id.action_all_notifications) {
|
||||
findNavController().navigate(R.id.action_alerts_to_notification_list)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun loadAll() {
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
binding.progress.visibility = View.VISIBLE
|
||||
binding.tvEmpty.visibility = View.GONE
|
||||
try {
|
||||
val api = ApiClientFactory.alertApi(requireContext())
|
||||
val cryptos = withContext(Dispatchers.IO) {
|
||||
runCatching { api.getCryptoList() }.getOrElse { emptyList() }
|
||||
}
|
||||
cryptoMap = cryptos.associate { it.koreanName to it.symbol }
|
||||
val unread = withContext(Dispatchers.IO) {
|
||||
runCatching { api.getUnreadNotifications(null) }.getOrElse { emptyList() }
|
||||
}
|
||||
renderCards(unread)
|
||||
} catch (e: Exception) {
|
||||
Toast.makeText(requireContext(), "알림 로드 실패: ${e.message}", Toast.LENGTH_LONG).show()
|
||||
binding.tvEmpty.visibility = View.VISIBLE
|
||||
} finally {
|
||||
binding.progress.visibility = View.GONE
|
||||
binding.swipeRefresh.isRefreshing = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun renderCards(notifications: List<AlertNotificationDto>) {
|
||||
binding.cardsContainer.removeAllViews()
|
||||
if (notifications.isEmpty()) {
|
||||
binding.tvEmpty.visibility = View.VISIBLE
|
||||
return
|
||||
}
|
||||
binding.tvEmpty.visibility = View.GONE
|
||||
val inflater = layoutInflater
|
||||
notifications.forEach { n ->
|
||||
val card = ItemAlertPopupCardBinding.inflate(inflater, binding.cardsContainer, false)
|
||||
val (title, pairs) = AlertMessageParser.titleAndPairs(n)
|
||||
card.tvCardTitle.text = title
|
||||
|
||||
card.btnClose.setOnClickListener {
|
||||
n.id?.let { id -> dismissNotification(id) }
|
||||
}
|
||||
card.btnInfo.setOnClickListener {
|
||||
n.id?.let { id ->
|
||||
SignalDetailBottomSheet.newInstance(
|
||||
notificationId = id,
|
||||
rowSymbol = n.symbol,
|
||||
signal = AlertSignalInference.inferFromNotification(n, null) ?: "BUY",
|
||||
).show(parentFragmentManager, "signal_detail")
|
||||
} ?: Toast.makeText(requireContext(), "알림 ID 없음", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
if (pairs.isNotEmpty()) {
|
||||
card.scrollRows.visibility = View.VISIBLE
|
||||
card.tvSingleLineMessage.visibility = View.GONE
|
||||
card.containerRows.removeAllViews()
|
||||
pairs.forEach { pair ->
|
||||
val row = ItemAlertStockRowBinding.inflate(inflater, card.containerRows, false)
|
||||
val resolved = AlertMessageParser.resolveStockStrategyPairRow(pair, cryptoMap)
|
||||
row.tvKoreanName.text = resolved.koreanName
|
||||
row.tvSymbol.text = resolved.displaySymbol
|
||||
|
||||
val marketSym = resolved.navSymbol
|
||||
val upbitUrl = UpbitWebUrls.getUpbitWebExchangeUrl(marketSym, n.timeInterval)
|
||||
|
||||
row.btnUpbit.setOnClickListener {
|
||||
if (upbitUrl != null) {
|
||||
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(upbitUrl)))
|
||||
} else {
|
||||
Toast.makeText(requireContext(), "업비트 URL을 만들 수 없습니다.", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
row.btnAlertList.setOnClickListener {
|
||||
openAlertListForSymbol(marketSym, resolved.koreanName, n)
|
||||
}
|
||||
row.root.setOnClickListener {
|
||||
openAlertListForSymbol(marketSym, resolved.koreanName, n)
|
||||
}
|
||||
|
||||
row.btnSignalDetail.setOnClickListener {
|
||||
val nid = n.id ?: return@setOnClickListener
|
||||
val sig = AlertSignalInference.inferFromNotification(n, resolved.koreanName) ?: "BUY"
|
||||
SignalDetailBottomSheet.newInstance(nid, marketSym, sig)
|
||||
.show(parentFragmentManager, "signal_detail")
|
||||
}
|
||||
|
||||
row.root.layoutParams = LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||
)
|
||||
card.containerRows.addView(row.root)
|
||||
}
|
||||
} else {
|
||||
card.scrollRows.visibility = View.GONE
|
||||
card.tvSingleLineMessage.visibility = View.VISIBLE
|
||||
card.tvSingleLineMessage.text =
|
||||
n.message?.split("\n")?.firstOrNull()?.take(200).orEmpty()
|
||||
}
|
||||
|
||||
binding.cardsContainer.addView(card.root)
|
||||
}
|
||||
}
|
||||
|
||||
private fun openAlertListForSymbol(
|
||||
marketSym: String?,
|
||||
koreanName: String,
|
||||
notification: AlertNotificationDto,
|
||||
) {
|
||||
val prefs = requireContext().getSharedPreferences("app_prefs", Context.MODE_PRIVATE)
|
||||
marketSym?.let {
|
||||
prefs.edit()
|
||||
.putString("pending_chart_symbol", it)
|
||||
.putString("pending_chart_korean", koreanName)
|
||||
.apply()
|
||||
}
|
||||
findNavController().navigate(
|
||||
R.id.action_alerts_to_notification_list,
|
||||
bundleOf(
|
||||
"filterSymbol" to (marketSym ?: ""),
|
||||
"filterKorean" to koreanName,
|
||||
"highlightId" to (notification.id ?: 0L),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun dismissNotification(id: Long) {
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
try {
|
||||
withContext(Dispatchers.IO) {
|
||||
ApiClientFactory.alertApi(requireContext()).markAsRead(id)
|
||||
}
|
||||
loadAll()
|
||||
} catch (e: Exception) {
|
||||
Toast.makeText(requireContext(), "닫기 실패: ${e.message}", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
_binding = null
|
||||
}
|
||||
}
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
package com.analysis.goldenanalysis.ui.alert
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.LinearLayout
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.analysis.goldenanalysis.data.model.AlertNotificationDto
|
||||
import com.analysis.goldenanalysis.databinding.ItemAlertStockRowBinding
|
||||
import com.analysis.goldenanalysis.databinding.ItemNotificationHistoryBinding
|
||||
import com.analysis.goldenanalysis.util.AlertMessageParser
|
||||
import com.analysis.goldenanalysis.util.AlertSignalInference
|
||||
|
||||
class NotificationHistoryAdapter(
|
||||
private val onUpbit: (symbol: String?, timeInterval: String?) -> Unit,
|
||||
private val onAlertListRow: (notification: AlertNotificationDto, symbol: String?, korean: String) -> Unit,
|
||||
private val onSignalDetail: (notificationId: Long, rowSymbol: String?, signal: String) -> Unit,
|
||||
private val onDelete: (Long) -> Unit,
|
||||
) : RecyclerView.Adapter<NotificationHistoryAdapter.VH>() {
|
||||
|
||||
private val items = mutableListOf<AlertNotificationDto>()
|
||||
private var filterSymbol: String = ""
|
||||
private var filterKorean: String = ""
|
||||
private var cryptoMap: Map<String, String> = emptyMap()
|
||||
|
||||
fun setFilter(symbol: String, korean: String) {
|
||||
filterSymbol = symbol.trim()
|
||||
filterKorean = korean.trim()
|
||||
}
|
||||
|
||||
fun submit(list: List<AlertNotificationDto>, map: Map<String, String>) {
|
||||
cryptoMap = map
|
||||
items.clear()
|
||||
val filtered = list.filter { n ->
|
||||
if (filterSymbol.isEmpty() && filterKorean.isEmpty()) return@filter true
|
||||
val msg = n.message.orEmpty()
|
||||
if (filterSymbol.isNotEmpty() && msg.contains(filterSymbol, ignoreCase = true)) return@filter true
|
||||
if (filterKorean.isNotEmpty() && msg.contains(filterKorean)) return@filter true
|
||||
if (filterSymbol.isNotEmpty() && n.symbol.equals(filterSymbol, ignoreCase = true)) return@filter true
|
||||
false
|
||||
}
|
||||
items.addAll(filtered)
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH {
|
||||
val binding = ItemNotificationHistoryBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||
return VH(binding)
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int = items.size
|
||||
|
||||
override fun onBindViewHolder(holder: VH, position: Int) {
|
||||
holder.bind(items[position])
|
||||
}
|
||||
|
||||
inner class VH(private val binding: ItemNotificationHistoryBinding) : RecyclerView.ViewHolder(binding.root) {
|
||||
|
||||
fun bind(n: AlertNotificationDto) {
|
||||
val (title, pairs) = AlertMessageParser.titleAndPairs(n)
|
||||
binding.tvTitle.text = title
|
||||
binding.tvTime.text = n.triggeredAt ?: n.notifiedAt ?: ""
|
||||
|
||||
binding.btnDelete.setOnClickListener {
|
||||
n.id?.let { onDelete(it) }
|
||||
}
|
||||
|
||||
binding.containerRows.removeAllViews()
|
||||
if (pairs.isEmpty()) {
|
||||
binding.containerRows.visibility = View.GONE
|
||||
} else {
|
||||
binding.containerRows.visibility = View.VISIBLE
|
||||
val inflater = LayoutInflater.from(binding.root.context)
|
||||
pairs.forEach { pair ->
|
||||
val resolved = AlertMessageParser.resolveStockStrategyPairRow(pair, cryptoMap)
|
||||
val row = ItemAlertStockRowBinding.inflate(inflater, binding.containerRows, false)
|
||||
row.tvKoreanName.text = resolved.koreanName
|
||||
row.tvSymbol.text = resolved.displaySymbol
|
||||
val sym = resolved.navSymbol
|
||||
row.btnUpbit.setOnClickListener { onUpbit(sym, n.timeInterval) }
|
||||
row.btnAlertList.setOnClickListener {
|
||||
onAlertListRow(n, sym, resolved.koreanName)
|
||||
}
|
||||
row.root.setOnClickListener {
|
||||
onAlertListRow(n, sym, resolved.koreanName)
|
||||
}
|
||||
row.btnSignalDetail.setOnClickListener {
|
||||
val id = n.id ?: return@setOnClickListener
|
||||
val sig = AlertSignalInference.inferFromNotification(n, resolved.koreanName) ?: "BUY"
|
||||
onSignalDetail(id, sym, sig)
|
||||
}
|
||||
row.root.layoutParams = LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||
)
|
||||
binding.containerRows.addView(row.root)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
package com.analysis.goldenanalysis.ui.alert
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import com.analysis.goldenanalysis.R
|
||||
import com.analysis.goldenanalysis.data.api.ApiClientFactory
|
||||
import com.analysis.goldenanalysis.data.model.AlertNotificationDto
|
||||
import com.analysis.goldenanalysis.databinding.FragmentNotificationListBinding
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* 전체 알림 목록 (프론트 NotificationListPage 유사)
|
||||
*/
|
||||
class NotificationListFragment : Fragment() {
|
||||
|
||||
private var _binding: FragmentNotificationListBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
|
||||
private lateinit var adapter: NotificationHistoryAdapter
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?,
|
||||
): View {
|
||||
_binding = FragmentNotificationListBinding.inflate(inflater, container, false)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
(activity as? AppCompatActivity)?.setSupportActionBar(binding.toolbar)
|
||||
binding.toolbar.setNavigationIcon(com.analysis.goldenanalysis.R.drawable.ic_arrow_back)
|
||||
binding.toolbar.setNavigationOnClickListener { findNavController().navigateUp() }
|
||||
binding.toolbar.title = getString(R.string.alerts_menu_all)
|
||||
|
||||
adapter = NotificationHistoryAdapter(
|
||||
onUpbit = { sym, interval ->
|
||||
val url = com.analysis.goldenanalysis.util.UpbitWebUrls.getUpbitWebExchangeUrl(sym, interval)
|
||||
if (url != null) startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
|
||||
else Toast.makeText(requireContext(), "업비트 URL 오류", Toast.LENGTH_SHORT).show()
|
||||
},
|
||||
onAlertListRow = { _, _, _ -> /* 목록 화면 내 동작 */ },
|
||||
onSignalDetail = { id, sym, signal ->
|
||||
SignalDetailBottomSheet.newInstance(id, sym, signal)
|
||||
.show(parentFragmentManager, "signal_detail")
|
||||
},
|
||||
onDelete = { id -> deleteNotification(id) },
|
||||
)
|
||||
binding.recycler.layoutManager = LinearLayoutManager(requireContext())
|
||||
binding.recycler.adapter = adapter
|
||||
|
||||
val filterSym = arguments?.getString("filterSymbol").orEmpty()
|
||||
val filterKr = arguments?.getString("filterKorean").orEmpty()
|
||||
adapter.setFilter(filterSym, filterKr)
|
||||
|
||||
binding.swipeRefresh.setOnRefreshListener { loadPage() }
|
||||
loadPage()
|
||||
}
|
||||
|
||||
private fun loadPage() {
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
binding.progress.visibility = View.VISIBLE
|
||||
try {
|
||||
val api = ApiClientFactory.alertApi(requireContext())
|
||||
val (page, map) = withContext(Dispatchers.IO) {
|
||||
val p = runCatching { api.getNotifications(null, 0, 50) }.getOrNull()
|
||||
val m = runCatching { api.getCryptoList().associate { it.koreanName to it.symbol } }
|
||||
.getOrElse { emptyMap() }
|
||||
p to m
|
||||
}
|
||||
val list = page?.content.orEmpty()
|
||||
adapter.submit(list, map)
|
||||
binding.tvEmpty.visibility = if (adapter.itemCount == 0) View.VISIBLE else View.GONE
|
||||
} catch (e: Exception) {
|
||||
Toast.makeText(requireContext(), "목록 로드 실패: ${e.message}", Toast.LENGTH_LONG).show()
|
||||
} finally {
|
||||
binding.progress.visibility = View.GONE
|
||||
binding.swipeRefresh.isRefreshing = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun deleteNotification(id: Long) {
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
try {
|
||||
withContext(Dispatchers.IO) {
|
||||
ApiClientFactory.alertApi(requireContext()).deleteNotification(id)
|
||||
}
|
||||
loadPage()
|
||||
} catch (e: Exception) {
|
||||
Toast.makeText(requireContext(), "삭제 실패: ${e.message}", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
_binding = null
|
||||
}
|
||||
|
||||
}
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
package com.analysis.goldenanalysis.ui.alert
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.Toast
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.analysis.goldenanalysis.data.api.ApiClientFactory
|
||||
import com.analysis.goldenanalysis.data.model.AlertNotificationDetailDto
|
||||
import com.analysis.goldenanalysis.databinding.BottomSheetSignalDetailBinding
|
||||
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class SignalDetailBottomSheet : BottomSheetDialogFragment() {
|
||||
|
||||
private var _binding: BottomSheetSignalDetailBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?,
|
||||
): View {
|
||||
_binding = BottomSheetSignalDetailBinding.inflate(inflater, container, false)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
val id = arguments?.getLong(ARG_ID) ?: return dismiss()
|
||||
val symbol = arguments?.getString(ARG_SYMBOL)
|
||||
val signal = arguments?.getString(ARG_SIGNAL) ?: "BUY"
|
||||
binding.tvTitle.text = if (signal == "SELL") "📉 매도 신호 상세" else "📈 매수 신호 상세"
|
||||
binding.tvBody.text = getString(com.analysis.goldenanalysis.R.string.loading)
|
||||
binding.btnClose.setOnClickListener { dismiss() }
|
||||
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
try {
|
||||
val api = ApiClientFactory.alertApi(requireContext())
|
||||
val detail = withContext(Dispatchers.IO) {
|
||||
api.getNotificationDetail(id, symbol?.trim()?.takeIf { it.isNotEmpty() })
|
||||
}
|
||||
binding.tvBody.text = buildDetailText(detail)
|
||||
} catch (e: Exception) {
|
||||
binding.tvBody.text = e.message ?: "오류"
|
||||
Toast.makeText(requireContext(), "상세를 불러오지 못했습니다.", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildDetailText(d: AlertNotificationDetailDto): String {
|
||||
val sb = StringBuilder()
|
||||
d.koreanName?.let { sb.appendLine("종목: $it (${d.symbol ?: ""})") }
|
||||
d.message?.let { sb.appendLine().appendLine(it) }
|
||||
d.conditionDescription?.let { sb.appendLine().appendLine("조건: $it") }
|
||||
d.timeInterval?.let { sb.appendLine("시간봉: $it") }
|
||||
val ind = d.indicatorData
|
||||
if (ind != null) {
|
||||
sb.appendLine().appendLine("— 지표 스냅샷 —")
|
||||
ind.time?.let { sb.appendLine("시간: $it") }
|
||||
ind.price?.let { sb.appendLine("가격: $it") }
|
||||
ind.rsi?.let { sb.appendLine("RSI: $it") }
|
||||
ind.macdLine?.let { sb.appendLine("MACD: $it") }
|
||||
ind.signalLine?.let { sb.appendLine("시그널: $it") }
|
||||
ind.cci?.let { sb.appendLine("CCI: $it") }
|
||||
}
|
||||
d.snapshotClose?.let { sb.appendLine().appendLine("봉 종가(스냅): $it") }
|
||||
val conds = d.satisfiedConditions.orEmpty()
|
||||
if (conds.isNotEmpty()) {
|
||||
sb.appendLine().appendLine("— 충족 조건 —")
|
||||
conds.forEach { c ->
|
||||
sb.appendLine(
|
||||
listOfNotNull(c.indicatorType, c.conditionType, c.description)
|
||||
.joinToString(" ")
|
||||
)
|
||||
}
|
||||
}
|
||||
return sb.toString().trim()
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
_binding = null
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val ARG_ID = "id"
|
||||
private const val ARG_SYMBOL = "symbol"
|
||||
private const val ARG_SIGNAL = "signal"
|
||||
|
||||
fun newInstance(notificationId: Long, rowSymbol: String?, signal: String): SignalDetailBottomSheet {
|
||||
val f = SignalDetailBottomSheet()
|
||||
f.arguments = bundleOf(
|
||||
ARG_ID to notificationId,
|
||||
ARG_SYMBOL to rowSymbol,
|
||||
ARG_SIGNAL to signal,
|
||||
)
|
||||
return f
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
package com.analysis.goldenanalysis.ui.chart
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.net.http.SslError
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.webkit.JavascriptInterface
|
||||
import android.webkit.SslErrorHandler
|
||||
import android.webkit.WebChromeClient
|
||||
import android.webkit.WebSettings
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import android.widget.Toast
|
||||
import androidx.fragment.app.Fragment
|
||||
import com.analysis.goldenanalysis.BuildConfig
|
||||
import com.analysis.goldenanalysis.databinding.FragmentChartBinding
|
||||
|
||||
/**
|
||||
* 차트 Fragment
|
||||
* - Frontend의 모바일 차트 페이지를 WebView로 표시
|
||||
*/
|
||||
class ChartFragment : Fragment() {
|
||||
|
||||
private var _binding: FragmentChartBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
|
||||
private val TAG = "ChartFragment"
|
||||
private val JS_TAG = "ChartFragment-JS"
|
||||
|
||||
private fun getFrontendBaseUrl(): String {
|
||||
val prefs = requireActivity().getSharedPreferences("app_prefs", Context.MODE_PRIVATE)
|
||||
return prefs.getString("frontend_url", "http://exdev.co.kr/mobile/chart")
|
||||
?: "http://exdev.co.kr/mobile/chart"
|
||||
}
|
||||
|
||||
/** 알림에서 저장한 심볼이 있으면 쿼리 gaSymbol 붙이고 prefs 에서 제거 (프론트 연동 시 사용). */
|
||||
private fun buildUrlConsumingPendingChartSymbol(): String {
|
||||
val prefs = requireActivity().getSharedPreferences("app_prefs", Context.MODE_PRIVATE)
|
||||
val base = getFrontendBaseUrl()
|
||||
val sym = prefs.getString("pending_chart_symbol", null)?.trim().orEmpty()
|
||||
if (sym.isNotEmpty()) {
|
||||
prefs.edit()
|
||||
.remove("pending_chart_symbol")
|
||||
.remove("pending_chart_korean")
|
||||
.apply()
|
||||
val sep = if (base.contains("?")) "&" else "?"
|
||||
return "$base${sep}gaSymbol=${Uri.encode(sym)}"
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
Log.d(TAG, "onCreateView")
|
||||
_binding = FragmentChartBinding.inflate(inflater, container, false)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
Log.d(TAG, "onViewCreated")
|
||||
|
||||
try {
|
||||
// Frontend 모바일 페이지를 WebView로 로드
|
||||
setupWebView()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error in onViewCreated", e)
|
||||
Toast.makeText(context, "초기화 오류: ${e.message}", Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
private fun setupWebView() {
|
||||
binding.webView.apply {
|
||||
webViewClient = object : WebViewClient() {
|
||||
override fun onPageStarted(view: WebView?, url: String?, favicon: android.graphics.Bitmap?) {
|
||||
super.onPageStarted(view, url, favicon)
|
||||
Log.d(TAG, "Page started loading: $url")
|
||||
binding.progressBar.visibility = View.VISIBLE
|
||||
}
|
||||
|
||||
override fun onPageFinished(view: WebView?, url: String?) {
|
||||
super.onPageFinished(view, url)
|
||||
Log.d(TAG, "Page finished loading: $url")
|
||||
|
||||
// JavaScript로 페이지 내용 확인
|
||||
view?.evaluateJavascript("document.body.innerHTML.length") { result ->
|
||||
Log.d(TAG, "Page content length: $result")
|
||||
if (result == "0" || result == "null") {
|
||||
Log.e(TAG, "Page loaded but content is empty!")
|
||||
Toast.makeText(context, "페이지가 비어있습니다", Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
|
||||
binding.progressBar.visibility = View.GONE
|
||||
}
|
||||
|
||||
override fun onReceivedError(
|
||||
view: WebView?,
|
||||
errorCode: Int,
|
||||
description: String?,
|
||||
failingUrl: String?
|
||||
) {
|
||||
super.onReceivedError(view, errorCode, description, failingUrl)
|
||||
Log.e(TAG, "WebView Error: code=$errorCode, desc=$description, url=$failingUrl")
|
||||
Toast.makeText(context, "페이지 로딩 오류 ($errorCode): $description", Toast.LENGTH_LONG).show()
|
||||
binding.progressBar.visibility = View.GONE
|
||||
}
|
||||
|
||||
@Deprecated("Deprecated in API level 23")
|
||||
override fun onReceivedError(
|
||||
view: WebView?,
|
||||
request: android.webkit.WebResourceRequest?,
|
||||
error: android.webkit.WebResourceError?
|
||||
) {
|
||||
super.onReceivedError(view, request, error)
|
||||
if (request?.isForMainFrame == true) {
|
||||
Log.e(TAG, "WebView Resource Error: ${error?.description}")
|
||||
}
|
||||
}
|
||||
|
||||
// HTTPS 오류 무시 (개발 환경에서만 사용 권장)
|
||||
override fun onReceivedSslError(view: WebView?, handler: SslErrorHandler?, error: SslError?) {
|
||||
Log.w(TAG, "SSL Error: ${error?.primaryError} on ${error?.url}. Proceeding anyway.")
|
||||
handler?.proceed() // 인증서 무시
|
||||
}
|
||||
}
|
||||
|
||||
webChromeClient = object : WebChromeClient() {
|
||||
override fun onConsoleMessage(consoleMessage: android.webkit.ConsoleMessage?): Boolean {
|
||||
Log.d(JS_TAG, "${consoleMessage?.message()} -- From ${consoleMessage?.sourceId()} : ${consoleMessage?.lineNumber()}")
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onProgressChanged(view: WebView?, newProgress: Int) {
|
||||
super.onProgressChanged(view, newProgress)
|
||||
if (newProgress < 100) {
|
||||
binding.progressBar.visibility = View.VISIBLE
|
||||
} else {
|
||||
binding.progressBar.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
settings.apply {
|
||||
javaScriptEnabled = true
|
||||
domStorageEnabled = true
|
||||
cacheMode = WebSettings.LOAD_NO_CACHE // 캐시 비활성화 (디버깅용)
|
||||
loadWithOverviewMode = true
|
||||
useWideViewPort = true
|
||||
builtInZoomControls = false // 모바일에서는 줌 컨트롤 비활성화
|
||||
displayZoomControls = false
|
||||
setSupportZoom(true)
|
||||
allowFileAccess = true
|
||||
allowContentAccess = true
|
||||
databaseEnabled = true
|
||||
|
||||
// 네트워크 설정
|
||||
mixedContentMode = android.webkit.WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
|
||||
|
||||
// User Agent 설정 (모바일 명시)
|
||||
userAgentString = userAgentString + " GoldenAnalysisMobile/1.0"
|
||||
|
||||
// WebView 디버깅 활성화 (크롬 개발자 도구에서 확인 가능)
|
||||
if (BuildConfig.DEBUG) {
|
||||
WebView.setWebContentsDebuggingEnabled(true)
|
||||
}
|
||||
}
|
||||
|
||||
// JavaScript 인터페이스 추가
|
||||
addJavascriptInterface(WebAppInterface(requireContext()), "Android")
|
||||
|
||||
// Frontend 모바일 페이지 로드
|
||||
val frontendUrl = buildUrlConsumingPendingChartSymbol()
|
||||
Log.d(TAG, "Loading Frontend URL: $frontendUrl")
|
||||
binding.progressBar.visibility = View.VISIBLE
|
||||
|
||||
// WebView 강제 재개
|
||||
onResume()
|
||||
resumeTimers()
|
||||
|
||||
// 약간의 지연 후 로드 (WebView 초기화 대기)
|
||||
post {
|
||||
Log.d(TAG, "Starting to load URL...")
|
||||
loadUrl(frontendUrl)
|
||||
Log.d(TAG, "loadUrl() called")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
Log.d(TAG, "Fragment onResume")
|
||||
try {
|
||||
binding.webView.onResume()
|
||||
binding.webView.resumeTimers()
|
||||
// 알림 탭 등에서 pending_chart_symbol 저장 후 차트 탭으로 온 경우
|
||||
val prefs = requireActivity().getSharedPreferences("app_prefs", Context.MODE_PRIVATE)
|
||||
if (!prefs.getString("pending_chart_symbol", null).isNullOrBlank()) {
|
||||
val url = buildUrlConsumingPendingChartSymbol()
|
||||
Log.d(TAG, "Reload chart URL: $url")
|
||||
binding.webView.loadUrl(url)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error in onResume", e)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
Log.d(TAG, "Fragment onPause")
|
||||
try {
|
||||
binding.webView.onPause()
|
||||
binding.webView.pauseTimers()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error in onPause", e)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
binding.webView.destroy()
|
||||
_binding = null
|
||||
}
|
||||
|
||||
// JavaScript에서 호출할 인터페이스
|
||||
class WebAppInterface(private val mContext: Context) {
|
||||
@JavascriptInterface
|
||||
fun showToast(toast: String) {
|
||||
Toast.makeText(mContext, toast, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun log(message: String) {
|
||||
Log.d("ChartFragment-JS", message)
|
||||
}
|
||||
}
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
package com.analysis.goldenanalysis.ui.dashboard
|
||||
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.fragment.app.Fragment
|
||||
import com.analysis.goldenanalysis.R
|
||||
import com.analysis.goldenanalysis.databinding.FragmentDashboardBinding
|
||||
|
||||
class DashboardFragment : Fragment() {
|
||||
|
||||
private var _binding: FragmentDashboardBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
Log.d("DashboardFragment", "onCreateView")
|
||||
_binding = FragmentDashboardBinding.inflate(inflater, container, false)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
Log.d("DashboardFragment", "onViewCreated")
|
||||
try {
|
||||
setupViews()
|
||||
} catch (e: Exception) {
|
||||
Log.e("DashboardFragment", "Error in setupViews", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupViews() {
|
||||
// TODO: Implement dashboard data loading from API
|
||||
try {
|
||||
binding.tvTotalAssets.text = "0원"
|
||||
binding.tvProfitLoss.text = "0.00%"
|
||||
binding.tvTodayChange.text = "0원"
|
||||
} catch (e: Exception) {
|
||||
Log.e("DashboardFragment", "Error setting text", e)
|
||||
// Views might not exist in layout
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
_binding = null
|
||||
}
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
package com.analysis.goldenanalysis.ui.portfolio
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.fragment.app.Fragment
|
||||
import com.analysis.goldenanalysis.databinding.FragmentPortfolioBinding
|
||||
|
||||
class PortfolioFragment : Fragment() {
|
||||
|
||||
private var _binding: FragmentPortfolioBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
_binding = FragmentPortfolioBinding.inflate(inflater, container, false)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
setupViews()
|
||||
}
|
||||
|
||||
private fun setupViews() {
|
||||
binding.fabAdd.setOnClickListener {
|
||||
// TODO: Open add portfolio item dialog
|
||||
}
|
||||
|
||||
// TODO: Load portfolio items from API
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
_binding = null
|
||||
}
|
||||
}
|
||||
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
package com.analysis.goldenanalysis.ui.scheduler
|
||||
|
||||
import android.app.Dialog
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ArrayAdapter
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import com.analysis.goldenanalysis.R
|
||||
import com.analysis.goldenanalysis.data.model.Schedule
|
||||
import com.analysis.goldenanalysis.databinding.DialogScheduleBinding
|
||||
|
||||
class ScheduleDialogFragment : DialogFragment() {
|
||||
|
||||
private var _binding: DialogScheduleBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
|
||||
private var schedule: Schedule? = null
|
||||
private var onSaveListener: ((Schedule) -> Unit)? = null
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setStyle(STYLE_NORMAL, R.style.Theme_GoldenAnalysis)
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
_binding = DialogScheduleBinding.inflate(inflater, container, false)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
setupSpinners()
|
||||
setupListeners()
|
||||
|
||||
// If editing existing schedule
|
||||
schedule?.let { populateFields(it) }
|
||||
}
|
||||
|
||||
private fun setupSpinners() {
|
||||
// Interval spinner
|
||||
val intervals = arrayOf("1분", "5분", "15분", "30분", "1시간", "4시간", "1일")
|
||||
val intervalAdapter = ArrayAdapter(requireContext(), android.R.layout.simple_spinner_item, intervals)
|
||||
intervalAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
|
||||
binding.spinnerInterval.adapter = intervalAdapter
|
||||
|
||||
// Notification method spinner
|
||||
val methods = arrayOf("앱 알림", "이메일", "SMS")
|
||||
val methodAdapter = ArrayAdapter(requireContext(), android.R.layout.simple_spinner_item, methods)
|
||||
methodAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
|
||||
binding.spinnerNotification.adapter = methodAdapter
|
||||
}
|
||||
|
||||
private fun setupListeners() {
|
||||
binding.btnSave.setOnClickListener {
|
||||
saveSchedule()
|
||||
}
|
||||
|
||||
binding.btnCancel.setOnClickListener {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
private fun populateFields(schedule: Schedule) {
|
||||
binding.apply {
|
||||
etScheduleName.setText(schedule.name)
|
||||
|
||||
// Set spinner selections
|
||||
val intervalPos = (spinnerInterval.adapter as ArrayAdapter<String>)
|
||||
.getPosition(schedule.interval)
|
||||
if (intervalPos >= 0) spinnerInterval.setSelection(intervalPos)
|
||||
|
||||
val methodPos = (spinnerNotification.adapter as ArrayAdapter<String>)
|
||||
.getPosition(schedule.notificationMethod)
|
||||
if (methodPos >= 0) spinnerNotification.setSelection(methodPos)
|
||||
|
||||
etIndicators.setText(schedule.indicators)
|
||||
etConditions.setText(schedule.conditions)
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveSchedule() {
|
||||
val name = binding.etScheduleName.text.toString()
|
||||
if (name.isBlank()) {
|
||||
binding.etScheduleName.error = "스케쥴 이름을 입력하세요"
|
||||
return
|
||||
}
|
||||
|
||||
val newSchedule = Schedule(
|
||||
id = schedule?.id ?: 0,
|
||||
name = name,
|
||||
interval = binding.spinnerInterval.selectedItem.toString(),
|
||||
indicators = binding.etIndicators.text.toString(),
|
||||
notificationMethod = binding.spinnerNotification.selectedItem.toString(),
|
||||
conditions = binding.etConditions.text.toString(),
|
||||
isEnabled = schedule?.isEnabled ?: true,
|
||||
createdAt = schedule?.createdAt ?: System.currentTimeMillis(),
|
||||
lastExecutedAt = schedule?.lastExecutedAt ?: 0
|
||||
)
|
||||
|
||||
onSaveListener?.invoke(newSchedule)
|
||||
dismiss()
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
dialog?.window?.setLayout(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
)
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
_binding = null
|
||||
}
|
||||
|
||||
fun setOnSaveListener(listener: (Schedule) -> Unit) {
|
||||
onSaveListener = listener
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun newInstance(schedule: Schedule? = null): ScheduleDialogFragment {
|
||||
val fragment = ScheduleDialogFragment()
|
||||
fragment.schedule = schedule
|
||||
return fragment
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.analysis.goldenanalysis.ui.scheduler
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.ListAdapter
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.analysis.goldenanalysis.data.model.Schedule
|
||||
import com.analysis.goldenanalysis.databinding.ItemScheduleBinding
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
class SchedulerAdapter(
|
||||
private val onEdit: (Schedule) -> Unit,
|
||||
private val onDelete: (Schedule) -> Unit,
|
||||
private val onToggle: (Schedule, Boolean) -> Unit
|
||||
) : ListAdapter<Schedule, SchedulerAdapter.ScheduleViewHolder>(ScheduleDiffCallback()) {
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ScheduleViewHolder {
|
||||
val binding = ItemScheduleBinding.inflate(
|
||||
LayoutInflater.from(parent.context),
|
||||
parent,
|
||||
false
|
||||
)
|
||||
return ScheduleViewHolder(binding)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: ScheduleViewHolder, position: Int) {
|
||||
holder.bind(getItem(position))
|
||||
}
|
||||
|
||||
inner class ScheduleViewHolder(
|
||||
private val binding: ItemScheduleBinding
|
||||
) : RecyclerView.ViewHolder(binding.root) {
|
||||
|
||||
fun bind(schedule: Schedule) {
|
||||
binding.apply {
|
||||
tvScheduleName.text = schedule.name
|
||||
tvInterval.text = schedule.interval
|
||||
tvIndicators.text = schedule.indicators
|
||||
switchEnabled.isChecked = schedule.isEnabled
|
||||
|
||||
// Format last executed time
|
||||
if (schedule.lastExecutedAt > 0) {
|
||||
val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault())
|
||||
tvLastExecuted.text = "마지막 실행: ${sdf.format(Date(schedule.lastExecutedAt))}"
|
||||
} else {
|
||||
tvLastExecuted.text = "실행 기록 없음"
|
||||
}
|
||||
|
||||
// Click listeners
|
||||
root.setOnClickListener { onEdit(schedule) }
|
||||
btnEdit.setOnClickListener { onEdit(schedule) }
|
||||
btnDelete.setOnClickListener { onDelete(schedule) }
|
||||
switchEnabled.setOnCheckedChangeListener { _, isChecked ->
|
||||
onToggle(schedule, isChecked)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class ScheduleDiffCallback : DiffUtil.ItemCallback<Schedule>() {
|
||||
override fun areItemsTheSame(oldItem: Schedule, newItem: Schedule): Boolean {
|
||||
return oldItem.id == newItem.id
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(oldItem: Schedule, newItem: Schedule): Boolean {
|
||||
return oldItem == newItem
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
package com.analysis.goldenanalysis.ui.scheduler
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import com.analysis.goldenanalysis.data.model.Schedule
|
||||
import com.analysis.goldenanalysis.databinding.FragmentSchedulerBinding
|
||||
|
||||
class SchedulerFragment : Fragment() {
|
||||
|
||||
private var _binding: FragmentSchedulerBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
|
||||
private val viewModel: SchedulerViewModel by viewModels()
|
||||
private lateinit var adapter: SchedulerAdapter
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
_binding = FragmentSchedulerBinding.inflate(inflater, container, false)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
setupRecyclerView()
|
||||
setupObservers()
|
||||
setupListeners()
|
||||
}
|
||||
|
||||
private fun setupRecyclerView() {
|
||||
adapter = SchedulerAdapter(
|
||||
onEdit = { schedule -> showEditDialog(schedule) },
|
||||
onDelete = { schedule -> viewModel.delete(schedule) },
|
||||
onToggle = { schedule, enabled ->
|
||||
viewModel.update(schedule.copy(isEnabled = enabled))
|
||||
}
|
||||
)
|
||||
|
||||
binding.rvScheduler.apply {
|
||||
layoutManager = LinearLayoutManager(context)
|
||||
adapter = this@SchedulerFragment.adapter
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupObservers() {
|
||||
viewModel.allSchedules.observe(viewLifecycleOwner) { schedules ->
|
||||
adapter.submitList(schedules)
|
||||
binding.tvEmptyState.visibility =
|
||||
if (schedules.isEmpty()) View.VISIBLE else View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupListeners() {
|
||||
binding.fabAdd.setOnClickListener {
|
||||
showAddDialog()
|
||||
}
|
||||
}
|
||||
|
||||
private fun showAddDialog() {
|
||||
val dialog = ScheduleDialogFragment.newInstance()
|
||||
dialog.setOnSaveListener { schedule ->
|
||||
viewModel.insert(schedule)
|
||||
}
|
||||
dialog.show(childFragmentManager, "AddScheduleDialog")
|
||||
}
|
||||
|
||||
private fun showEditDialog(schedule: Schedule) {
|
||||
val dialog = ScheduleDialogFragment.newInstance(schedule)
|
||||
dialog.setOnSaveListener { updatedSchedule ->
|
||||
viewModel.update(updatedSchedule)
|
||||
}
|
||||
dialog.show(childFragmentManager, "EditScheduleDialog")
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
_binding = null
|
||||
}
|
||||
}
|
||||
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
package com.analysis.goldenanalysis.ui.scheduler
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.analysis.goldenanalysis.data.database.AppDatabase
|
||||
import com.analysis.goldenanalysis.data.model.Schedule
|
||||
import com.analysis.goldenanalysis.data.repository.ScheduleRepository
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class SchedulerViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
private val repository: ScheduleRepository
|
||||
val allSchedules: LiveData<List<Schedule>>
|
||||
|
||||
init {
|
||||
val scheduleDao = AppDatabase.getDatabase(application).scheduleDao()
|
||||
repository = ScheduleRepository(scheduleDao)
|
||||
allSchedules = repository.allSchedules
|
||||
}
|
||||
|
||||
fun insert(schedule: Schedule) = viewModelScope.launch {
|
||||
repository.insert(schedule)
|
||||
}
|
||||
|
||||
fun update(schedule: Schedule) = viewModelScope.launch {
|
||||
repository.update(schedule)
|
||||
}
|
||||
|
||||
fun delete(schedule: Schedule) = viewModelScope.launch {
|
||||
repository.delete(schedule)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
package com.analysis.goldenanalysis.ui.settings
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.fragment.app.Fragment
|
||||
import com.analysis.goldenanalysis.data.api.ApiClientFactory
|
||||
import com.analysis.goldenanalysis.databinding.FragmentSettingsBinding
|
||||
|
||||
class SettingsFragment : Fragment() {
|
||||
|
||||
private var _binding: FragmentSettingsBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
|
||||
private lateinit var prefs: SharedPreferences
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
_binding = FragmentSettingsBinding.inflate(inflater, container, false)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
prefs = requireContext().getSharedPreferences("app_prefs", Context.MODE_PRIVATE)
|
||||
setupViews()
|
||||
}
|
||||
|
||||
private fun setupViews() {
|
||||
// Load saved settings
|
||||
binding.switchNotification.isChecked = prefs.getBoolean("notifications_enabled", true)
|
||||
binding.switchDarkMode.isChecked = prefs.getBoolean("dark_mode", false)
|
||||
// Frontend URL 설정 (차트 페이지)
|
||||
// 로컬 Docker: http://192.168.219.106/mobile/chart
|
||||
// 운영: https://exdev.co.kr/mobile/chart
|
||||
val defaultFrontendUrl = "https://exdev.co.kr/mobile/chart"
|
||||
binding.etApiServer.setText(prefs.getString("frontend_url", defaultFrontendUrl))
|
||||
binding.etApiBackend.setText(prefs.getString("api_base_url", ""))
|
||||
|
||||
// Version info
|
||||
binding.tvVersionValue.text = "1.0.0"
|
||||
|
||||
// Setup listeners
|
||||
binding.switchNotification.setOnCheckedChangeListener { _, isChecked ->
|
||||
prefs.edit().putBoolean("notifications_enabled", isChecked).apply()
|
||||
}
|
||||
|
||||
binding.switchDarkMode.setOnCheckedChangeListener { _, isChecked ->
|
||||
prefs.edit().putBoolean("dark_mode", isChecked).apply()
|
||||
applyTheme(isChecked)
|
||||
}
|
||||
|
||||
binding.btnSaveServer.setOnClickListener {
|
||||
val frontendUrl = binding.etApiServer.text.toString()
|
||||
val apiBackend = binding.etApiBackend.text.toString().trim()
|
||||
val ed = prefs.edit().putString("frontend_url", frontendUrl)
|
||||
if (apiBackend.isEmpty()) ed.remove("api_base_url") else ed.putString("api_base_url", apiBackend)
|
||||
ed.apply()
|
||||
ApiClientFactory.reset()
|
||||
android.widget.Toast.makeText(context, "저장되었습니다", android.widget.Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyTheme(isDarkMode: Boolean) {
|
||||
AppCompatDelegate.setDefaultNightMode(
|
||||
if (isDarkMode) AppCompatDelegate.MODE_NIGHT_YES
|
||||
else AppCompatDelegate.MODE_NIGHT_NO
|
||||
)
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
_binding = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
package com.analysis.goldenanalysis.util
|
||||
|
||||
import com.analysis.goldenanalysis.data.model.AlertNotificationDto
|
||||
|
||||
data class StockStrategyPair(
|
||||
val koreanName: String,
|
||||
val strategyName: String,
|
||||
val symbol: String? = null,
|
||||
)
|
||||
|
||||
data class ResolvedStockRow(
|
||||
val koreanName: String,
|
||||
val strategyName: String,
|
||||
val displaySymbol: String,
|
||||
val navSymbol: String?,
|
||||
)
|
||||
|
||||
object AlertMessageParser {
|
||||
|
||||
private val marketCodeRe = Regex("^(KRW|USDT|BTC)-[A-Z0-9]+$", RegexOption.IGNORE_CASE)
|
||||
|
||||
fun extractStrategyNames(message: String): List<String>? {
|
||||
if (!message.contains("알림 조건 충족") || !message.contains("개)")) return null
|
||||
val lines = message.split("\n").map { it.trim() }.filter { it.isNotEmpty() }
|
||||
val names = mutableListOf<String>()
|
||||
for (i in 1 until lines.size) {
|
||||
val sep = listOf(" × ", " X ").firstOrNull { lines[i].contains(it) } ?: continue
|
||||
val idx = lines[i].indexOf(sep)
|
||||
if (idx >= 0) {
|
||||
val name = lines[i].substring(idx + sep.length).trim()
|
||||
if (name.isNotEmpty()) names.add(name)
|
||||
}
|
||||
}
|
||||
return if (names.isEmpty()) null else names.distinct()
|
||||
}
|
||||
|
||||
fun extractStockStrategyPairs(message: String): List<StockStrategyPair>? {
|
||||
if (!message.contains("알림 조건 충족") || !message.contains("개)")) return null
|
||||
val lines = message.split("\n").map { it.trim() }.filter { it.isNotEmpty() }
|
||||
val pairs = mutableListOf<StockStrategyPair>()
|
||||
for (i in 1 until lines.size) {
|
||||
val sep = listOf(" × ", " X ").firstOrNull { lines[i].contains(it) } ?: continue
|
||||
val idx = lines[i].indexOf(sep)
|
||||
if (idx >= 0) {
|
||||
val kn = lines[i].substring(0, idx).trim()
|
||||
val sn = lines[i].substring(idx + sep.length).trim()
|
||||
if (kn.isNotEmpty()) pairs.add(StockStrategyPair(kn, sn))
|
||||
}
|
||||
}
|
||||
return pairs.ifEmpty { null }
|
||||
}
|
||||
|
||||
fun resolveSymbols(koreanNames: List<String>, cryptoMap: Map<String, String>): List<Pair<String, String>> {
|
||||
val entries = cryptoMap.entries.toList()
|
||||
fun norm(s: String) = s.replace(Regex("\\s+"), " ").trim()
|
||||
val out = mutableListOf<Pair<String, String>>()
|
||||
for (kn in koreanNames) {
|
||||
val n = norm(kn)
|
||||
var matched = entries.find { norm(it.key) == n || it.key == kn }
|
||||
if (matched == null) {
|
||||
matched = entries.find {
|
||||
norm(it.key).contains(n) || n.contains(norm(it.key))
|
||||
}
|
||||
}
|
||||
if (matched != null) out.add(matched.key to matched.value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private fun koreanNameForMarketSymbol(symbol: String, cryptoMap: Map<String, String>, fallback: String): String {
|
||||
val s = symbol.trim().uppercase()
|
||||
cryptoMap.entries.forEach { (k, v) ->
|
||||
if (v.uppercase() == s) return k
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
fun resolveStockStrategyPairRow(pair: StockStrategyPair, cryptoMap: Map<String, String>): ResolvedStockRow {
|
||||
if (!pair.symbol.isNullOrBlank()) {
|
||||
val nav = pair.symbol.trim().uppercase()
|
||||
val left = pair.koreanName.trim()
|
||||
val kn = if (left.isNotEmpty() && !marketCodeRe.matches(left)) pair.koreanName
|
||||
else koreanNameForMarketSymbol(nav, cryptoMap, left.ifEmpty { nav })
|
||||
return ResolvedStockRow(kn, pair.strategyName, nav, nav)
|
||||
}
|
||||
val raw = pair.koreanName.trim()
|
||||
if (marketCodeRe.matches(raw)) {
|
||||
val nav = raw.uppercase()
|
||||
val kn = koreanNameForMarketSymbol(nav, cryptoMap, raw)
|
||||
return ResolvedStockRow(kn, pair.strategyName, nav, nav)
|
||||
}
|
||||
val resolved = resolveSymbols(listOf(pair.koreanName), cryptoMap).firstOrNull()
|
||||
return ResolvedStockRow(
|
||||
pair.koreanName,
|
||||
pair.strategyName,
|
||||
resolved?.second ?: pair.strategyName,
|
||||
resolved?.second,
|
||||
)
|
||||
}
|
||||
|
||||
fun titleAndPairs(notification: AlertNotificationDto): Pair<String, List<StockStrategyPair>> {
|
||||
val msg = notification.message.orEmpty()
|
||||
if (msg.contains("알림 조건 충족") && msg.contains("개)")) {
|
||||
val strategyNames = extractStrategyNames(msg).orEmpty()
|
||||
val title = when {
|
||||
strategyNames.isEmpty() -> "🎯 통합 알림"
|
||||
strategyNames.size <= 3 -> strategyNames.joinToString(", ")
|
||||
else -> "${strategyNames.take(2).joinToString(", ")} 외 ${strategyNames.size - 2}개 전략"
|
||||
}
|
||||
val pairs = extractStockStrategyPairs(msg).orEmpty()
|
||||
return title to pairs
|
||||
}
|
||||
if (!notification.koreanName.isNullOrBlank()) {
|
||||
val firstLine = msg.split("\n").firstOrNull()?.trim().orEmpty()
|
||||
val strategyName = firstLine.replace(Regex("^[\\s🧪🎯✅]*"), "").trim().ifEmpty { "알림" }
|
||||
return strategyName to listOf(
|
||||
StockStrategyPair(notification.koreanName!!, strategyName, notification.symbol)
|
||||
)
|
||||
}
|
||||
return (notification.symbol ?: "알림") to emptyList()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.analysis.goldenanalysis.util
|
||||
|
||||
import com.analysis.goldenanalysis.data.model.AlertNotificationDto
|
||||
|
||||
object AlertSignalInference {
|
||||
|
||||
fun inferFromStrategyName(strategyName: String): String? {
|
||||
val s = strategyName.trim()
|
||||
if (s.isEmpty()) return null
|
||||
val hint = s.replace(Regex("\\s"), "")
|
||||
if (Regex("매도$").containsMatchIn(hint) && !Regex("과매도$").containsMatchIn(hint)) return "SELL"
|
||||
if (Regex("매수$").containsMatchIn(hint) && !Regex("과매수$").containsMatchIn(hint)) return "BUY"
|
||||
if (s.contains("데드크로스")) return "SELL"
|
||||
if (s.contains("골든크로스")) return "BUY"
|
||||
return null
|
||||
}
|
||||
|
||||
fun inferFromNotification(n: AlertNotificationDto, koreanNameForRow: String?): String? {
|
||||
val row = koreanNameForRow?.trim()
|
||||
if (!row.isNullOrEmpty()) {
|
||||
val pairs = AlertMessageParser.extractStockStrategyPairs(n.message.orEmpty()).orEmpty()
|
||||
val pair = pairs.find { it.koreanName.trim() == row }
|
||||
pair?.strategyName?.let { inferFromStrategyName(it) }?.let { return it }
|
||||
}
|
||||
val raw = "${n.message.orEmpty()}\n${n.conditionDescription.orEmpty()}"
|
||||
val pairsAll = AlertMessageParser.extractStockStrategyPairs(n.message.orEmpty()).orEmpty()
|
||||
if (pairsAll.size == 1) {
|
||||
inferFromStrategyName(pairsAll[0].strategyName)?.let { return it }
|
||||
}
|
||||
val hint = raw.replace(Regex("\\s"), "")
|
||||
if (Regex("매도$").containsMatchIn(hint) && !Regex("과매도$").containsMatchIn(hint)) return "SELL"
|
||||
if (Regex("매수$").containsMatchIn(hint) && !Regex("과매수$").containsMatchIn(hint)) return "BUY"
|
||||
if (raw.contains("데드크로스")) return "SELL"
|
||||
if (raw.contains("골든크로스")) return "BUY"
|
||||
val masked = raw.replace("과매도", "").replace("과매수", "")
|
||||
if (Regex("매도조건|매도신호").containsMatchIn(masked) &&
|
||||
!Regex("매수조건|매수신호").containsMatchIn(masked)
|
||||
) return "SELL"
|
||||
if (Regex("매수조건|매수신호").containsMatchIn(masked) &&
|
||||
!Regex("매도조건|매도신호").containsMatchIn(masked)
|
||||
) return "BUY"
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.analysis.goldenanalysis.util
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.net.Uri
|
||||
|
||||
object ApiPrefs {
|
||||
private const val PREFS = "app_prefs"
|
||||
private const val KEY_API_BASE = "api_base_url"
|
||||
private const val KEY_FRONTEND = "frontend_url"
|
||||
|
||||
fun prefs(context: Context): SharedPreferences =
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
|
||||
/**
|
||||
* 백엔드 REST 베이스 (…/api/ 형태, Retrofit용).
|
||||
* [api_base_url]이 있으면 사용, 없으면 [frontend_url]의 origin + /api/
|
||||
*/
|
||||
fun getApiBaseUrl(context: Context): String {
|
||||
val p = prefs(context)
|
||||
val explicit = p.getString(KEY_API_BASE, null)?.trim().orEmpty()
|
||||
if (explicit.isNotEmpty()) {
|
||||
var b = explicit.trimEnd('/')
|
||||
if (!b.endsWith("/api")) b = "$b/api"
|
||||
return "$b/"
|
||||
}
|
||||
val front = p.getString(KEY_FRONTEND, "https://exdev.co.kr/mobile/chart")
|
||||
?: "https://exdev.co.kr/mobile/chart"
|
||||
val uri = Uri.parse(front)
|
||||
val scheme = uri.scheme ?: "https"
|
||||
val host = uri.host ?: "exdev.co.kr"
|
||||
val port = if (uri.port == -1 || uri.port == 80 || uri.port == 443) "" else ":${uri.port}"
|
||||
return "$scheme://$host$port/api/"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.analysis.goldenanalysis.util
|
||||
|
||||
import android.content.Context
|
||||
import androidx.work.*
|
||||
import com.analysis.goldenanalysis.data.model.Schedule
|
||||
import com.analysis.goldenanalysis.worker.ScheduleWorker
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
object ScheduleManager {
|
||||
|
||||
fun scheduleWork(context: Context, schedule: Schedule) {
|
||||
val workManager = WorkManager.getInstance(context)
|
||||
|
||||
if (!schedule.isEnabled) {
|
||||
cancelWork(context, schedule.id)
|
||||
return
|
||||
}
|
||||
|
||||
val intervalMinutes = getIntervalInMinutes(schedule.interval)
|
||||
val inputData = Data.Builder()
|
||||
.putLong("schedule_id", schedule.id)
|
||||
.build()
|
||||
|
||||
val workRequest = PeriodicWorkRequestBuilder<ScheduleWorker>(
|
||||
intervalMinutes,
|
||||
TimeUnit.MINUTES
|
||||
)
|
||||
.setInputData(inputData)
|
||||
.setConstraints(
|
||||
Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
.build()
|
||||
)
|
||||
.addTag("schedule_${schedule.id}")
|
||||
.build()
|
||||
|
||||
workManager.enqueueUniquePeriodicWork(
|
||||
"schedule_${schedule.id}",
|
||||
ExistingPeriodicWorkPolicy.REPLACE,
|
||||
workRequest
|
||||
)
|
||||
}
|
||||
|
||||
fun cancelWork(context: Context, scheduleId: Long) {
|
||||
val workManager = WorkManager.getInstance(context)
|
||||
workManager.cancelUniqueWork("schedule_$scheduleId")
|
||||
}
|
||||
|
||||
private fun getIntervalInMinutes(interval: String): Long {
|
||||
return when (interval) {
|
||||
"1분" -> 15 // Minimum 15 minutes for WorkManager
|
||||
"5분" -> 15
|
||||
"15분" -> 15
|
||||
"30분" -> 30
|
||||
"1시간" -> 60
|
||||
"4시간" -> 240
|
||||
"1일" -> 1440
|
||||
else -> 60
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.analysis.goldenanalysis.util
|
||||
|
||||
import java.net.URLEncoder
|
||||
import java.nio.charset.StandardCharsets
|
||||
|
||||
object UpbitWebUrls {
|
||||
|
||||
fun mapAlertTimeIntervalToResolution(timeInterval: String?): String? {
|
||||
if (timeInterval.isNullOrBlank()) return null
|
||||
val raw = timeInterval.trim()
|
||||
if (Regex("^\\d+M$").matches(raw)) {
|
||||
return mapOf("1M" to "1M", "3M" to "3M", "6M" to "6M", "12M" to "12M")[raw]
|
||||
}
|
||||
val key = raw.lowercase()
|
||||
val map = mapOf(
|
||||
"1m" to "1", "3m" to "3", "5m" to "5", "10m" to "10",
|
||||
"15m" to "15", "30m" to "30", "60m" to "60", "1h" to "60",
|
||||
"4h" to "240", "240m" to "240", "1d" to "1D", "1w" to "1W",
|
||||
)
|
||||
return map[key]
|
||||
}
|
||||
|
||||
fun getUpbitWebExchangeUrl(market: String?, timeInterval: String?): String? {
|
||||
if (market.isNullOrBlank()) return null
|
||||
var m = market.trim().uppercase()
|
||||
if (m.startsWith("CRIX.UPBIT.")) m = m.removePrefix("CRIX.UPBIT.")
|
||||
if (!Regex("^(KRW|USDT|BTC)-[A-Z0-9]+$", RegexOption.IGNORE_CASE).matches(m)) return null
|
||||
val code = "CRIX.UPBIT.$m"
|
||||
val base = "https://www.upbit.com/exchange?code=" + URLEncoder.encode(code, StandardCharsets.UTF_8.name())
|
||||
val res = mapAlertTimeIntervalToResolution(timeInterval)
|
||||
return if (res != null) "$base&resolution=" + URLEncoder.encode(res, StandardCharsets.UTF_8.name()) else base
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package com.analysis.goldenanalysis.worker
|
||||
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.WorkerParameters
|
||||
import com.analysis.goldenanalysis.R
|
||||
import com.analysis.goldenanalysis.data.database.AppDatabase
|
||||
import com.analysis.goldenanalysis.data.repository.ScheduleRepository
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class ScheduleWorker(
|
||||
context: Context,
|
||||
params: WorkerParameters
|
||||
) : CoroutineWorker(context, params) {
|
||||
|
||||
private val repository: ScheduleRepository by lazy {
|
||||
val dao = AppDatabase.getDatabase(context).scheduleDao()
|
||||
ScheduleRepository(dao)
|
||||
}
|
||||
|
||||
override suspend fun doWork(): Result {
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val scheduleId = inputData.getLong("schedule_id", -1)
|
||||
if (scheduleId == -1L) return@withContext Result.failure()
|
||||
|
||||
val schedule = repository.getScheduleById(scheduleId)
|
||||
if (schedule == null || !schedule.isEnabled) {
|
||||
return@withContext Result.failure()
|
||||
}
|
||||
|
||||
// TODO: Call API to check indicators
|
||||
// For now, just show notification
|
||||
showNotification(schedule.name, "스케쥴이 실행되었습니다")
|
||||
|
||||
// Update last executed timestamp
|
||||
repository.updateLastExecutedAt(scheduleId, System.currentTimeMillis())
|
||||
|
||||
Result.success()
|
||||
} catch (e: Exception) {
|
||||
Result.failure()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showNotification(title: String, message: String) {
|
||||
val notificationManager = applicationContext.getSystemService(Context.NOTIFICATION_SERVICE)
|
||||
as NotificationManager
|
||||
|
||||
val channelId = "schedule_notifications"
|
||||
|
||||
// Create notification channel for Android O and above
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val channel = NotificationChannel(
|
||||
channelId,
|
||||
"스케쥴 알림",
|
||||
NotificationManager.IMPORTANCE_DEFAULT
|
||||
)
|
||||
notificationManager.createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
val notification = NotificationCompat.Builder(applicationContext, channelId)
|
||||
.setSmallIcon(R.drawable.ic_logo)
|
||||
.setContentTitle(title)
|
||||
.setContentText(message)
|
||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||
.setAutoCancel(true)
|
||||
.build()
|
||||
|
||||
notificationManager.notify(System.currentTimeMillis().toInt(), notification)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user