295 lines
11 KiB
Swift
295 lines
11 KiB
Swift
import Shared
|
|
import SwiftUI
|
|
|
|
struct ContentView: View {
|
|
@StateObject private var viewModel = ConverterViewModel()
|
|
@State private var showCurrencyPicker = false
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
Group {
|
|
if let state = viewModel.state {
|
|
form(for: state)
|
|
} else {
|
|
ProgressView()
|
|
}
|
|
}
|
|
.navigationTitle("SatsPrice")
|
|
#if os(iOS)
|
|
// macOS Lists support drag-to-reorder directly; iOS only shows reorder handles
|
|
// once edit mode is active, which this toggles.
|
|
.toolbar {
|
|
ToolbarItem(placement: .navigationBarTrailing) {
|
|
EditButton()
|
|
}
|
|
}
|
|
#endif
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func form(for state: IosConverterState) -> some View {
|
|
Form {
|
|
Section(
|
|
header: Text(IosLocalizationKt.localizedFormattedString(
|
|
resource: MR.strings.shared.btc_to_currency,
|
|
args: [state.defaultCurrencyCode]
|
|
)),
|
|
footer: Text(state.statusLine)
|
|
) {
|
|
Picker(
|
|
IosLocalizationKt.localizedString(resource: MR.strings.shared.price_source),
|
|
selection: Binding(
|
|
get: { state.sourceName },
|
|
set: { viewModel.onSourceSelected($0) }
|
|
)
|
|
) {
|
|
ForEach(viewModel.sourceNames, id: \.self) { name in
|
|
Text(name).tag(name)
|
|
}
|
|
}
|
|
|
|
HStack {
|
|
if state.isManualSource {
|
|
NumericField(
|
|
placeholder: IosLocalizationKt.localizedString(resource: MR.strings.shared.rate_label),
|
|
value: state.manualRateInput,
|
|
keyboardType: .decimalPad,
|
|
sanitize: sanitizeDecimalInput,
|
|
onChange: { viewModel.onManualRateChanged($0) }
|
|
)
|
|
} else if !state.defaultCurrencyRate.isEmpty {
|
|
Text(state.defaultCurrencyRate)
|
|
Spacer()
|
|
} else {
|
|
Spacer()
|
|
}
|
|
if state.isLoading {
|
|
ProgressView()
|
|
} else {
|
|
Button {
|
|
viewModel.refresh()
|
|
} label: {
|
|
Image(systemName: "arrow.clockwise")
|
|
}
|
|
.buttonStyle(.borderless)
|
|
}
|
|
}
|
|
}
|
|
|
|
if let error = state.errorMessage {
|
|
Section {
|
|
HStack {
|
|
Text(error).foregroundColor(.red)
|
|
Spacer()
|
|
Button(IosLocalizationKt.localizedString(resource: MR.strings.shared.retry)) { viewModel.refresh() }
|
|
}
|
|
}
|
|
}
|
|
|
|
Section(IosLocalizationKt.localizedString(resource: MR.strings.shared.bitcoin_section_title)) {
|
|
amountRow(
|
|
label: IosLocalizationKt.localizedString(resource: MR.strings.shared.sats_label),
|
|
value: state.satsAmount,
|
|
keyboardType: .numberPad,
|
|
sanitize: sanitizeIntegerInput,
|
|
onChange: { viewModel.onSatsAmountChanged($0) }
|
|
)
|
|
amountRow(
|
|
label: IosLocalizationKt.localizedString(resource: MR.strings.shared.btc_label),
|
|
value: state.btcAmount,
|
|
keyboardType: .decimalPad,
|
|
sanitize: sanitizeDecimalInput,
|
|
onChange: { viewModel.onBtcAmountChanged($0) }
|
|
)
|
|
if state.exceedsMaxSupply {
|
|
Text(IosLocalizationKt.localizedString(resource: MR.strings.shared.exceeds_max_supply))
|
|
.font(.caption)
|
|
.foregroundColor(.red)
|
|
}
|
|
}
|
|
|
|
Section(IosLocalizationKt.localizedString(resource: MR.strings.shared.currencies_section_title)) {
|
|
Button(
|
|
state.selectedCurrencyCodes.count <= 1
|
|
? IosLocalizationKt.localizedString(resource: MR.strings.shared.add_currency)
|
|
: IosLocalizationKt.localizedFormattedString(
|
|
resource: MR.strings.shared.currencies_selected_count,
|
|
args: [state.selectedCurrencyCodes.count]
|
|
)
|
|
) {
|
|
showCurrencyPicker = true
|
|
}
|
|
|
|
ForEach(Array(state.fiatRows.enumerated()), id: \.element.code) { index, row in
|
|
amountRow(
|
|
label: row.code,
|
|
value: row.amount,
|
|
keyboardType: .decimalPad,
|
|
sanitize: sanitizeDecimalInput,
|
|
onChange: { viewModel.onFiatAmountChanged(code: row.code, value: $0) },
|
|
onMoveUp: index > 0 ? {
|
|
var codes = state.fiatRows.map(\.code)
|
|
codes.move(fromOffsets: [index], toOffset: index - 1)
|
|
viewModel.onFiatCurrenciesReordered(codes)
|
|
} : nil,
|
|
onMoveDown: index < state.fiatRows.count - 1 ? {
|
|
var codes = state.fiatRows.map(\.code)
|
|
codes.move(fromOffsets: [index], toOffset: index + 2)
|
|
viewModel.onFiatCurrenciesReordered(codes)
|
|
} : nil
|
|
)
|
|
.deleteDisabled(row.code == state.defaultCurrencyCode)
|
|
}
|
|
.onMove { indices, newOffset in
|
|
var codes = state.fiatRows.map(\.code)
|
|
codes.move(fromOffsets: indices, toOffset: newOffset)
|
|
viewModel.onFiatCurrenciesReordered(codes)
|
|
}
|
|
#if os(iOS)
|
|
.onDelete { indexSet in
|
|
for index in indexSet {
|
|
viewModel.onFiatCurrencyToggled(state.fiatRows[index].code)
|
|
}
|
|
}
|
|
#endif
|
|
}
|
|
}
|
|
#if os(macOS)
|
|
.formStyle(.grouped)
|
|
#endif
|
|
.sheet(isPresented: $showCurrencyPicker) {
|
|
CurrencyPickerSheet(
|
|
currentCurrency: state.currentCurrency,
|
|
selectedOtherCurrencies: state.selectedOtherCurrencies,
|
|
unselectedCurrencies: state.unselectedCurrencies,
|
|
localeCurrencyCode: state.localeCurrencyCode,
|
|
onToggle: { viewModel.onFiatCurrencyToggled($0) }
|
|
)
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func amountRow(
|
|
label: String,
|
|
value: String,
|
|
keyboardType: NumericFieldKeyboard,
|
|
sanitize: @escaping (String) -> String,
|
|
onChange: @escaping (String) -> Void,
|
|
onMoveUp: (() -> Void)? = nil,
|
|
onMoveDown: (() -> Void)? = nil
|
|
) -> some View {
|
|
HStack {
|
|
#if os(macOS)
|
|
// macOS's Form isn't List-backed, so .onMove's drag-to-reorder has no effect here;
|
|
// these buttons are macOS's equivalent of iOS's Edit-mode drag handles.
|
|
if onMoveUp != nil || onMoveDown != nil {
|
|
VStack(spacing: 2) {
|
|
Button(action: { onMoveUp?() }) {
|
|
Image(systemName: "chevron.up")
|
|
.frame(width: 16, height: 10)
|
|
}
|
|
.disabled(onMoveUp == nil)
|
|
Button(action: { onMoveDown?() }) {
|
|
Image(systemName: "chevron.down")
|
|
.frame(width: 16, height: 10)
|
|
}
|
|
.disabled(onMoveDown == nil)
|
|
}
|
|
.font(.system(size: 10))
|
|
.buttonStyle(.borderless)
|
|
}
|
|
#endif
|
|
Text(label)
|
|
Spacer()
|
|
NumericField(
|
|
placeholder: "",
|
|
value: value,
|
|
keyboardType: keyboardType,
|
|
sanitize: sanitize,
|
|
onChange: onChange,
|
|
alignment: .trailing
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A text field whose displayed text is always forced back to [sanitize]'s output, even for
|
|
/// characters typed via a hardware keyboard or pasted in — `.keyboardType` alone only restricts
|
|
/// the on-screen keyboard's layout, and a `TextField` bound purely to external/async state
|
|
/// (like the shared Kotlin `PriceViewModel`'s `StateFlow`) doesn't reliably self-correct what a
|
|
/// user has already typed into its own internal editing buffer. Local `@State` does, since
|
|
/// SwiftUI re-renders a view synchronously whenever its own `@State` changes.
|
|
private struct NumericField: View {
|
|
let placeholder: String
|
|
let value: String
|
|
let keyboardType: NumericFieldKeyboard
|
|
let sanitize: (String) -> String
|
|
let onChange: (String) -> Void
|
|
var alignment: TextAlignment = .leading
|
|
|
|
@State private var text: String = ""
|
|
|
|
var body: some View {
|
|
TextField(placeholder, text: $text)
|
|
#if os(iOS)
|
|
.keyboardType(keyboardType.uiKeyboardType)
|
|
#endif
|
|
.multilineTextAlignment(alignment)
|
|
#if os(macOS)
|
|
.frame(maxWidth: 140)
|
|
#endif
|
|
.onAppear { text = value }
|
|
.onChange(of: text) { newValue in
|
|
let sanitized = sanitize(newValue)
|
|
if sanitized != newValue {
|
|
text = sanitized
|
|
}
|
|
if sanitized != value {
|
|
onChange(sanitized)
|
|
}
|
|
}
|
|
.onChange(of: value) { newValue in
|
|
if newValue != text {
|
|
text = newValue
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func sanitizeDecimalInput(_ raw: String) -> String {
|
|
var result = ""
|
|
var seenDot = false
|
|
for char in raw {
|
|
if char.isNumber {
|
|
result.append(char)
|
|
} else if char == "." && !seenDot {
|
|
result.append(char)
|
|
seenDot = true
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func sanitizeIntegerInput(_ raw: String) -> String {
|
|
raw.filter { $0.isNumber }
|
|
}
|
|
|
|
/// `UIKeyboardType` doesn't exist on macOS (no on-screen keyboard), so this stands in for it
|
|
/// across both platforms; only iOS actually applies it, via `uiKeyboardType` below.
|
|
private enum NumericFieldKeyboard {
|
|
case decimalPad
|
|
case numberPad
|
|
}
|
|
|
|
#if os(iOS)
|
|
private extension NumericFieldKeyboard {
|
|
var uiKeyboardType: UIKeyboardType {
|
|
switch self {
|
|
case .decimalPad: return .decimalPad
|
|
case .numberPad: return .numberPad
|
|
}
|
|
}
|
|
}
|
|
#endif
|