diff --git a/mac/src/App/EmbeddedBackend/WebBackend b/mac/src/App/EmbeddedBackend/WebBackend index c31469b..c3e8c62 100755 Binary files a/mac/src/App/EmbeddedBackend/WebBackend and b/mac/src/App/EmbeddedBackend/WebBackend differ diff --git a/mac/src/AppMobile/Assets.xcassets/AccentColor.colorset/Contents.json b/mac/src/AppMobile/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/mac/src/AppMobile/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/mac/src/AppMobile/Assets.xcassets/AppIcon.appiconset/Contents.json b/mac/src/AppMobile/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..dfa108c --- /dev/null +++ b/mac/src/AppMobile/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,110 @@ +{ + "images" : [ + { + "filename" : "icon-20@2x.png", + "idiom" : "iphone", + "scale" : "2x", + "size" : "20x20" + }, + { + "filename" : "icon-20@3x.png", + "idiom" : "iphone", + "scale" : "3x", + "size" : "20x20" + }, + { + "filename" : "icon-29@2x.png", + "idiom" : "iphone", + "scale" : "2x", + "size" : "29x29" + }, + { + "filename" : "icon-29@3x.png", + "idiom" : "iphone", + "scale" : "3x", + "size" : "29x29" + }, + { + "filename" : "icon-40@2x.png", + "idiom" : "iphone", + "scale" : "2x", + "size" : "40x40" + }, + { + "filename" : "icon-40@3x.png", + "idiom" : "iphone", + "scale" : "3x", + "size" : "40x40" + }, + { + "filename" : "icon-60@2x.png", + "idiom" : "iphone", + "scale" : "2x", + "size" : "60x60" + }, + { + "filename" : "icon-60@3x.png", + "idiom" : "iphone", + "scale" : "3x", + "size" : "60x60" + }, + { + "filename" : "icon-20@1x-ipad.png", + "idiom" : "ipad", + "scale" : "1x", + "size" : "20x20" + }, + { + "filename" : "icon-20@2x-ipad.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "20x20" + }, + { + "filename" : "icon-29@1x-ipad.png", + "idiom" : "ipad", + "scale" : "1x", + "size" : "29x29" + }, + { + "filename" : "icon-29@2x-ipad.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "29x29" + }, + { + "filename" : "icon-40@1x-ipad.png", + "idiom" : "ipad", + "scale" : "1x", + "size" : "40x40" + }, + { + "filename" : "icon-40@2x-ipad.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "40x40" + }, + { + "filename" : "icon-76@2x-ipad.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "76x76" + }, + { + "filename" : "icon-83.5@2x-ipad.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "83.5x83.5" + }, + { + "filename" : "icon-1024-marketing.png", + "idiom" : "ios-marketing", + "scale" : "1x", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/mac/src/AppMobile/Assets.xcassets/AppIcon.appiconset/icon-1024-marketing.png b/mac/src/AppMobile/Assets.xcassets/AppIcon.appiconset/icon-1024-marketing.png new file mode 100644 index 0000000..bb9b5b9 Binary files /dev/null and b/mac/src/AppMobile/Assets.xcassets/AppIcon.appiconset/icon-1024-marketing.png differ diff --git a/mac/src/AppMobile/Assets.xcassets/AppIcon.appiconset/icon-20@1x-ipad.png b/mac/src/AppMobile/Assets.xcassets/AppIcon.appiconset/icon-20@1x-ipad.png new file mode 100644 index 0000000..466520a Binary files /dev/null and b/mac/src/AppMobile/Assets.xcassets/AppIcon.appiconset/icon-20@1x-ipad.png differ diff --git a/mac/src/AppMobile/Assets.xcassets/AppIcon.appiconset/icon-20@2x-ipad.png b/mac/src/AppMobile/Assets.xcassets/AppIcon.appiconset/icon-20@2x-ipad.png new file mode 100644 index 0000000..e40a4f1 Binary files /dev/null and b/mac/src/AppMobile/Assets.xcassets/AppIcon.appiconset/icon-20@2x-ipad.png differ diff --git a/mac/src/AppMobile/Assets.xcassets/AppIcon.appiconset/icon-20@2x.png b/mac/src/AppMobile/Assets.xcassets/AppIcon.appiconset/icon-20@2x.png new file mode 100644 index 0000000..e40a4f1 Binary files /dev/null and b/mac/src/AppMobile/Assets.xcassets/AppIcon.appiconset/icon-20@2x.png differ diff --git a/mac/src/AppMobile/Assets.xcassets/AppIcon.appiconset/icon-20@3x.png b/mac/src/AppMobile/Assets.xcassets/AppIcon.appiconset/icon-20@3x.png new file mode 100644 index 0000000..2b8117a Binary files /dev/null and b/mac/src/AppMobile/Assets.xcassets/AppIcon.appiconset/icon-20@3x.png differ diff --git a/mac/src/AppMobile/Assets.xcassets/AppIcon.appiconset/icon-29@1x-ipad.png b/mac/src/AppMobile/Assets.xcassets/AppIcon.appiconset/icon-29@1x-ipad.png new file mode 100644 index 0000000..b988d74 Binary files /dev/null and b/mac/src/AppMobile/Assets.xcassets/AppIcon.appiconset/icon-29@1x-ipad.png differ diff --git a/mac/src/AppMobile/Assets.xcassets/AppIcon.appiconset/icon-29@2x-ipad.png b/mac/src/AppMobile/Assets.xcassets/AppIcon.appiconset/icon-29@2x-ipad.png new file mode 100644 index 0000000..f25b929 Binary files /dev/null and b/mac/src/AppMobile/Assets.xcassets/AppIcon.appiconset/icon-29@2x-ipad.png differ diff --git a/mac/src/AppMobile/Assets.xcassets/AppIcon.appiconset/icon-29@2x.png b/mac/src/AppMobile/Assets.xcassets/AppIcon.appiconset/icon-29@2x.png new file mode 100644 index 0000000..f25b929 Binary files /dev/null and b/mac/src/AppMobile/Assets.xcassets/AppIcon.appiconset/icon-29@2x.png differ diff --git a/mac/src/AppMobile/Assets.xcassets/AppIcon.appiconset/icon-29@3x.png b/mac/src/AppMobile/Assets.xcassets/AppIcon.appiconset/icon-29@3x.png new file mode 100644 index 0000000..8230a77 Binary files /dev/null and b/mac/src/AppMobile/Assets.xcassets/AppIcon.appiconset/icon-29@3x.png differ diff --git a/mac/src/AppMobile/Assets.xcassets/AppIcon.appiconset/icon-40@1x-ipad.png b/mac/src/AppMobile/Assets.xcassets/AppIcon.appiconset/icon-40@1x-ipad.png new file mode 100644 index 0000000..e40a4f1 Binary files /dev/null and b/mac/src/AppMobile/Assets.xcassets/AppIcon.appiconset/icon-40@1x-ipad.png differ diff --git a/mac/src/AppMobile/Assets.xcassets/AppIcon.appiconset/icon-40@2x-ipad.png b/mac/src/AppMobile/Assets.xcassets/AppIcon.appiconset/icon-40@2x-ipad.png new file mode 100644 index 0000000..649c20b Binary files /dev/null and b/mac/src/AppMobile/Assets.xcassets/AppIcon.appiconset/icon-40@2x-ipad.png differ diff --git a/mac/src/AppMobile/Assets.xcassets/AppIcon.appiconset/icon-40@2x.png b/mac/src/AppMobile/Assets.xcassets/AppIcon.appiconset/icon-40@2x.png new file mode 100644 index 0000000..649c20b Binary files /dev/null and b/mac/src/AppMobile/Assets.xcassets/AppIcon.appiconset/icon-40@2x.png differ diff --git a/mac/src/AppMobile/Assets.xcassets/AppIcon.appiconset/icon-40@3x.png b/mac/src/AppMobile/Assets.xcassets/AppIcon.appiconset/icon-40@3x.png new file mode 100644 index 0000000..df31be1 Binary files /dev/null and b/mac/src/AppMobile/Assets.xcassets/AppIcon.appiconset/icon-40@3x.png differ diff --git a/mac/src/AppMobile/Assets.xcassets/AppIcon.appiconset/icon-60@2x.png b/mac/src/AppMobile/Assets.xcassets/AppIcon.appiconset/icon-60@2x.png new file mode 100644 index 0000000..df31be1 Binary files /dev/null and b/mac/src/AppMobile/Assets.xcassets/AppIcon.appiconset/icon-60@2x.png differ diff --git a/mac/src/AppMobile/Assets.xcassets/AppIcon.appiconset/icon-60@3x.png b/mac/src/AppMobile/Assets.xcassets/AppIcon.appiconset/icon-60@3x.png new file mode 100644 index 0000000..2f58cc3 Binary files /dev/null and b/mac/src/AppMobile/Assets.xcassets/AppIcon.appiconset/icon-60@3x.png differ diff --git a/mac/src/AppMobile/Assets.xcassets/AppIcon.appiconset/icon-76@2x-ipad.png b/mac/src/AppMobile/Assets.xcassets/AppIcon.appiconset/icon-76@2x-ipad.png new file mode 100644 index 0000000..7f79f23 Binary files /dev/null and b/mac/src/AppMobile/Assets.xcassets/AppIcon.appiconset/icon-76@2x-ipad.png differ diff --git a/mac/src/AppMobile/Assets.xcassets/AppIcon.appiconset/icon-83.5@2x-ipad.png b/mac/src/AppMobile/Assets.xcassets/AppIcon.appiconset/icon-83.5@2x-ipad.png new file mode 100644 index 0000000..b824e4e Binary files /dev/null and b/mac/src/AppMobile/Assets.xcassets/AppIcon.appiconset/icon-83.5@2x-ipad.png differ diff --git a/mac/src/AppMobile/Assets.xcassets/Contents.json b/mac/src/AppMobile/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/mac/src/AppMobile/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/mac/src/AppMobile/Help/help.html b/mac/src/AppMobile/Help/help.html new file mode 100644 index 0000000..027daa5 --- /dev/null +++ b/mac/src/AppMobile/Help/help.html @@ -0,0 +1,196 @@ + + + + + + Help & Quick Start + + + +
+

Help & Quick Start

+

A quick guide to reading signals, choosing settings, and troubleshooting.

+ +
+

Start in 60 Seconds

+
    +
  1. Set a symbol like AAPL or BTC-USD.
  2. +
  3. Choose Timeframe (1d is a good default) and Period (6mo).
  4. +
  5. Keep Ignore potentially live last bar enabled.
  6. +
  7. Review trend status and chart markers.
  8. +
  9. Use Export to download CSV/PDF outputs.
  10. +
+
+ +
+

Signal Rules

+

+ real_bull close above previous high + real_bear close below previous low + fake close inside previous range +

+ +
+ +
+

Data Settings

+

Core fields

+ +

Optional filters

+ +
+ +
+

Chart Reading

+ +
+ +
+

Troubleshooting

+ +
+
+ + diff --git a/mac/src/AppMobile/ManeshTraderMobileApp.swift b/mac/src/AppMobile/ManeshTraderMobileApp.swift new file mode 100644 index 0000000..153c164 --- /dev/null +++ b/mac/src/AppMobile/ManeshTraderMobileApp.swift @@ -0,0 +1,10 @@ +import SwiftUI + +@main +struct ManeshTraderMobileApp: App { + var body: some Scene { + WindowGroup { + MobileContentView() + } + } +} diff --git a/mac/src/AppMobile/MobileContentView.swift b/mac/src/AppMobile/MobileContentView.swift new file mode 100644 index 0000000..2578b00 --- /dev/null +++ b/mac/src/AppMobile/MobileContentView.swift @@ -0,0 +1,615 @@ +import SwiftUI +import WebKit +import Foundation +import Observation + +struct MobileContentView: View { + @State private var host = MobileTraderHost() + @State private var didAutostart = false + @AppStorage("mt_mobile_setup_completed") private var setupCompleted = false + @AppStorage("mt_mobile_backend_url") private var storedBackendURL = "http://127.0.0.1:8501" + @AppStorage("mt_mobile_symbol") private var storedSymbol = "AAPL" + @AppStorage("mt_mobile_interval") private var storedInterval = "1d" + @AppStorage("mt_mobile_period") private var storedPeriod = "6mo" + @AppStorage("mt_mobile_max_bars") private var storedMaxBars = 500 + @AppStorage("mt_mobile_drop_live") private var storedDropLive = true + @AppStorage("mt_mobile_use_body_range") private var storedUseBodyRange = false + @AppStorage("mt_mobile_volume_filter_enabled") private var storedVolumeFilterEnabled = false + @AppStorage("mt_mobile_volume_sma_window") private var storedVolumeSMAWindow = 20 + @AppStorage("mt_mobile_volume_multiplier") private var storedVolumeMultiplier = 1.0 + @AppStorage("mt_mobile_gray_fake") private var storedGrayFake = true + @AppStorage("mt_mobile_hide_market_closed_gaps") private var storedHideMarketClosedGaps = true + @AppStorage("mt_mobile_enable_auto_refresh") private var storedEnableAutoRefresh = false + @AppStorage("mt_mobile_refresh_sec") private var storedRefreshSeconds = 60 + + @State private var showSetupSheet = false + @State private var showHelpSheet = false + @State private var setupDraft = MobileUserSetupPreferences.default + @State private var setupBackendURL = "http://127.0.0.1:8501" + + var body: some View { + NavigationStack { + Group { + if let url = host.serverURL { + MobileWebView(url: url, reloadToken: host.reloadToken) + } else { + launchView + } + } + .navigationTitle("ManeshTrader") + .toolbar { + ToolbarItemGroup(placement: .topBarTrailing) { + Button("Help") { + showHelpSheet = true + } + + Button("Setup") { + setupDraft = storedWebPreferences.normalized().setupDefaults + setupBackendURL = storedBackendURL + showSetupSheet = true + } + + Button("Reload") { + _ = syncHostPreferencesFromStorage() + host.reloadWebView() + } + .disabled(host.serverURL == nil) + } + } + } + .onAppear { + guard !didAutostart else { return } + didAutostart = true + + let normalized = syncHostPreferencesFromStorage() + if !setupCompleted { + setupDraft = normalized.setupDefaults + setupBackendURL = storedBackendURL + showSetupSheet = true + } + } + .sheet(isPresented: $showSetupSheet) { + setupSheet + } + .sheet(isPresented: $showHelpSheet) { + MobileHelpSheetView() + } + } + + private var storedWebPreferences: MobileWebPreferences { + MobileWebPreferences( + symbol: storedSymbol, + interval: storedInterval, + period: storedPeriod, + maxBars: storedMaxBars, + dropLive: storedDropLive, + useBodyRange: storedUseBodyRange, + volumeFilterEnabled: storedVolumeFilterEnabled, + volumeSMAWindow: storedVolumeSMAWindow, + volumeMultiplier: storedVolumeMultiplier, + grayFake: storedGrayFake, + hideMarketClosedGaps: storedHideMarketClosedGaps, + enableAutoRefresh: storedEnableAutoRefresh, + refreshSeconds: storedRefreshSeconds + ) + } + + private var setupSheet: some View { + NavigationStack { + Form { + Section { + Text("Connect this app to a reachable ManeshTrader web backend. iOS cannot run the embedded backend executable directly.") + .foregroundStyle(.secondary) + } + + Section("Backend") { + TextField("Base URL", text: $setupBackendURL) + .textInputAutocapitalization(.never) + .autocorrectionDisabled(true) + .keyboardType(.URL) + Text("Examples: http://192.168.1.10:8501 or https://your-host") + .font(.caption) + .foregroundStyle(.secondary) + } + + Section("Data Defaults") { + TextField("Symbol", text: $setupDraft.symbol) + .textInputAutocapitalization(.characters) + .autocorrectionDisabled(true) + + Picker("Timeframe", selection: $setupDraft.timeframe) { + ForEach(MobileUserSetupPreferences.timeframeOptions, id: \.self) { option in + Text(option).tag(option) + } + } + + Picker("Period", selection: $setupDraft.period) { + ForEach(MobileUserSetupPreferences.periodOptions, id: \.self) { option in + Text(option).tag(option) + } + } + + Stepper(value: $setupDraft.maxBars, in: 20...5000, step: 10) { + Text("Max bars: \(setupDraft.maxBars)") + } + } + + if let validationMessage = host.validationMessage { + Section { + Text(validationMessage) + .foregroundStyle(.red) + .font(.footnote) + } + } + } + .navigationTitle("Setup") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Use Defaults") { + applySetup(MobileUserSetupPreferences.default, backendURL: setupBackendURL, markCompleted: true) + } + } + + ToolbarItem(placement: .confirmationAction) { + Button("Save") { + applySetup(setupDraft.normalized(), backendURL: setupBackendURL, markCompleted: true) + } + } + } + } + } + + private func applySetup(_ preferences: MobileUserSetupPreferences, backendURL: String, markCompleted: Bool) { + let normalizedSetup = preferences.normalized() + let mergedPreferences = storedWebPreferences + .applyingSetup(normalizedSetup) + .normalized() + + storedBackendURL = backendURL.trimmingCharacters(in: .whitespacesAndNewlines) + writeWebPreferencesToStorage(mergedPreferences) + + host.applyPreferences(mergedPreferences, backendURLString: storedBackendURL) + host.reloadWebView() + + if markCompleted { + setupCompleted = true + } + + if host.serverURL != nil { + showSetupSheet = false + } + } + + @discardableResult + private func syncHostPreferencesFromStorage() -> MobileWebPreferences { + let normalized = storedWebPreferences.normalized() + writeWebPreferencesToStorage(normalized) + host.applyPreferences(normalized, backendURLString: storedBackendURL) + return normalized + } + + private func writeWebPreferencesToStorage(_ preferences: MobileWebPreferences) { + storedSymbol = preferences.symbol + storedInterval = preferences.interval + storedPeriod = preferences.period + storedMaxBars = preferences.maxBars + storedDropLive = preferences.dropLive + storedUseBodyRange = preferences.useBodyRange + storedVolumeFilterEnabled = preferences.volumeFilterEnabled + storedVolumeSMAWindow = preferences.volumeSMAWindow + storedVolumeMultiplier = preferences.volumeMultiplier + storedGrayFake = preferences.grayFake + storedHideMarketClosedGaps = preferences.hideMarketClosedGaps + storedEnableAutoRefresh = preferences.enableAutoRefresh + storedRefreshSeconds = preferences.refreshSeconds + } + + private var launchView: some View { + VStack(spacing: 14) { + Image(systemName: "network.badge.shield.half.filled") + .font(.system(size: 42)) + .foregroundStyle(.blue) + + Text("Connect ManeshTrader") + .font(.title3.weight(.semibold)) + + Text("Set a backend URL reachable from this device, then load the web app in place.") + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: 460) + + Button("Open Setup") { + setupDraft = storedWebPreferences.normalized().setupDefaults + setupBackendURL = storedBackendURL + showSetupSheet = true + } + .buttonStyle(.borderedProminent) + + if let validationMessage = host.validationMessage { + Text(validationMessage) + .font(.footnote) + .foregroundStyle(.red) + .multilineTextAlignment(.center) + .frame(maxWidth: 460) + } + } + .padding(20) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background( + LinearGradient( + colors: [ + Color(uiColor: .systemBackground), + Color(uiColor: .secondarySystemBackground), + ], + startPoint: .top, + endPoint: .bottom + ) + ) + } +} + +private struct MobileHelpSheetView: View { + @Environment(\.dismiss) private var dismiss + + private var helpFileURL: URL? { + let fm = FileManager.default + let candidates = [ + Bundle.main.url(forResource: "help", withExtension: "html", subdirectory: "Help"), + Bundle.main.url(forResource: "help", withExtension: "html"), + Bundle.main.resourceURL?.appendingPathComponent("Help/help.html"), + Bundle.main.resourceURL?.appendingPathComponent("help.html"), + ].compactMap { $0 } + + return candidates.first(where: { fm.fileExists(atPath: $0.path) }) + } + + var body: some View { + NavigationStack { + Group { + if let helpFileURL { + MobileHelpDocumentWebView(fileURL: helpFileURL) + } else { + VStack(alignment: .leading, spacing: 8) { + Text("Help file not found.") + .font(.title3.weight(.semibold)) + Text("Expected bundled file: help.html") + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .padding(24) + } + } + .navigationTitle("Help & Quick Start") + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { dismiss() } + } + } + } + } +} + +private struct MobileHelpDocumentWebView: UIViewRepresentable { + let fileURL: URL + + func makeUIView(context: Context) -> WKWebView { + let webView = WKWebView(frame: .zero) + webView.allowsBackForwardNavigationGestures = true + webView.loadFileURL(fileURL, allowingReadAccessTo: fileURL.deletingLastPathComponent()) + return webView + } + + func updateUIView(_ webView: WKWebView, context: Context) { + if webView.url != fileURL { + webView.loadFileURL(fileURL, allowingReadAccessTo: fileURL.deletingLastPathComponent()) + } + } +} + +private struct MobileWebView: UIViewRepresentable { + let url: URL + let reloadToken: Int + + func makeUIView(context: Context) -> WKWebView { + let webView = WKWebView(frame: .zero) + webView.customUserAgent = "ManeshTraderMobile" + webView.allowsBackForwardNavigationGestures = true + webView.navigationDelegate = context.coordinator + context.coordinator.attach(webView) + webView.load(URLRequest(url: url)) + return webView + } + + func updateUIView(_ webView: WKWebView, context: Context) { + if context.coordinator.lastReloadToken != reloadToken || webView.url != url { + context.coordinator.lastReloadToken = reloadToken + webView.load(URLRequest(url: url)) + } + } + + func makeCoordinator() -> Coordinator { + Coordinator(url: url, reloadToken: reloadToken) + } + + final class Coordinator: NSObject, WKNavigationDelegate { + private let url: URL + private weak var webView: WKWebView? + private var pendingRetry: DispatchWorkItem? + var lastReloadToken: Int + + init(url: URL, reloadToken: Int) { + self.url = url + self.lastReloadToken = reloadToken + } + + func attach(_ webView: WKWebView) { + self.webView = webView + } + + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + pendingRetry?.cancel() + pendingRetry = nil + } + + func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { + scheduleRetry() + } + + func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { + scheduleRetry() + } + + private func scheduleRetry() { + pendingRetry?.cancel() + let retry = DispatchWorkItem { [weak self] in + guard let self, let webView = self.webView else { return } + webView.load(URLRequest(url: self.url)) + } + pendingRetry = retry + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0, execute: retry) + } + } +} + +@Observable +@MainActor +private final class MobileTraderHost { + var reloadToken = 0 + var validationMessage: String? + var webQueryItems: [URLQueryItem] = [] + var backendURLString = "" + + var serverURL: URL? { + guard var components = URLComponents(string: backendURLString), + let scheme = components.scheme?.lowercased(), + ["http", "https"].contains(scheme), + components.host != nil else { + return nil + } + + components.queryItems = webQueryItems.isEmpty ? nil : webQueryItems + return components.url + } + + func applyPreferences(_ preferences: MobileWebPreferences, backendURLString: String) { + self.backendURLString = backendURLString.trimmingCharacters(in: .whitespacesAndNewlines) + webQueryItems = preferences.normalized().queryItems + + if serverURL == nil { + validationMessage = "Enter a valid backend URL with http:// or https:// and a host." + } else { + validationMessage = nil + } + } + + func reloadWebView() { + guard serverURL != nil else { return } + reloadToken += 1 + } +} + +private struct MobileUserSetupPreferences { + static let timeframeOptions = ["1m", "2m", "5m", "15m", "30m", "60m", "90m", "1h", "1d", "5d", "1wk", "1mo"] + static let periodOptions = ["1d", "5d", "1mo", "3mo", "6mo", "1y", "2y", "5y", "10y", "max"] + + static let `default` = MobileUserSetupPreferences(symbol: "AAPL", timeframe: "1d", period: "6mo", maxBars: 500) + + var symbol: String + var timeframe: String + var period: String + var maxBars: Int + + func normalized() -> MobileUserSetupPreferences { + let normalizedSymbol = symbol.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() + let safeSymbol = normalizedSymbol.isEmpty ? "AAPL" : normalizedSymbol + + let safeTimeframe = Self.timeframeOptions.contains(timeframe) ? timeframe : "1d" + let safePeriod = Self.periodOptions.contains(period) ? period : "6mo" + let safeMaxBars = min(5000, max(20, maxBars)) + + return MobileUserSetupPreferences( + symbol: safeSymbol, + timeframe: safeTimeframe, + period: safePeriod, + maxBars: safeMaxBars + ) + } +} + +private struct MobileWebPreferences: Codable { + var symbol: String + var interval: String + var period: String + var maxBars: Int + var dropLive: Bool + var useBodyRange: Bool + var volumeFilterEnabled: Bool + var volumeSMAWindow: Int + var volumeMultiplier: Double + var grayFake: Bool + var hideMarketClosedGaps: Bool + var enableAutoRefresh: Bool + var refreshSeconds: Int + + init( + symbol: String, + interval: String, + period: String, + maxBars: Int, + dropLive: Bool, + useBodyRange: Bool, + volumeFilterEnabled: Bool, + volumeSMAWindow: Int, + volumeMultiplier: Double, + grayFake: Bool, + hideMarketClosedGaps: Bool, + enableAutoRefresh: Bool, + refreshSeconds: Int + ) { + self.symbol = symbol + self.interval = interval + self.period = period + self.maxBars = maxBars + self.dropLive = dropLive + self.useBodyRange = useBodyRange + self.volumeFilterEnabled = volumeFilterEnabled + self.volumeSMAWindow = volumeSMAWindow + self.volumeMultiplier = volumeMultiplier + self.grayFake = grayFake + self.hideMarketClosedGaps = hideMarketClosedGaps + self.enableAutoRefresh = enableAutoRefresh + self.refreshSeconds = refreshSeconds + } + + static let `default` = MobileWebPreferences( + symbol: "AAPL", + interval: "1d", + period: "6mo", + maxBars: 500, + dropLive: true, + useBodyRange: false, + volumeFilterEnabled: false, + volumeSMAWindow: 20, + volumeMultiplier: 1.0, + grayFake: true, + hideMarketClosedGaps: true, + enableAutoRefresh: false, + refreshSeconds: 60 + ) + + private enum CodingKeys: String, CodingKey { + case symbol + case interval + case period + case maxBars = "max_bars" + case dropLive = "drop_live" + case useBodyRange = "use_body_range" + case volumeFilterEnabled = "volume_filter_enabled" + case volumeSMAWindow = "volume_sma_window" + case volumeMultiplier = "volume_multiplier" + case grayFake = "gray_fake" + case hideMarketClosedGaps = "hide_market_closed_gaps" + case enableAutoRefresh = "enable_auto_refresh" + case refreshSeconds = "refresh_sec" + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let defaults = Self.default + + symbol = try container.decodeIfPresent(String.self, forKey: .symbol) ?? defaults.symbol + interval = try container.decodeIfPresent(String.self, forKey: .interval) ?? defaults.interval + period = try container.decodeIfPresent(String.self, forKey: .period) ?? defaults.period + maxBars = try container.decodeIfPresent(Int.self, forKey: .maxBars) ?? defaults.maxBars + dropLive = try container.decodeIfPresent(Bool.self, forKey: .dropLive) ?? defaults.dropLive + useBodyRange = try container.decodeIfPresent(Bool.self, forKey: .useBodyRange) ?? defaults.useBodyRange + volumeFilterEnabled = try container.decodeIfPresent(Bool.self, forKey: .volumeFilterEnabled) ?? defaults.volumeFilterEnabled + volumeSMAWindow = try container.decodeIfPresent(Int.self, forKey: .volumeSMAWindow) ?? defaults.volumeSMAWindow + volumeMultiplier = try container.decodeIfPresent(Double.self, forKey: .volumeMultiplier) ?? defaults.volumeMultiplier + grayFake = try container.decodeIfPresent(Bool.self, forKey: .grayFake) ?? defaults.grayFake + hideMarketClosedGaps = try container.decodeIfPresent(Bool.self, forKey: .hideMarketClosedGaps) ?? defaults.hideMarketClosedGaps + enableAutoRefresh = try container.decodeIfPresent(Bool.self, forKey: .enableAutoRefresh) ?? defaults.enableAutoRefresh + refreshSeconds = try container.decodeIfPresent(Int.self, forKey: .refreshSeconds) ?? defaults.refreshSeconds + } + + func normalized() -> MobileWebPreferences { + let safeSymbol = { + let candidate = symbol.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() + return candidate.isEmpty ? "AAPL" : candidate + }() + + let safeInterval = MobileUserSetupPreferences.timeframeOptions.contains(interval) ? interval : "1d" + let safePeriod = MobileUserSetupPreferences.periodOptions.contains(period) ? period : "6mo" + let safeMaxBars = min(5000, max(20, maxBars)) + let safeVolumeSMAWindow = min(100, max(2, volumeSMAWindow)) + let clampedMultiplier = min(3.0, max(0.1, volumeMultiplier)) + let safeVolumeMultiplier = (clampedMultiplier * 10).rounded() / 10 + let safeRefreshSeconds = min(600, max(10, refreshSeconds)) + + return MobileWebPreferences( + symbol: safeSymbol, + interval: safeInterval, + period: safePeriod, + maxBars: safeMaxBars, + dropLive: dropLive, + useBodyRange: useBodyRange, + volumeFilterEnabled: volumeFilterEnabled, + volumeSMAWindow: safeVolumeSMAWindow, + volumeMultiplier: safeVolumeMultiplier, + grayFake: grayFake, + hideMarketClosedGaps: hideMarketClosedGaps, + enableAutoRefresh: enableAutoRefresh, + refreshSeconds: safeRefreshSeconds + ) + } + + var setupDefaults: MobileUserSetupPreferences { + MobileUserSetupPreferences( + symbol: symbol, + timeframe: interval, + period: period, + maxBars: maxBars + ).normalized() + } + + func applyingSetup(_ setup: MobileUserSetupPreferences) -> MobileWebPreferences { + let normalizedSetup = setup.normalized() + return MobileWebPreferences( + symbol: normalizedSetup.symbol, + interval: normalizedSetup.timeframe, + period: normalizedSetup.period, + maxBars: normalizedSetup.maxBars, + dropLive: dropLive, + useBodyRange: useBodyRange, + volumeFilterEnabled: volumeFilterEnabled, + volumeSMAWindow: volumeSMAWindow, + volumeMultiplier: volumeMultiplier, + grayFake: grayFake, + hideMarketClosedGaps: hideMarketClosedGaps, + enableAutoRefresh: enableAutoRefresh, + refreshSeconds: refreshSeconds + ) + } + + var queryItems: [URLQueryItem] { + [ + URLQueryItem(name: "symbol", value: symbol), + URLQueryItem(name: "interval", value: interval), + URLQueryItem(name: "period", value: period), + URLQueryItem(name: "max_bars", value: String(maxBars)), + URLQueryItem(name: "drop_live", value: String(dropLive)), + URLQueryItem(name: "use_body_range", value: String(useBodyRange)), + URLQueryItem(name: "volume_filter_enabled", value: String(volumeFilterEnabled)), + URLQueryItem(name: "volume_sma_window", value: String(volumeSMAWindow)), + URLQueryItem(name: "volume_multiplier", value: String(volumeMultiplier)), + URLQueryItem(name: "gray_fake", value: String(grayFake)), + URLQueryItem(name: "hide_market_closed_gaps", value: String(hideMarketClosedGaps)), + URLQueryItem(name: "enable_auto_refresh", value: String(enableAutoRefresh)), + URLQueryItem(name: "refresh_sec", value: String(refreshSeconds)), + ] + } +} + +#Preview { + MobileContentView() +} diff --git a/mac/src/MacShell.xcodeproj/project.pbxproj b/mac/src/MacShell.xcodeproj/project.pbxproj index 045f0a6..16d097e 100644 --- a/mac/src/MacShell.xcodeproj/project.pbxproj +++ b/mac/src/MacShell.xcodeproj/project.pbxproj @@ -25,6 +25,7 @@ /* Begin PBXFileReference section */ EAB6606C2F3FD5C000ED41BA /* ManeshTraderMac.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ManeshTraderMac.app; sourceTree = BUILT_PRODUCTS_DIR; }; + EAB661002F3FD5C100ED41BA /* ManeshTraderMobile.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ManeshTraderMobile.app; sourceTree = BUILT_PRODUCTS_DIR; }; EAB660792F3FD5C100ED41BA /* ManeshTraderMacTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ManeshTraderMacTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; EAB660832F3FD5C100ED41BA /* ManeshTraderMacUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ManeshTraderMacUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ @@ -35,6 +36,11 @@ path = App; sourceTree = ""; }; + EAB661012F3FD5C100ED41BA /* AppMobile */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = AppMobile; + sourceTree = ""; + }; EAB6607C2F3FD5C100ED41BA /* AppTests */ = { isa = PBXFileSystemSynchronizedRootGroup; path = AppTests; @@ -55,6 +61,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + EAB661032F3FD5C100ED41BA /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; EAB660762F3FD5C100ED41BA /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -76,6 +89,7 @@ isa = PBXGroup; children = ( EAB6606E2F3FD5C000ED41BA /* App */, + EAB661012F3FD5C100ED41BA /* AppMobile */, EAB6607C2F3FD5C100ED41BA /* AppTests */, EAB660862F3FD5C100ED41BA /* AppUITests */, EAB6606D2F3FD5C000ED41BA /* Products */, @@ -86,6 +100,7 @@ isa = PBXGroup; children = ( EAB6606C2F3FD5C000ED41BA /* ManeshTraderMac.app */, + EAB661002F3FD5C100ED41BA /* ManeshTraderMobile.app */, EAB660792F3FD5C100ED41BA /* ManeshTraderMacTests.xctest */, EAB660832F3FD5C100ED41BA /* ManeshTraderMacUITests.xctest */, ); @@ -117,6 +132,28 @@ productReference = EAB6606C2F3FD5C000ED41BA /* ManeshTraderMac.app */; productType = "com.apple.product-type.application"; }; + EAB661022F3FD5C100ED41BA /* ManeshTraderMobile */ = { + isa = PBXNativeTarget; + buildConfigurationList = EAB661082F3FD5C100ED41BA /* Build configuration list for PBXNativeTarget "ManeshTraderMobile" */; + buildPhases = ( + EAB661052F3FD5C100ED41BA /* Sources */, + EAB661032F3FD5C100ED41BA /* Frameworks */, + EAB661042F3FD5C100ED41BA /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + EAB661012F3FD5C100ED41BA /* AppMobile */, + ); + name = ManeshTraderMobile; + packageProductDependencies = ( + ); + productName = ManeshTraderMobile; + productReference = EAB661002F3FD5C100ED41BA /* ManeshTraderMobile.app */; + productType = "com.apple.product-type.application"; + }; EAB660782F3FD5C100ED41BA /* ManeshTraderMacTests */ = { isa = PBXNativeTarget; buildConfigurationList = EAB660902F3FD5C100ED41BA /* Build configuration list for PBXNativeTarget "ManeshTraderMacTests" */; @@ -176,6 +213,9 @@ EAB6606B2F3FD5C000ED41BA = { CreatedOnToolsVersion = 26.3; }; + EAB661022F3FD5C100ED41BA = { + CreatedOnToolsVersion = 26.3; + }; EAB660782F3FD5C100ED41BA = { CreatedOnToolsVersion = 26.3; TestTargetID = EAB6606B2F3FD5C000ED41BA; @@ -201,6 +241,7 @@ projectRoot = ""; targets = ( EAB6606B2F3FD5C000ED41BA /* ManeshTraderMac */, + EAB661022F3FD5C100ED41BA /* ManeshTraderMobile */, EAB660782F3FD5C100ED41BA /* ManeshTraderMacTests */, EAB660822F3FD5C100ED41BA /* ManeshTraderMacUITests */, ); @@ -215,6 +256,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + EAB661042F3FD5C100ED41BA /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; EAB660772F3FD5C100ED41BA /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -239,6 +287,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + EAB661052F3FD5C100ED41BA /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; EAB660752F3FD5C100ED41BA /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -457,6 +512,64 @@ }; name = Release; }; + EAB661062F3FD5C100ED41BA /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 6R7KLBPBLZ; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.mbrucedogs.ManeshTraderMobile; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + EAB661072F3FD5C100ED41BA /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 6R7KLBPBLZ; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.mbrucedogs.ManeshTraderMobile; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; EAB660912F3FD5C100ED41BA /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -558,6 +671,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + EAB661082F3FD5C100ED41BA /* Build configuration list for PBXNativeTarget "ManeshTraderMobile" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + EAB661062F3FD5C100ED41BA /* Debug */, + EAB661072F3FD5C100ED41BA /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; EAB660902F3FD5C100ED41BA /* Build configuration list for PBXNativeTarget "ManeshTraderMacTests" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/mac/src/MacShell.xcodeproj/xcshareddata/xcschemes/ManeshTraderMobile.xcscheme b/mac/src/MacShell.xcodeproj/xcshareddata/xcschemes/ManeshTraderMobile.xcscheme new file mode 100644 index 0000000..6f4fa4a --- /dev/null +++ b/mac/src/MacShell.xcodeproj/xcshareddata/xcschemes/ManeshTraderMobile.xcscheme @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/mac/src/README.md b/mac/src/README.md index 3ea9bd7..e2cf7b0 100644 --- a/mac/src/README.md +++ b/mac/src/README.md @@ -27,6 +27,12 @@ From repo root: 3. Build/Run scheme: - default: project name (or set `MAC_SCHEME` in scripts) +## iOS/iPadOS Target +- New target: `ManeshTraderMobile` (same Xcode project: `mac/src/MacShell.xcodeproj`). +- The mobile target is a web wrapper and does **not** launch the embedded backend executable. +- In the app, open `Setup` and provide a backend URL reachable from the device/simulator (for example `http://:8501` or an HTTPS host). +- The same symbol/timeframe/period preferences are passed as query params to the backend URL. + ## Notes - Web source of truth is `web/src/` (`web/src/app.py`, `web/src/web_core/`). - Embedded backend binary is copied into the first `EmbeddedBackend/` folder discovered under the selected project directory.