From f9061a6b041afa1f3fda50c177886e7718e28f90 Mon Sep 17 00:00:00 2001
From: reddaniel <48639104@qq.com>
Date: Mon, 26 Oct 2026 14:04:56 +0800
Subject: [PATCH] OpenTerm v0.3.0 INTL: global data sources
(Yahoo/Stooq/Tencent), EN/ZH UI, symbol search, watchlist persistence, world
clocks, intraday chart, news feeds
---
.gitignore | 5 +
AndroidManifest.xml | 22 +
build.sh | 34 +
res/drawable/ic_launcher.xml | 12 +
res/values/strings.xml | 4 +
src/com/dsh/openterm/MainActivity.java | 1019 ++++++++++++++++++++++++
6 files changed, 1096 insertions(+)
create mode 100644 .gitignore
create mode 100644 AndroidManifest.xml
create mode 100644 build.sh
create mode 100644 res/drawable/ic_launcher.xml
create mode 100644 res/values/strings.xml
create mode 100644 src/com/dsh/openterm/MainActivity.java
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..a2ceee7
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,5 @@
+OpenTermBuild/
+*.apk
+*.keystore
+gen/
+classes/
diff --git a/AndroidManifest.xml b/AndroidManifest.xml
new file mode 100644
index 0000000..5806c25
--- /dev/null
+++ b/AndroidManifest.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/build.sh b/build.sh
new file mode 100644
index 0000000..e9fc13d
--- /dev/null
+++ b/build.sh
@@ -0,0 +1,34 @@
+#!/data/data/com.dsharnessmobile.shell/files/usr/bin/bash
+# OpenTerm 一键构建脚本
+set -e
+P=/data/data/com.dsharnessmobile.shell/files/usr
+J=$P/lib/jvm/java-17-openjdk/bin
+export PATH=$J:$P/bin:$PATH
+SRC=/data/data/com.dsharnessmobile.shell/files/home/.dsh/workspaces/incoming/OpenTerm
+OUT=/data/data/com.dsharnessmobile.shell/files/home/OpenTermBuild
+FWRES=/system/framework/framework-res.apk
+
+cd "$OUT"
+rm -rf src gen classes classes.dex unsigned.apk OpenTerm.apk
+mkdir -p src/com/dsh/openterm gen/com/dsh/openterm classes
+cp -r "$SRC"/src/com/dsh/openterm/* src/com/dsh/openterm/
+cp "$SRC"/AndroidManifest.xml .
+
+# 1. 资源 + R.java
+aapt package -f -M AndroidManifest.xml -S "$SRC/res" -I "$FWRES" -J gen
+# 2. 编译
+javac --release 8 -classpath "$HOME/android-stub.jar:$HOME/json.jar" -d classes \
+ src/com/dsh/openterm/*.java $(find gen -name "*.java")
+# 3. 转 dex
+java -cp "$HOME/r8.jar" com.android.tools.r8.D8 --release \
+ --lib "$HOME/android-stub.jar" --output . classes/com/dsh/openterm/*.class
+# 4. 打包
+aapt package -f -M AndroidManifest.xml -S "$SRC/res" -I "$FWRES" -F unsigned.apk
+aapt add unsigned.apk classes.dex
+# 5. 签名
+apksigner sign --ks "$HOME/openterm.keystore" --ks-pass pass:openterm123 \
+ --key-pass pass:openterm123 --out OpenTerm.apk unsigned.apk
+apksigner verify OpenTerm.apk
+cp OpenTerm.apk "$SRC/../OpenTerm.apk"
+ls -la "$SRC/../OpenTerm.apk"
+echo BUILD_OK
diff --git a/res/drawable/ic_launcher.xml b/res/drawable/ic_launcher.xml
new file mode 100644
index 0000000..16ac765
--- /dev/null
+++ b/res/drawable/ic_launcher.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/res/values/strings.xml b/res/values/strings.xml
new file mode 100644
index 0000000..ba18bcd
--- /dev/null
+++ b/res/values/strings.xml
@@ -0,0 +1,4 @@
+
+
+ OpenTerm 行情终端
+
diff --git a/src/com/dsh/openterm/MainActivity.java b/src/com/dsh/openterm/MainActivity.java
new file mode 100644
index 0000000..fb595ef
--- /dev/null
+++ b/src/com/dsh/openterm/MainActivity.java
@@ -0,0 +1,1019 @@
+package com.dsh.openterm;
+
+import android.app.Activity;
+import android.app.AlertDialog;
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.graphics.Canvas;
+import android.graphics.Paint;
+import android.graphics.Path;
+import android.graphics.Typeface;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.Looper;
+import android.text.Editable;
+import android.text.TextWatcher;
+import android.view.Gravity;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.inputmethod.EditorInfo;
+import android.widget.BaseAdapter;
+import android.widget.EditText;
+import android.widget.LinearLayout;
+import android.widget.ListView;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import org.json.JSONArray;
+import org.json.JSONObject;
+
+import java.io.BufferedReader;
+import java.io.InputStreamReader;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.net.URLEncoder;
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Calendar;
+import java.util.Date;
+import java.util.List;
+import java.util.Locale;
+import java.util.TimeZone;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+
+public class MainActivity extends Activity implements View.OnClickListener {
+
+ /* ══════════ i18n ══════════ */
+ static boolean zh() { return Locale.getDefault().getLanguage().startsWith("zh"); }
+ static String t(String z, String e) { return zh() ? z : e; }
+
+ /* ══════════ 数据模型 ══════════ */
+
+ static class Quote {
+ String sym; // 显示符号 AAPL / BMW.DE / BTC-USD
+ String yahoo; // Yahoo 代码
+ String tencent; // 腾讯代码(国内回落,可空)
+ String name = "";
+ String price = "--", change = "--", pct = "--";
+ double priceNum = Double.NaN, prevNum = Double.NaN;
+ String highS = "--", lowS = "--", volS = "--";
+ String currency = "", exchange = "";
+ boolean up, loaded;
+ Quote(String yahoo, String tencent, String name) {
+ this.yahoo = yahoo; this.tencent = tencent; this.name = name;
+ this.sym = yahoo;
+ }
+ }
+
+ static class MPoint { double t; double price; }
+
+ static class NewsItem { String time = "", title = "", link = ""; }
+
+ static class SearchHit { String symbol, name, exchange, type; }
+
+ /* ══════════ 默认自选(全球均衡) ══════════ */
+ static final String[][] DEFAULTS = {
+ {"^GSPC", "usSPX", "S&P 500"},
+ {"^IXIC", "usIXIC", "Nasdaq Composite"},
+ {"^DJI", "usDJI", "Dow Jones"},
+ {"^HSI", "r_hkHSI", "Hang Seng"},
+ {"^N225", null, "Nikkei 225"},
+ {"^STOXX50E", null, "Euro Stoxx 50"},
+ {"AAPL", "usAAPL", "Apple"},
+ {"NVDA", "usNVDA", "NVIDIA"},
+ {"MSFT", null, "Microsoft"},
+ {"BMW.DE", null, "BMW"},
+ {"ASML.AS",null, "ASML"},
+ {"BTC-USD",null, "Bitcoin"},
+ {"ETH-USD",null, "Ethereum"},
+ {"EURUSD=X",null, "EUR/USD"},
+ };
+
+ /* ══════════ 配色 ══════════ */
+ static final int BG = 0xFF0A0A0A, PANEL = 0xFF141414, BAR = 0xFF1B1B1B;
+ static final int ORANGE = 0xFFFF9900, AMBER = 0xFFFFD580;
+ static final int RED = 0xFFE53935, GREEN = 0xFF00C853;
+ static final int WHITE = 0xFFE8E8E8, GREY = 0xFF808080, LINE = 0xFF2A2A2A;
+
+ /* ══════════ 状态 ══════════ */
+ static final int PAGE_WATCH = 0, PAGE_NEWS = 1, PAGE_SEARCH = 2, PAGE_DETAIL = 3;
+ int page = PAGE_WATCH;
+ final List quotes = new ArrayList<>();
+ final List news = new ArrayList<>();
+ final List detailNews = new ArrayList<>();
+ final List hits = new ArrayList<>();
+ Quote detail;
+
+ QuoteAdapter quoteAdapter;
+ NewsAdapter newsAdapter, detailNewsAdapter;
+ HitAdapter hitAdapter;
+ ListView quoteList, newsList, hitList, dNewsList;
+ LinearLayout pageWatch, pageNews, pageSearch, pageDetail;
+ TextView tvClock, tvStatus, tvStats, tvTicker;
+ TextView tabWatch, tabNews, tabSearch;
+ TextView dTitle, dName, dCode, dPrice, dChange, dO, dH, dL, dP, dV, dUpdated, dNewsHead;
+ EditText searchBox;
+ MinuteChart chart;
+ LinearLayout clocksBar;
+
+ final Handler ui = new Handler(Looper.getMainLooper());
+ final ExecutorService net = Executors.newSingleThreadExecutor();
+ final SimpleDateFormat hm = new SimpleDateFormat("HH:mm:ss", Locale.US);
+ SharedPreferences prefs;
+
+ @Override protected void onCreate(Bundle b) {
+ super.onCreate(b);
+ prefs = getSharedPreferences("openterm", MODE_PRIVATE);
+ loadWatchlist();
+ buildUi();
+ refreshQuotes();
+ ui.postDelayed(quoteLoop, 30000);
+ ui.postDelayed(tick, 1000);
+ ui.postDelayed(newsLoop, 1000);
+ }
+
+ final Runnable tick = new Runnable() { @Override public void run() {
+ tvClock.setText(hm.format(new Date()) + " LOCAL");
+ renderClocks();
+ ui.postDelayed(this, 1000);
+ }};
+
+ final Runnable quoteLoop = new Runnable() { @Override public void run() {
+ refreshQuotes(); ui.postDelayed(this, 30000);
+ }};
+
+ final Runnable newsLoop = new Runnable() { @Override public void run() {
+ if (page == PAGE_NEWS || news.isEmpty()) fetchNews();
+ ui.postDelayed(this, 90000);
+ }};
+
+ /* ══════════ 自选持久化 ══════════ */
+
+ void loadWatchlist() {
+ quotes.clear();
+ String s = prefs.getString("watchlist", null);
+ try {
+ if (s != null) {
+ JSONArray a = new JSONArray(s);
+ for (int i = 0; i < a.length(); i++) {
+ JSONObject o = a.getJSONObject(i);
+ String tc = o.optString("t", "");
+ quotes.add(new Quote(o.getString("y"), tc.isEmpty() ? null : tc, o.getString("n")));
+ }
+ if (!quotes.isEmpty()) return;
+ }
+ } catch (Exception ignored) { }
+ for (String[] d : DEFAULTS) quotes.add(new Quote(d[0], d[1], d[2]));
+ }
+
+ void saveWatchlist() {
+ try {
+ JSONArray a = new JSONArray();
+ for (Quote q : quotes) {
+ JSONObject o = new JSONObject();
+ o.put("y", q.yahoo);
+ o.put("t", q.tencent == null ? "" : q.tencent);
+ o.put("n", q.name);
+ a.put(o);
+ }
+ prefs.edit().putString("watchlist", a.toString()).apply();
+ } catch (Exception ignored) { }
+ }
+
+ /* ══════════ UI ══════════ */
+
+ void buildUi() {
+ LinearLayout root = new LinearLayout(this);
+ root.setOrientation(LinearLayout.VERTICAL);
+ root.setBackgroundColor(BG);
+
+ LinearLayout bar = new LinearLayout(this);
+ bar.setOrientation(LinearLayout.HORIZONTAL);
+ bar.setBackgroundColor(BAR);
+ bar.setPadding(dp(12), dp(10), dp(12), dp(10));
+ bar.addView(mono("OPENTERM", 20, ORANGE, Typeface.BOLD),
+ new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1));
+ tvClock = mono("--:--:--", 13, AMBER, Typeface.NORMAL);
+ LinearLayout.LayoutParams cp = wrapWrap(); cp.gravity = Gravity.CENTER_VERTICAL;
+ bar.addView(tvClock, cp);
+ root.addView(bar, matchWrap());
+
+ clocksBar = new LinearLayout(this);
+ clocksBar.setOrientation(LinearLayout.HORIZONTAL);
+ clocksBar.setBackgroundColor(PANEL);
+ clocksBar.setPadding(dp(12), dp(3), dp(12), dp(3));
+ root.addView(clocksBar, matchWrap());
+
+ LinearLayout nav = new LinearLayout(this);
+ nav.setOrientation(LinearLayout.HORIZONTAL);
+ nav.setBackgroundColor(PANEL);
+ nav.setPadding(dp(8), dp(4), dp(8), dp(0));
+ tabWatch = tabBtn("1 " + t("自选", "WATCH"), PAGE_WATCH);
+ tabNews = tabBtn("2 " + t("快讯", "NEWS"), PAGE_NEWS);
+ tabSearch= tabBtn("3 " + t("搜索", "SEARCH"), PAGE_SEARCH);
+ nav.addView(tabWatch, navLP()); nav.addView(tabNews, navLP()); nav.addView(tabSearch, navLP());
+ root.addView(nav, matchWrap());
+
+ LinearLayout sub = new LinearLayout(this);
+ sub.setOrientation(LinearLayout.HORIZONTAL);
+ sub.setBackgroundColor(PANEL);
+ sub.setPadding(dp(12), dp(3), dp(12), dp(4));
+ tvStatus = mono("", 11, GREY, Typeface.NORMAL);
+ sub.addView(tvStatus, new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1));
+ tvStats = mono("", 11, AMBER, Typeface.BOLD);
+ sub.addView(tvStats, wrapWrap());
+ root.addView(sub, matchWrap());
+
+ pageWatch = buildWatchPage();
+ pageNews = buildNewsPage();
+ pageSearch = buildSearchPage();
+ pageDetail = buildDetailPage();
+ for (LinearLayout p : new LinearLayout[]{pageWatch, pageNews, pageSearch, pageDetail})
+ root.addView(p, new LinearLayout.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT, 0, 1));
+
+ tvTicker = mono(" OPENTERM 0.3 INTL · "
+ + t("数据:Yahoo Finance 公开接口", "Data: Yahoo Finance public API")
+ + " · " + t("仅供参考,不构成投资建议", "For information only, not investment advice") + " ",
+ 12, AMBER, Typeface.NORMAL);
+ tvTicker.setBackgroundColor(BAR);
+ tvTicker.setPadding(dp(8), dp(6), dp(8), dp(6));
+ root.addView(tvTicker, matchWrap());
+
+ setContentView(root);
+ showPage(PAGE_WATCH);
+ }
+
+ LinearLayout.LayoutParams matchWrap() {
+ return new LinearLayout.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
+ }
+ LinearLayout.LayoutParams wrapWrap() {
+ return new LinearLayout.LayoutParams(
+ ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
+ }
+ LinearLayout.LayoutParams navLP() {
+ LinearLayout.LayoutParams p = new LinearLayout.LayoutParams(0,
+ ViewGroup.LayoutParams.WRAP_CONTENT, 1);
+ p.setMargins(dp(4), 0, dp(4), 0);
+ return p;
+ }
+
+ TextView tabBtn(String label, final int target) {
+ TextView tv = new TextView(this);
+ tv.setText(label); tv.setTextSize(12);
+ tv.setTypeface(Typeface.MONOSPACE, Typeface.BOLD);
+ tv.setGravity(Gravity.CENTER);
+ tv.setPadding(dp(6), dp(6), dp(6), dp(6));
+ tv.setOnClickListener(this);
+ tv.setTag(target);
+ tv.setBackgroundColor(PANEL);
+ return tv;
+ }
+
+ LinearLayout buildWatchPage() {
+ LinearLayout p = new LinearLayout(this);
+ p.setOrientation(LinearLayout.VERTICAL);
+ p.setBackgroundColor(BG);
+ LinearLayout head = new LinearLayout(this);
+ head.setOrientation(LinearLayout.HORIZONTAL);
+ head.setBackgroundColor(0xFF1E1E1E);
+ head.setPadding(dp(12), dp(5), dp(12), dp(5));
+ head.addView(mono(t("名称/代码", "NAME / SYMBOL"), 11, ORANGE, Typeface.BOLD), rowLP(2.4f));
+ head.addView(mono(t("最新", "LAST"), 11, ORANGE, Typeface.BOLD), rightLP(1.2f));
+ head.addView(mono(t("涨跌", "CHG"), 11, ORANGE, Typeface.BOLD), rightLP(1.1f));
+ head.addView(mono(t("幅度", "%CHG"), 11, ORANGE, Typeface.BOLD), rightLP(1.1f));
+ p.addView(head, matchWrap());
+ quoteAdapter = new QuoteAdapter();
+ quoteList = new ListView(this);
+ quoteList.setAdapter(quoteAdapter);
+ quoteList.setDivider(new android.graphics.drawable.ColorDrawable(LINE));
+ quoteList.setDividerHeight(1);
+ quoteList.setBackgroundColor(BG);
+ p.addView(quoteList, new LinearLayout.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT, 0, 1));
+ return p;
+ }
+
+ LinearLayout buildNewsPage() {
+ LinearLayout p = new LinearLayout(this);
+ p.setOrientation(LinearLayout.VERTICAL);
+ p.setBackgroundColor(BG);
+ LinearLayout head = new LinearLayout(this);
+ head.setOrientation(LinearLayout.HORIZONTAL);
+ head.setBackgroundColor(0xFF1E1E1E);
+ head.setPadding(dp(12), dp(5), dp(12), dp(5));
+ head.addView(mono(t("时间", "TIME"), 11, ORANGE, Typeface.BOLD), rowLP(0.6f));
+ head.addView(mono(t("全球财经头条 · 滚动更新", "GLOBAL MARKET NEWS · LIVE"), 11, ORANGE, Typeface.BOLD), rowLP(4f));
+ p.addView(head, matchWrap());
+ newsAdapter = new NewsAdapter(news);
+ newsList = new ListView(this);
+ newsList.setAdapter(newsAdapter);
+ newsList.setDivider(new android.graphics.drawable.ColorDrawable(LINE));
+ newsList.setDividerHeight(1);
+ newsList.setBackgroundColor(BG);
+ p.addView(newsList, new LinearLayout.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT, 0, 1));
+ return p;
+ }
+
+ LinearLayout buildSearchPage() {
+ LinearLayout p = new LinearLayout(this);
+ p.setOrientation(LinearLayout.VERTICAL);
+ p.setBackgroundColor(BG);
+ searchBox = new EditText(this);
+ searchBox.setHint(t("输入代码或名称,如 AAPL / BMW.DE / BTC-USD",
+ "Symbol or name, e.g. AAPL / BMW.DE / BTC-USD"));
+ searchBox.setTextColor(WHITE); searchBox.setHintTextColor(GREY);
+ searchBox.setTextSize(13);
+ searchBox.setTypeface(Typeface.MONOSPACE);
+ searchBox.setBackgroundColor(PANEL);
+ searchBox.setPadding(dp(12), dp(10), dp(12), dp(10));
+ searchBox.setImeOptions(EditorInfo.IME_ACTION_SEARCH);
+ searchBox.setSingleLine();
+ searchBox.addTextChangedListener(new TextWatcher() {
+ @Override public void beforeTextChanged(CharSequence s, int a, int b, int c) { }
+ @Override public void onTextChanged(CharSequence s, int a, int b, int c) { }
+ @Override public void afterTextChanged(Editable s) {
+ String q = s.toString().trim();
+ if (q.length() >= 2) doSearch(q);
+ }
+ });
+ p.addView(searchBox, matchWrap());
+ hitAdapter = new HitAdapter();
+ hitList = new ListView(this);
+ hitList.setAdapter(hitAdapter);
+ hitList.setDivider(new android.graphics.drawable.ColorDrawable(LINE));
+ hitList.setDividerHeight(1);
+ hitList.setBackgroundColor(BG);
+ p.addView(hitList, new LinearLayout.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT, 0, 1));
+ return p;
+ }
+
+ LinearLayout buildDetailPage() {
+ LinearLayout p = new LinearLayout(this);
+ p.setOrientation(LinearLayout.VERTICAL);
+ p.setBackgroundColor(BG);
+ p.setPadding(0, dp(6), 0, 0);
+
+ dTitle = mono("", 13, ORANGE, Typeface.BOLD);
+ dTitle.setPadding(dp(12), dp(2), dp(12), dp(2));
+ p.addView(dTitle, matchWrap());
+
+ LinearLayout priceRow = new LinearLayout(this);
+ priceRow.setOrientation(LinearLayout.HORIZONTAL);
+ priceRow.setPadding(dp(12), dp(4), dp(12), dp(6));
+ dName = mono("", 15, WHITE, Typeface.BOLD);
+ priceRow.addView(dName, new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1));
+ dPrice = mono("", 22, WHITE, Typeface.BOLD);
+ LinearLayout.LayoutParams pp = wrapWrap(); pp.gravity = Gravity.CENTER_VERTICAL;
+ priceRow.addView(dPrice, pp);
+ p.addView(priceRow, matchWrap());
+
+ dChange = mono("", 13, WHITE, Typeface.NORMAL);
+ dChange.setPadding(dp(12), 0, dp(12), dp(4));
+ p.addView(dChange, matchWrap());
+
+ chart = new MinuteChart(this);
+ p.addView(chart, new LinearLayout.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT, dp(200)));
+
+ LinearLayout grid = new LinearLayout(this);
+ grid.setOrientation(LinearLayout.VERTICAL);
+ grid.setBackgroundColor(PANEL);
+ LinearLayout r1 = kvRow(), r2 = kvRow(), r3 = kvRow();
+ dH = kvCell(r1, t("最高", "HIGH")); dL = kvCell(r1, t("最低", "LOW"));
+ dP = kvCell(r2, t("昨收", "PREV CLOSE")); dV = kvCell(r2, t("成交量", "VOLUME"));
+ TextView exch = kvCell(r3, t("交易所", "EXCHANGE")); exch.setTag("exch");
+ TextView cur = kvCell(r3, t("币种", "CURRENCY")); cur.setTag("cur");
+ grid.addView(r1, matchWrap()); grid.addView(r2, matchWrap()); grid.addView(r3, matchWrap());
+ p.addView(grid, matchWrap());
+
+ dNewsHead = mono(t("相关新闻", "RELATED NEWS"), 11, ORANGE, Typeface.BOLD);
+ dNewsHead.setPadding(dp(12), dp(8), dp(12), dp(3));
+ p.addView(dNewsHead, matchWrap());
+
+ detailNewsAdapter = new NewsAdapter(detailNews);
+ dNewsList = new ListView(this);
+ dNewsList.setAdapter(detailNewsAdapter);
+ dNewsList.setDivider(new android.graphics.drawable.ColorDrawable(LINE));
+ dNewsList.setDividerHeight(1);
+ dNewsList.setBackgroundColor(BG);
+ p.addView(dNewsList, new LinearLayout.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT, 0, 1));
+
+ dUpdated = mono("", 11, GREY, Typeface.NORMAL);
+ dUpdated.setPadding(dp(12), dp(3), dp(12), dp(3));
+ p.addView(dUpdated, matchWrap());
+
+ TextView back = mono("<< " + t("返回自选(点击)", "BACK TO WATCHLIST (tap)"), 13, AMBER, Typeface.BOLD);
+ back.setPadding(dp(12), dp(6), dp(12), dp(10));
+ back.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) {
+ showPage(PAGE_WATCH);
+ }});
+ p.addView(back, matchWrap());
+ return p;
+ }
+
+ LinearLayout kvRow() {
+ LinearLayout r = new LinearLayout(this);
+ r.setOrientation(LinearLayout.HORIZONTAL);
+ r.setPadding(dp(12), dp(4), dp(12), dp(4));
+ return r;
+ }
+
+ TextView kvCell(LinearLayout parent, String k) {
+ LinearLayout c = new LinearLayout(this);
+ c.setOrientation(LinearLayout.VERTICAL);
+ c.addView(mono(k, 10, GREY, Typeface.NORMAL), wrapWrap());
+ TextView vt = mono("--", 13, WHITE, Typeface.BOLD);
+ c.addView(vt, wrapWrap());
+ parent.addView(c, new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1));
+ return vt;
+ }
+
+ LinearLayout.LayoutParams rowLP(float w) {
+ return new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, w);
+ }
+ LinearLayout.LayoutParams rightLP(float w) {
+ LinearLayout.LayoutParams p = rowLP(w); p.gravity = Gravity.RIGHT;
+ return p;
+ }
+
+ TextView mono(String s, int sp, int color, int style) {
+ TextView tv = new TextView(this);
+ tv.setText(s); tv.setTextSize(sp); tv.setTextColor(color);
+ tv.setTypeface(Typeface.MONOSPACE, style);
+ return tv;
+ }
+
+ int dp(int v) { return Math.round(v * getResources().getDisplayMetrics().density); }
+ float dpx(float v) { return v * getResources().getDisplayMetrics().density; }
+
+ void showPage(int which) {
+ page = which;
+ pageWatch.setVisibility(which == PAGE_WATCH ? View.VISIBLE : View.GONE);
+ pageNews.setVisibility(which == PAGE_NEWS ? View.VISIBLE : View.GONE);
+ pageSearch.setVisibility(which == PAGE_SEARCH ? View.VISIBLE : View.GONE);
+ pageDetail.setVisibility(which == PAGE_DETAIL ? View.VISIBLE : View.GONE);
+ tabWatch.setBackgroundColor(which == PAGE_WATCH ? BAR : PANEL);
+ tabNews.setBackgroundColor(which == PAGE_NEWS ? BAR : PANEL);
+ tabSearch.setBackgroundColor(which == PAGE_SEARCH ? BAR : PANEL);
+ tabWatch.setTextColor(which == PAGE_WATCH ? ORANGE : GREY);
+ tabNews.setTextColor(which == PAGE_NEWS ? ORANGE : GREY);
+ tabSearch.setTextColor(which == PAGE_SEARCH ? ORANGE : GREY);
+ }
+
+ @Override public void onClick(View v) {
+ Object tag = v.getTag();
+ if (tag instanceof Integer) showPage((Integer) tag);
+ }
+
+ /* ══════════ 全球市场时钟条 ══════════ */
+
+ static final String[][] MKTS = {
+ {"NEW YORK", "America/New_York", "9.5", "16"},
+ {"LONDON", "Europe/London", "8", "16.5"},
+ {"TOKYO", "Asia/Tokyo", "9", "15"},
+ {"HONGKONG", "Asia/Hong_Kong", "9.5", "16"},
+ };
+
+ void renderClocks() {
+ clocksBar.removeAllViews();
+ for (String[] m : MKTS) {
+ TimeZone tz = TimeZone.getTimeZone(m[1]);
+ Calendar c = Calendar.getInstance(tz);
+ boolean weekday = c.get(Calendar.DAY_OF_WEEK) != Calendar.SATURDAY
+ && c.get(Calendar.DAY_OF_WEEK) != Calendar.SUNDAY;
+ double hr = c.get(Calendar.HOUR_OF_DAY) + c.get(Calendar.MINUTE) / 60.0;
+ boolean open = weekday && hr >= Double.parseDouble(m[2]) && hr < Double.parseDouble(m[3]);
+ TextView tv = mono("", 10, open ? GREEN : GREY, open ? Typeface.BOLD : Typeface.NORMAL);
+ tv.setText(String.format(Locale.US, "%s %02d:%02d%s",
+ m[0], c.get(Calendar.HOUR_OF_DAY), c.get(Calendar.MINUTE), open ? " ●" : " ○"));
+ clocksBar.addView(tv, new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1));
+ }
+ }
+
+ /* ══════════ HTTP ══════════ */
+
+ String httpGet(String url) throws Exception {
+ URL u = new URL(url);
+ HttpURLConnection c = (HttpURLConnection) u.openConnection();
+ c.setConnectTimeout(10000); c.setReadTimeout(10000);
+ c.setRequestProperty("User-Agent",
+ "Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 Chrome/120 Safari/537.36");
+ c.setRequestProperty("Accept", "*/*");
+ BufferedReader r = new BufferedReader(new InputStreamReader(c.getInputStream(), "UTF-8"));
+ StringBuilder sb = new StringBuilder(); String line;
+ while ((line = r.readLine()) != null) sb.append(line);
+ r.close();
+ return sb.toString();
+ }
+
+ /* ══════════ 行情:Yahoo 主源 + Stooq/腾讯兜底 ══════════ */
+
+ void refreshQuotes() {
+ setStatus(t("刷新中 …", "Refreshing …"));
+ net.submit(new Runnable() { @Override public void run() { fetchQuotes(); }});
+ }
+
+ void setStatus(final String s) {
+ ui.post(new Runnable() { @Override public void run() { tvStatus.setText(s); }});
+ }
+
+ void fetchQuotes() {
+ int ok = 0;
+ for (Quote q : quotes) {
+ if (fetchYahoo(q)) ok++;
+ else if (fetchStooq(q)) ok++;
+ else if (q.tencent != null && fetchTencent(q)) ok++;
+ }
+ final int n = ok;
+ ui.post(new Runnable() { @Override public void run() {
+ int up = 0, down = 0;
+ for (Quote q : quotes) { if (!q.loaded) continue; if (q.up) up++; else down++; }
+ setStatus((n >= quotes.size() ? "" : t("部分失败 · ", "partial · "))
+ + t("已更新 ", "updated ") + n + "/" + quotes.size() + " · " + hm.format(new Date()));
+ tvStats.setText("▲" + up + " ▼" + down);
+ quoteAdapter.notifyDataSetChanged();
+ }});
+ }
+
+ /** Yahoo chart 接口:一次返回报价元数据 */
+ boolean fetchYahoo(Quote q) {
+ try {
+ String json = httpGet("https://query1.finance.yahoo.com/v8/finance/chart/"
+ + URLEncoder.encode(q.yahoo, "UTF-8") + "?range=1d&interval=5m");
+ JSONObject meta = new JSONObject(json)
+ .getJSONObject("chart").getJSONArray("result").getJSONObject(0)
+ .getJSONObject("meta");
+ double price = meta.optDouble("regularMarketPrice", Double.NaN);
+ double prev = meta.optDouble("chartPreviousClose",
+ meta.optDouble("previousClose", Double.NaN));
+ if (Double.isNaN(price) || price == 0) return false;
+ applyQuote(q, price, prev,
+ meta.optString("currency", ""), meta.optString("exchangeName", ""),
+ meta.optDouble("regularMarketDayHigh", Double.NaN),
+ meta.optDouble("regularMarketDayLow", Double.NaN),
+ meta.optDouble("regularMarketVolume", Double.NaN));
+ return true;
+ } catch (Exception e) { return false; }
+ }
+
+ /** Stooq 兜底(指数/美股) */
+ boolean fetchStooq(Quote q) {
+ try {
+ String s = q.yahoo.startsWith("^") ? stooqIndex(q.yahoo)
+ : q.yahoo.replace('.', '-').toLowerCase(Locale.US) + ".us";
+ if (s == null) return false;
+ String csv = httpGet("https://stooq.com/q/l/?s=" + s + "&f=sd2t2ohlcv&h&e=csv");
+ String[] lines = csv.split("\n");
+ if (lines.length < 2) return false;
+ String[] f = lines[1].split(",");
+ if (f.length < 7) return false;
+ double close = Double.parseDouble(f[6].trim());
+ double open = Double.parseDouble(f[3].trim());
+ if (close == 0) return false;
+ applyQuote(q, close, open, "", "STOOQ", close, close, 0);
+ return true;
+ } catch (Exception e) { return false; }
+ }
+
+ String stooqIndex(String yahoo) {
+ if ("^GSPC".equals(yahoo)) return "^spx";
+ if ("^DJI".equals(yahoo)) return "^dji";
+ if ("^IXIC".equals(yahoo)) return "^ndq";
+ return null;
+ }
+
+ /** 腾讯源(国内网络回落) */
+ boolean fetchTencent(Quote q) {
+ try {
+ String body = httpGet("https://qt.gtimg.cn/q=" + q.tencent);
+ String tag = "v_" + q.tencent + "=\"";
+ int i = body.indexOf(tag); if (i < 0) return false;
+ int s = i + tag.length(), e = body.indexOf('"', s);
+ String[] f = body.substring(s, e).split("~");
+ if (f.length < 39) return false;
+ applyQuote(q, Double.parseDouble(f[3]), Double.parseDouble(f[4]),
+ "CNY", "TCT",
+ Double.parseDouble(f[33]), Double.parseDouble(f[34]), Double.parseDouble(f[36]));
+ return true;
+ } catch (Exception ex) { return false; }
+ }
+
+ void applyQuote(Quote q, double price, double prev, String cur, String exch,
+ double high, double low, double vol) {
+ q.priceNum = price; q.prevNum = prev;
+ q.currency = cur; q.exchange = exch;
+ q.price = fmt(price);
+ double d = price - prev;
+ q.up = d >= 0;
+ q.change = (d >= 0 ? "+" : "-") + fmt(Math.abs(d));
+ q.pct = String.format(Locale.US, "%+.2f%%", prev != 0 ? d / prev * 100 : 0);
+ if (!Double.isNaN(high)) q.highS = fmt(high);
+ if (!Double.isNaN(low)) q.lowS = fmt(low);
+ if (!Double.isNaN(vol)) q.volS = fmtVol(vol);
+ q.loaded = true;
+ }
+
+ String fmt(double v) {
+ if (Math.abs(v) >= 1e6) return String.format(Locale.US, "%.2fM", v / 1e6);
+ if (Math.abs(v) >= 10000) return String.format(Locale.US, "%.0f", v);
+ if (Math.abs(v) < 1 && v != 0) return String.format(Locale.US, "%.4f", v);
+ return String.format(Locale.US, "%.2f", v);
+ }
+
+ String fmtVol(double v) {
+ if (v >= 1e9) return String.format(Locale.US, "%.2fB", v / 1e9);
+ if (v >= 1e6) return String.format(Locale.US, "%.2fM", v / 1e6);
+ if (v >= 1e3) return String.format(Locale.US, "%.1fK", v / 1e3);
+ return String.format(Locale.US, "%.0f", v);
+ }
+
+ /* ══════════ 详情 ══════════ */
+
+ void openDetail(String yahooSym) {
+ for (Quote q : quotes) {
+ if (q.yahoo.equals(yahooSym)) { detail = q; break; }
+ }
+ if (detail == null) detail = new Quote(yahooSym, null, yahooSym);
+ showPage(PAGE_DETAIL);
+ renderDetail();
+ net.submit(new Runnable() { @Override public void run() { fetchDetail(); }});
+ }
+
+ void renderDetail() {
+ if (detail == null) return;
+ dTitle.setText("DES · " + t("详情", "DETAIL") + " · " + detail.sym);
+ dName.setText(detail.name + " (" + detail.sym + ")");
+ dPrice.setText(detail.price + (detail.currency.isEmpty() ? "" : " " + detail.currency));
+ int cl = detail.loaded ? (detail.up ? RED : GREEN) : GREY;
+ dPrice.setTextColor(cl);
+ dChange.setText(detail.change + " " + detail.pct);
+ dChange.setTextColor(cl);
+ dH.setText(detail.highS); dL.setText(detail.lowS);
+ dP.setText(Double.isNaN(detail.prevNum) ? "--" : fmt(detail.prevNum));
+ dV.setText(detail.volS);
+ LinearLayout r3 = (LinearLayout) dUpdated.getParent();
+ dUpdated.setText((detail.exchange.isEmpty() ? "" : detail.exchange + " · ")
+ + t("更新 ", "updated ") + hm.format(new Date()));
+ }
+
+ void fetchDetail() {
+ try {
+ String json = httpGet("https://query1.finance.yahoo.com/v8/finance/chart/"
+ + URLEncoder.encode(detail.yahoo, "UTF-8") + "?range=1d&interval=5m");
+ JSONObject result = new JSONObject(json)
+ .getJSONObject("chart").getJSONArray("result").getJSONObject(0);
+ JSONObject meta = result.getJSONObject("meta");
+ double price = meta.optDouble("regularMarketPrice", Double.NaN);
+ double prev = meta.optDouble("chartPreviousClose",
+ meta.optDouble("previousClose", Double.NaN));
+ applyQuote(detail, price, prev, meta.optString("currency", ""),
+ meta.optString("exchangeName", ""),
+ meta.optDouble("regularMarketDayHigh", Double.NaN),
+ meta.optDouble("regularMarketDayLow", Double.NaN),
+ meta.optDouble("regularMarketVolume", Double.NaN));
+
+ final List pts = new ArrayList<>();
+ JSONObject quote = result.getJSONObject("indicators").getJSONArray("quote").getJSONObject(0);
+ org.json.JSONArray ts = result.optJSONArray("timestamp");
+ org.json.JSONArray cl = quote.optJSONArray("close");
+ double lastKnown = Double.NaN;
+ if (ts != null && cl != null) {
+ for (int i = 0; i < ts.length() && i < cl.length(); i++) {
+ double v = cl.optDouble(i, Double.NaN);
+ if (Double.isNaN(v)) continue;
+ MPoint mp = new MPoint();
+ mp.t = ts.getLong(i);
+ mp.price = v;
+ pts.add(mp);
+ lastKnown = v;
+ }
+ }
+ final double fprev = Double.isNaN(prev) ? lastKnown : prev;
+ ui.post(new Runnable() { @Override public void run() {
+ chart.setData(pts, fprev);
+ renderDetail();
+ quoteAdapter.notifyDataSetChanged();
+ }});
+ } catch (Exception ignored) { }
+ fetchRss("https://finance.yahoo.com/rss/2.0/headline?s=" + detail.yahoo, detailNews, detailNewsAdapter);
+ }
+
+ /* ══════════ 搜索 ══════════ */
+
+ void doSearch(final String q) {
+ net.submit(new Runnable() { @Override public void run() {
+ try {
+ String json = httpGet("https://query1.finance.yahoo.com/v1/finance/search?q="
+ + URLEncoder.encode(q, "UTF-8") + ""esCount=15&newsCount=0");
+ final List out = new ArrayList<>();
+ org.json.JSONArray arr = new JSONObject(json).optJSONArray("quotes");
+ if (arr != null) {
+ for (int i = 0; i < arr.length(); i++) {
+ JSONObject o = arr.getJSONObject(i);
+ SearchHit h = new SearchHit();
+ h.symbol = o.optString("symbol", "");
+ h.name = o.optString("shortname", o.optString("longname", ""));
+ h.exchange = o.optString("exchDisp", o.optString("exchange", ""));
+ h.type = o.optString("quoteType", "");
+ if (h.symbol.isEmpty()) continue;
+ out.add(h);
+ }
+ }
+ ui.post(new Runnable() { @Override public void run() {
+ hits.clear(); hits.addAll(out);
+ hitAdapter.notifyDataSetChanged();
+ }});
+ } catch (Exception ignored) { }
+ }});
+ }
+
+ void addToWatch(SearchHit h) {
+ for (Quote q : quotes)
+ if (q.yahoo.equals(h.symbol)) {
+ Toast.makeText(this, t("已在自选中", "Already in watchlist"), Toast.LENGTH_SHORT).show();
+ return;
+ }
+ Quote q = new Quote(h.symbol, null, h.name.isEmpty() ? h.symbol : h.name);
+ quotes.add(q);
+ saveWatchlist();
+ quoteAdapter.notifyDataSetChanged();
+ Toast.makeText(this, t("已添加:", "Added: ") + h.symbol, Toast.LENGTH_SHORT).show();
+ refreshQuotes();
+ }
+
+ void confirmRemove(final Quote q) {
+ new AlertDialog.Builder(this)
+ .setTitle(t("移除自选", "Remove"))
+ .setMessage((q.name.isEmpty() ? q.sym : q.name) + " (" + q.sym + ")")
+ .setPositiveButton(t("移除", "Remove"), new android.content.DialogInterface.OnClickListener() {
+ @Override public void onClick(android.content.DialogInterface d, int w) {
+ quotes.remove(q);
+ saveWatchlist();
+ quoteAdapter.notifyDataSetChanged();
+ }})
+ .setNegativeButton(t("取消", "Cancel"), null)
+ .show();
+ }
+
+ /* ══════════ 资讯 RSS ══════════ */
+
+ void fetchNews() {
+ fetchRss("https://finance.yahoo.com/news/rssindex", news, newsAdapter);
+ }
+
+ void fetchRss(final String feed, final List sink, final NewsAdapter adapter) {
+ net.submit(new Runnable() { @Override public void run() {
+ try {
+ String xml = httpGet(feed);
+ final List out = new ArrayList<>();
+ int i = 0;
+ while (out.size() < 30) {
+ int s = xml.indexOf("- ", i);
+ if (s < 0) break;
+ int e = xml.indexOf("
", s);
+ if (e < 0) break;
+ String seg = xml.substring(s, e);
+ NewsItem n = new NewsItem();
+ n.title = tag(seg, "title");
+ n.time = tag(seg, "pubDate");
+ n.link = tag(seg, "link");
+ if (!n.title.isEmpty()) out.add(n);
+ i = e + 7;
+ }
+ ui.post(new Runnable() { @Override public void run() {
+ if (out.isEmpty()) return;
+ sink.clear(); sink.addAll(out);
+ adapter.notifyDataSetChanged();
+ }});
+ } catch (Exception ignored) { }
+ }});
+ }
+
+ static String tag(String xml, String name) {
+ int s = xml.indexOf("<" + name + ">");
+ if (s < 0) return "";
+ s += name.length() + 2;
+ int e = xml.indexOf("" + name + ">", s);
+ if (e < 0) return "";
+ String v = xml.substring(s, e);
+ v = v.replace("&", "&").replace("<", "<").replace(">", ">")
+ .replace(""", "\"").replace("'", "'").replace("'", "'");
+ return v;
+ }
+
+ /* ══════════ 适配器 ══════════ */
+
+ class QuoteAdapter extends BaseAdapter {
+ @Override public int getCount() { return quotes.size(); }
+ @Override public Object getItem(int pos) { return quotes.get(pos); }
+ @Override public long getItemId(int pos) { return pos; }
+
+ @Override public View getView(int pos, View cv, ViewGroup parent) {
+ LinearLayout row;
+ if (cv instanceof LinearLayout) row = (LinearLayout) cv;
+ else {
+ row = new LinearLayout(MainActivity.this);
+ row.setOrientation(LinearLayout.HORIZONTAL);
+ row.setPadding(dp(12), dp(7), dp(12), dp(7));
+ row.addView(nameCol(), rowLP(2.4f));
+ TextView p = mono("", 14, WHITE, Typeface.BOLD); p.setGravity(Gravity.RIGHT);
+ row.addView(p, rightLP(1.2f));
+ TextView c = mono("", 13, WHITE, Typeface.NORMAL); c.setGravity(Gravity.RIGHT);
+ row.addView(c, rightLP(1.1f));
+ TextView g = mono("", 13, WHITE, Typeface.BOLD); g.setGravity(Gravity.RIGHT);
+ row.addView(g, rightLP(1.1f));
+ }
+ final Quote q = quotes.get(pos);
+ LinearLayout nc = (LinearLayout) row.getChildAt(0);
+ TextView nm = (TextView) nc.getChildAt(0);
+ TextView cd = (TextView) nc.getChildAt(1);
+ nm.setText(q.name.isEmpty() ? q.sym : q.name);
+ cd.setText(q.sym + (q.currency.isEmpty() ? "" : " · " + q.currency));
+ nm.setTextColor(q.loaded ? WHITE : GREY);
+
+ int cl = q.loaded ? (q.up ? RED : GREEN) : GREY;
+ TextView p = (TextView) row.getChildAt(1);
+ TextView c = (TextView) row.getChildAt(2);
+ TextView g = (TextView) row.getChildAt(3);
+ p.setText(q.price); p.setTextColor(cl);
+ c.setText(q.change); c.setTextColor(cl);
+ g.setText(q.pct); g.setTextColor(cl);
+ row.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) {
+ openDetail(q.yahoo);
+ }});
+ row.setOnLongClickListener(new View.OnLongClickListener() {
+ @Override public boolean onLongClick(View v) { confirmRemove(q); return true; }
+ });
+ return row;
+ }
+
+ LinearLayout nameCol() {
+ LinearLayout col = new LinearLayout(MainActivity.this);
+ col.setOrientation(LinearLayout.VERTICAL);
+ col.addView(mono("", 14, WHITE, Typeface.BOLD), wrapWrap());
+ col.addView(mono("", 9, GREY, Typeface.NORMAL), wrapWrap());
+ return col;
+ }
+ }
+
+ class NewsAdapter extends BaseAdapter {
+ final List src;
+ NewsAdapter(List src) { this.src = src; }
+ @Override public int getCount() { return src.size(); }
+ @Override public Object getItem(int pos) { return src.get(pos); }
+ @Override public long getItemId(int pos) { return pos; }
+
+ @Override public View getView(int pos, View cv, ViewGroup parent) {
+ LinearLayout row;
+ if (cv instanceof LinearLayout) row = (LinearLayout) cv;
+ else {
+ row = new LinearLayout(MainActivity.this);
+ row.setOrientation(LinearLayout.HORIZONTAL);
+ row.setPadding(dp(12), dp(8), dp(12), dp(8));
+ TextView tt = mono("", 10, ORANGE, Typeface.BOLD);
+ row.addView(tt, rowLP(0.55f));
+ TextView x = mono("", 12, WHITE, Typeface.NORMAL);
+ row.addView(x, rowLP(4.2f));
+ }
+ NewsItem n = src.get(pos);
+ String time = "";
+ if (n.time.length() >= 5) {
+ int c = n.time.indexOf(':');
+ if (c >= 3) time = n.time.substring(c - 2, c + 3);
+ }
+ ((TextView) row.getChildAt(0)).setText(time);
+ ((TextView) row.getChildAt(1)).setText(n.title);
+ return row;
+ }
+ }
+
+ class HitAdapter extends BaseAdapter {
+ @Override public int getCount() { return hits.size(); }
+ @Override public Object getItem(int pos) { return hits.get(pos); }
+ @Override public long getItemId(int pos) { return pos; }
+
+ @Override public View getView(int pos, View cv, ViewGroup parent) {
+ LinearLayout row;
+ if (cv instanceof LinearLayout) row = (LinearLayout) cv;
+ else {
+ row = new LinearLayout(MainActivity.this);
+ row.setOrientation(LinearLayout.HORIZONTAL);
+ row.setPadding(dp(12), dp(9), dp(12), dp(9));
+ TextView s = mono("", 14, ORANGE, Typeface.BOLD);
+ row.addView(s, rowLP(1.3f));
+ LinearLayout col = new LinearLayout(MainActivity.this);
+ col.setOrientation(LinearLayout.VERTICAL);
+ col.addView(mono("", 12, WHITE, Typeface.NORMAL), wrapWrap());
+ col.addView(mono("", 9, GREY, Typeface.NORMAL), wrapWrap());
+ row.addView(col, rowLP(2.8f));
+ TextView add = mono("[+]", 14, GREEN, Typeface.BOLD);
+ add.setGravity(Gravity.RIGHT | Gravity.CENTER_VERTICAL);
+ row.addView(add, rightLP(0.5f));
+ }
+ final SearchHit h = hits.get(pos);
+ ((TextView) row.getChildAt(0)).setText(h.symbol);
+ LinearLayout col = (LinearLayout) row.getChildAt(1);
+ ((TextView) col.getChildAt(0)).setText(h.name);
+ ((TextView) col.getChildAt(1)).setText(h.type + " · " + h.exchange);
+ row.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) {
+ addToWatch(h);
+ openDetail(h.symbol);
+ }});
+ row.setOnLongClickListener(new View.OnLongClickListener() {
+ @Override public boolean onLongClick(View v) { addToWatch(h); return true; }
+ });
+ return row;
+ }
+ }
+
+ /* ══════════ 分时图 ══════════ */
+
+ class MinuteChart extends View {
+ List data = new ArrayList<>();
+ double prev = Double.NaN;
+ final Paint pLine = new Paint(), pFill = new Paint(), pGrid = new Paint(),
+ pBase = new Paint(), pText = new Paint(), pLast = new Paint();
+ double lo = 0, hi = 1;
+ double loT = 0, hiT = 1;
+
+ MinuteChart(Context c) {
+ super(c);
+ pLine.setColor(ORANGE); pLine.setStyle(Paint.Style.STROKE); pLine.setStrokeWidth(dpx(1.5f));
+ pFill.setColor(0x33FF9900); pFill.setStyle(Paint.Style.FILL);
+ pGrid.setColor(LINE); pGrid.setStrokeWidth(1);
+ pBase.setColor(AMBER); pBase.setStyle(Paint.Style.STROKE);
+ pBase.setPathEffect(new android.graphics.DashPathEffect(new float[]{6, 6}, 0));
+ pText.setColor(GREY); pText.setTextSize(dp(9)); pText.setTypeface(Typeface.MONOSPACE);
+ pLast.setTextSize(dp(10));
+ pLast.setTypeface(Typeface.create(Typeface.MONOSPACE, Typeface.BOLD));
+ }
+
+ void setData(List pts, double prevClose) {
+ data = pts; prev = prevClose;
+ if (pts.isEmpty()) { invalidate(); return; }
+ lo = Double.MAX_VALUE; hi = -Double.MAX_VALUE;
+ for (MPoint p : pts) { lo = Math.min(lo, p.price); hi = Math.max(hi, p.price); }
+ if (!Double.isNaN(prevClose)) { lo = Math.min(lo, prevClose); hi = Math.max(hi, prevClose); }
+ if (hi - lo < 1e-9) { hi += 1; lo -= 1; }
+ double pad = (hi - lo) * 0.08; hi += pad; lo -= pad;
+ loT = pts.get(0).t; hiT = pts.get(pts.size() - 1).t;
+ if (hiT <= loT) hiT = loT + 1;
+ invalidate();
+ }
+
+ @Override protected void onDraw(Canvas cv) {
+ int w = getWidth(), h = getHeight();
+ int pl = dp(8), pr = dp(60), pt = dp(14), pb = dp(16);
+ cv.drawColor(BG);
+ for (int i = 0; i <= 4; i++) {
+ float y = pt + (h - pt - pb) * i / 4f;
+ cv.drawLine(pl, y, w - pr, y, pGrid);
+ double v = hi - (hi - lo) * i / 4.0;
+ cv.drawText(fmt(v), w - pr + dp(4), y + dp(3), pText);
+ }
+ if (data.isEmpty()) {
+ cv.drawText(t("暂无分时数据", "No intraday data"), pl, h / 2f, pText);
+ return;
+ }
+ if (!Double.isNaN(prev)) {
+ float y = (float) (pt + (h - pt - pb) * (1 - (prev - lo) / (hi - lo)));
+ cv.drawLine(pl, y, w - pr, y, pBase);
+ }
+ Path line = new Path(), fill = new Path();
+ boolean first = true;
+ for (MPoint p : data) {
+ float x = (float) (pl + (w - pl - pr) * (p.t - loT) / (hiT - loT));
+ float y = (float) (pt + (h - pt - pb) * (1 - (p.price - lo) / (hi - lo)));
+ if (first) { line.moveTo(x, y); fill.moveTo(x, h - pb); fill.lineTo(x, y); first = false; }
+ else { line.lineTo(x, y); fill.lineTo(x, y); }
+ }
+ MPoint lastP = data.get(data.size() - 1);
+ fill.lineTo(w - pr, h - pb); fill.close();
+ cv.drawPath(fill, pFill);
+ cv.drawPath(line, pLine);
+ float lx = (float) (pl + (w - pl - pr) * (lastP.t - loT) / (hiT - loT));
+ float ly = (float) (pt + (h - pt - pb) * (1 - (lastP.price - lo) / (hi - lo)));
+ pLast.setColor(lastP.price >= prev ? RED : GREEN);
+ cv.drawCircle(lx, ly, dpx(2.5f), pLast);
+ String ls = fmt(lastP.price);
+ cv.drawText(ls, Math.min(lx + dp(4), w - pLast.measureText(ls) - dp(2)),
+ Math.max(ly - dp(6), pt + dp(8)), pLast);
+ SimpleDateFormat tf = new SimpleDateFormat("HH:mm", Locale.US);
+ cv.drawText(tf.format(new Date((long) (loT * 1000L))), pl, h - dp(4), pText);
+ String rs = tf.format(new Date((long) (hiT * 1000L)));
+ cv.drawText(rs, w - pr - pText.measureText(rs), h - dp(4), pText);
+ }
+ }
+
+ @Override protected void onDestroy() {
+ super.onDestroy();
+ ui.removeCallbacksAndMessages(null);
+ net.shutdownNow();
+ }
+}