+ Wohnsitz- und Nationalitäts-Filter für den RealUnit-Aktientoken, direkt
+ aus GET /v1/country. Die JSON-Felder sind
+ realunit.residence und realunit.nationality
+ (plus nullable Existing/IP/Tax unter demselben realunit-Objekt);
+ auf der country-Tabelle heissen die Spalten
+ realunitResidence / realunitNationality.
+ Die Tabelle kopiert keine Zeilen ins Repo. Onboarding prüft nur Residence
+ und Nationality; IP, Steueransässigkeit und Grandfathering bleiben leer,
+ bis Legal sie füllt.
+
+
+
+
+
+
+
+
Lade GET /v1/country …
+
+
+ API ohne realunit-Objekt. Die Geo-Spalten bleiben
+ leer, bis DFXswiss/backend mit dem Country-DTO auf dieser Umgebung
+ deployed ist.
+
+
+
+
+
+
+
ISO
+
Land
+
Wohnsitz
+
Nationalität
+
Wohnsitz Bestand
+
Nationalität Bestand
+
IP neu
+
IP Bestand
+
Steuer neu
+
Steuer Bestand
+
+
+
+
+
+
+
+
+
+
diff --git a/handbook.nginx.conf b/handbook.nginx.conf
index 0f9fde6ed..89619c236 100644
--- a/handbook.nginx.conf
+++ b/handbook.nginx.conf
@@ -64,6 +64,27 @@ server {
# here; otherwise defining add_header inside the block would silently
# drop them. Cache-Control is "no-store" so a healthcheck answer is
# never cached by intermediaries while the container is actually down.
+ # Public country list used by the live geo-filter table. Same payload as
+ # api.dfx.swiss/v1/country; kept behind Basic Auth with the rest of the
+ # handbook. Variable proxy_pass needs a resolver so nginx looks the
+ # host up at request time (the image has no baked IP).
+ resolver 1.1.1.1 ipv6=off valid=300s;
+ location = /v1/country {
+ set $dfx_country_upstream https://api.dfx.swiss/v1/country;
+ proxy_pass $dfx_country_upstream;
+ proxy_ssl_server_name on;
+ proxy_ssl_name api.dfx.swiss;
+ proxy_set_header Host api.dfx.swiss;
+ # Country list is public. Do not forward handbook Basic Auth to the API.
+ proxy_set_header Authorization "";
+ proxy_connect_timeout 10s;
+ proxy_read_timeout 20s;
+ add_header X-Content-Type-Options "nosniff" always;
+ add_header X-Frame-Options "SAMEORIGIN" always;
+ add_header Referrer-Policy "strict-origin-when-cross-origin" always;
+ add_header Cache-Control "private, no-store" always;
+ }
+
location = /healthz {
auth_basic off;
add_header X-Content-Type-Options "nosniff" always;
From f491ac0a824aacc5362dcc467e82b7d3bc7fcd35 Mon Sep 17 00:00:00 2001
From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com>
Date: Sat, 22 Aug 2026 21:35:27 +0200
Subject: [PATCH 03/25] 01a02915 - Geo-filter DE/EN names and downloads (#932)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
EN:
The geo-filter table now shows German and English country names.
CSV, Excel, and PDF downloads export the currently visible rows.
Native-script API foreignName is no longer used.
DE:
Die Geo-Filter-Tabelle zeigt Ländernamen auf Deutsch und Englisch.
CSV, Excel und PDF laden die aktuell angezeigten Zeilen herunter.
Die native foreignName der API wird nicht mehr verwendet.
Details
Names come from `Intl.DisplayNames` (ISO 3166-1) so
Arabic/Japanese/Chinese script in `foreignName` is not shown. Downloads
are generated in the browser from the live `GET /v1/country` payload; no
country rows are copied into the repo.
---
docs/handbook/README.md | 8 +-
docs/handbook/de/geo-filter.js | 287 +++++++++++++++++++++++++++++++--
docs/handbook/de/index.html | 31 +++-
3 files changed, 305 insertions(+), 21 deletions(-)
diff --git a/docs/handbook/README.md b/docs/handbook/README.md
index 2fa5ab8d3..18bcaba94 100644
--- a/docs/handbook/README.md
+++ b/docs/handbook/README.md
@@ -67,9 +67,11 @@ mehr aus diesen Maestro-Läufen.)
## Live Geo-Filter-Tabelle
Die Sektion **Aktientoken — Geo-Filter** (`#spec-geo`) lädt `GET /v1/country`
-zur Laufzeit. Länderzeilen gehören nicht ins Repo. Im Image proxied nginx
-`/v1/country` auf `https://api.dfx.swiss/v1/country`; eine lokale HTML-Vorschau
-fällt auf die öffentliche API zurück.
+zur Laufzeit. Länderzeilen gehören nicht ins Repo. Namen kommen als Deutsch und
+Englisch aus `Intl.DisplayNames` (ISO 3166), nicht aus `foreignName`. CSV, Excel
+und PDF exportieren die angezeigte Liste. Im Image proxied nginx `/v1/country`
+auf `https://api.dfx.swiss/v1/country`; eine lokale HTML-Vorschau fällt auf die
+öffentliche API zurück.
## Einen neuen Handbook-Eintrag hinzufügen
diff --git a/docs/handbook/de/geo-filter.js b/docs/handbook/de/geo-filter.js
index 0dede72de..a96223bd5 100644
--- a/docs/handbook/de/geo-filter.js
+++ b/docs/handbook/de/geo-filter.js
@@ -1,15 +1,16 @@
/**
* Live RealUnit share-token geo-filter table.
*
- * Source of truth is GET /v1/country (same payload the wallet uses). Rows are
- * never copied into this repo. Same-origin `/v1/country` is the handbook
- * nginx proxy; the public API is the fallback for a local file preview.
+ * Source of truth is GET /v1/country. Rows are never copied into this repo.
+ * Country labels are German and English via Intl.DisplayNames (ISO 3166-1),
+ * not API foreignName (often native script).
*/
(function () {
var URLS = ['/v1/country', 'https://api.dfx.swiss/v1/country'];
var COLUMNS = [
{ key: 'symbol', label: 'ISO' },
- { key: 'name', label: 'Land' },
+ { key: 'nameDe', label: 'Land (DE)' },
+ { key: 'nameEn', label: 'Country (EN)' },
{ key: 'residence', label: 'Wohnsitz' },
{ key: 'nationality', label: 'Nationalität' },
{ key: 'residenceExisting', label: 'Wohnsitz Bestand' },
@@ -20,10 +21,37 @@
{ key: 'taxExisting', label: 'Steuer Bestand' },
];
+ var displayDe;
+ var displayEn;
+ try {
+ displayDe = new Intl.DisplayNames(['de'], { type: 'region', fallback: 'none' });
+ displayEn = new Intl.DisplayNames(['en'], { type: 'region', fallback: 'none' });
+ } catch (e) {
+ try {
+ displayDe = new Intl.DisplayNames(['de'], { type: 'region' });
+ displayEn = new Intl.DisplayNames(['en'], { type: 'region' });
+ } catch (e2) {
+ displayDe = null;
+ displayEn = null;
+ }
+ }
+
function $(id) {
return document.getElementById(id);
}
+ function regionName(display, symbol, fallback) {
+ if (display && symbol) {
+ try {
+ var label = display.of(symbol);
+ if (label && String(label).toUpperCase() !== String(symbol).toUpperCase()) {
+ return label;
+ }
+ } catch (e) {}
+ }
+ return fallback || symbol || '';
+ }
+
function cell(value) {
if (value === true) return 'ja';
if (value === false) return 'nein';
@@ -52,9 +80,11 @@
function rowView(country) {
var geo = country.realunit || {};
+ var english = country.name || country.symbol;
return {
symbol: country.symbol,
- name: country.foreignName || country.name,
+ nameDe: regionName(displayDe, country.symbol, english),
+ nameEn: regionName(displayEn, country.symbol, english),
residence: geo.residence,
nationality: geo.nationality,
residenceExisting: geo.residenceExisting,
@@ -80,26 +110,237 @@
});
}
- function render(state) {
- var tbody = $('geo-filter-body');
- var status = $('geo-filter-status');
- var missing = $('geo-filter-missing');
- if (!tbody) return;
-
+ function filteredRows(state) {
var q = ($('geo-filter-search') || {}).value || '';
q = q.trim().toLowerCase();
var residence = ($('geo-filter-residence') || {}).value || '';
var nationality = ($('geo-filter-nationality') || {}).value || '';
-
- var rows = state.rows.filter(function (row) {
- if (q && (row.symbol || '').toLowerCase().indexOf(q) < 0 && (row.name || '').toLowerCase().indexOf(q) < 0) {
+ return state.rows.filter(function (row) {
+ if (
+ q &&
+ (row.symbol || '').toLowerCase().indexOf(q) < 0 &&
+ (row.nameDe || '').toLowerCase().indexOf(q) < 0 &&
+ (row.nameEn || '').toLowerCase().indexOf(q) < 0
+ ) {
return false;
}
if (residence && row.residence !== residence) return false;
if (nationality && row.nationality !== nationality) return false;
return true;
});
+ }
+
+ function stamp() {
+ var d = new Date();
+ var p = function (n) {
+ return n < 10 ? '0' + n : String(n);
+ };
+ return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate());
+ }
+
+ function fileBase() {
+ return 'realunit-geo-filter-' + stamp();
+ }
+
+ function saveBlob(blob, filename) {
+ var url = URL.createObjectURL(blob);
+ var a = document.createElement('a');
+ a.href = url;
+ a.download = filename;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ setTimeout(function () {
+ URL.revokeObjectURL(url);
+ }, 1000);
+ }
+
+ function xmlEscape(value) {
+ return String(value)
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"');
+ }
+
+ function csvField(value) {
+ var s = cell(value);
+ if (/[;"\n\r]/.test(s)) return '"' + s.replace(/"/g, '""') + '"';
+ return s;
+ }
+
+ function downloadCsv(rows) {
+ var lines = [COLUMNS.map(function (c) { return csvField(c.label); }).join(';')];
+ rows.forEach(function (row) {
+ lines.push(COLUMNS.map(function (c) { return csvField(row[c.key]); }).join(';'));
+ });
+ var blob = new Blob(['\uFEFF' + lines.join('\r\n')], { type: 'text/csv;charset=utf-8' });
+ saveBlob(blob, fileBase() + '.csv');
+ }
+ function downloadExcel(rows) {
+ var xml = '\r\n';
+ xml += '\r\n';
+ xml += '\r\n';
+ xml += '
';
+ var blob = new Blob([xml], { type: 'application/vnd.ms-excel' });
+ saveBlob(blob, fileBase() + '.xls');
+ }
+
+ var WINANSI = {
+ 0x20ac: 128,
+ 0x201a: 130,
+ 0x0192: 131,
+ 0x201e: 132,
+ 0x2026: 133,
+ 0x2020: 134,
+ 0x2021: 135,
+ 0x02c6: 136,
+ 0x2030: 137,
+ 0x0160: 138,
+ 0x2039: 139,
+ 0x0152: 140,
+ 0x017d: 142,
+ 0x2018: 145,
+ 0x2019: 146,
+ 0x201c: 147,
+ 0x201d: 148,
+ 0x2022: 149,
+ 0x2013: 150,
+ 0x2014: 151,
+ 0x02dc: 152,
+ 0x2122: 153,
+ 0x0161: 154,
+ 0x203a: 155,
+ 0x0153: 156,
+ 0x017e: 158,
+ 0x0178: 159,
+ };
+
+ function pdfEscape(text) {
+ var s = '';
+ var raw = String(text);
+ for (var i = 0; i < raw.length; i++) {
+ var code = raw.charCodeAt(i);
+ if (WINANSI[code] != null) code = WINANSI[code];
+ if (code === 92) s += '\\\\';
+ else if (code === 40) s += '\\(';
+ else if (code === 41) s += '\\)';
+ else if (code === 13 || code === 10) s += ' ';
+ else if (code >= 32 && code < 128) s += String.fromCharCode(code);
+ else if (code >= 128 && code <= 255) s += '\\' + ('00' + code.toString(8)).slice(-3);
+ else s += '?';
+ }
+ return s;
+ }
+
+ function downloadPdf(rows) {
+ var pageW = 842;
+ var pageH = 595;
+ var margin = 32;
+ var fontSize = 8;
+ var lineH = 11;
+ var headerH = 36;
+ var usable = pageH - margin * 2 - headerH;
+ var rowsPerPage = Math.max(1, Math.floor(usable / lineH) - 1);
+ var pages = [];
+ for (var i = 0; i < rows.length || (rows.length === 0 && pages.length === 0); i += rowsPerPage) {
+ pages.push(rows.slice(i, i + rowsPerPage));
+ if (rows.length === 0) break;
+ }
+ var colX = [margin, 62, 168, 274, 348, 422, 496, 546, 596, 646, 700];
+
+ function pageStream(pageRows, pageIndex, pageCount) {
+ var y = pageH - margin - 14;
+ var out = 'BT\n/F1 11 Tf\n';
+ out += margin + ' ' + y + ' Td\n(' + pdfEscape('RealUnit Aktientoken — Geo-Filter') + ') Tj\n';
+ out += '/F1 8 Tf\n0 -12 Td\n(' + pdfEscape(fileBase() + ' · Seite ' + (pageIndex + 1) + '/' + pageCount) + ') Tj\n';
+ y -= headerH;
+ out += '/F1 8 Tf\n';
+ COLUMNS.forEach(function (col, idx) {
+ out += '1 0 0 1 ' + colX[idx] + ' ' + y + ' Tm\n(' + pdfEscape(col.label) + ') Tj\n';
+ });
+ y -= lineH;
+ pageRows.forEach(function (row) {
+ COLUMNS.forEach(function (col, idx) {
+ var value = cell(row[col.key]);
+ if (value.length > 22 && (col.key === 'nameDe' || col.key === 'nameEn')) value = value.slice(0, 21) + '...';
+ out += '1 0 0 1 ' + colX[idx] + ' ' + y + ' Tm\n(' + pdfEscape(value) + ') Tj\n';
+ });
+ y -= lineH;
+ });
+ out += 'ET\n';
+ return out;
+ }
+
+ var objects = [];
+ objects.push('<< /Type /Catalog /Pages 2 0 R >>');
+ var pageIds = [];
+ var contentIds = [];
+ var fontId;
+ var startId = 3;
+ for (var p = 0; p < pages.length; p++) {
+ pageIds.push(startId + p);
+ contentIds.push(startId + pages.length + p);
+ }
+ fontId = startId + pages.length * 2;
+ var kids = pageIds.map(function (id) { return id + ' 0 R'; }).join(' ');
+ objects.push('<< /Type /Pages /Kids [' + kids + '] /Count ' + pages.length + ' >>');
+
+ var contents = [];
+ pages.forEach(function (pageRows, idx) {
+ objects.push(
+ '<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ' +
+ pageW +
+ ' ' +
+ pageH +
+ '] /Contents ' +
+ contentIds[idx] +
+ ' 0 R /Resources << /Font << /F1 ' +
+ fontId +
+ ' 0 R >> >> >>'
+ );
+ contents[idx] = pageStream(pageRows, idx, pages.length);
+ });
+ contents.forEach(function (stream) {
+ objects.push('<< /Length ' + stream.length + ' >>\nstream\n' + stream + 'endstream');
+ });
+ objects.push('<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>');
+
+ var xref = [0];
+ var pdf = '%PDF-1.4\n';
+ objects.forEach(function (body, idx) {
+ xref.push(pdf.length);
+ pdf += idx + 1 + ' 0 obj\n' + body + '\nendobj\n';
+ });
+ var xrefPos = pdf.length;
+ pdf += 'xref\n0 ' + (objects.length + 1) + '\n';
+ pdf += '0000000000 65535 f \n';
+ xref.slice(1).forEach(function (offset) {
+ var n = String(offset);
+ pdf += ('0000000000' + n).slice(-10) + ' 00000 n \n';
+ });
+ pdf += 'trailer\n<< /Size ' + (objects.length + 1) + ' /Root 1 0 R >>\nstartxref\n' + xrefPos + '\n%%EOF';
+ saveBlob(new Blob([pdf], { type: 'application/pdf' }), fileBase() + '.pdf');
+ }
+
+ function render(state) {
+ var tbody = $('geo-filter-body');
+ var status = $('geo-filter-status');
+ var missing = $('geo-filter-missing');
+ if (!tbody) return;
+
+ var rows = filteredRows(state);
tbody.textContent = '';
rows.forEach(function (row) {
var tr = document.createElement('tr');
@@ -119,6 +360,21 @@
if (missing) {
missing.hidden = state.hasRealunit;
}
+ state.visible = rows;
+ var ready = rows.length > 0;
+ ['geo-filter-csv', 'geo-filter-xls', 'geo-filter-pdf'].forEach(function (id) {
+ var btn = $(id);
+ if (btn) btn.disabled = !ready;
+ });
+ }
+
+ function bindDownloads(state) {
+ var csv = $('geo-filter-csv');
+ var xls = $('geo-filter-xls');
+ var pdf = $('geo-filter-pdf');
+ if (csv) csv.addEventListener('click', function () { downloadCsv(state.visible || []); });
+ if (xls) xls.addEventListener('click', function () { downloadExcel(state.visible || []); });
+ if (pdf) pdf.addEventListener('click', function () { downloadPdf(state.visible || []); });
}
function init() {
@@ -133,12 +389,13 @@
var hasRealunit = result.body.some(function (country) {
return country && country.realunit != null;
});
- var state = { rows: rows, url: result.url, hasRealunit: hasRealunit };
+ var state = { rows: rows, url: result.url, hasRealunit: hasRealunit, visible: rows };
['geo-filter-search', 'geo-filter-residence', 'geo-filter-nationality'].forEach(function (id) {
var el = $(id);
if (el) el.addEventListener('input', function () { render(state); });
if (el) el.addEventListener('change', function () { render(state); });
});
+ bindDownloads(state);
render(state);
})
.catch(function (err) {
diff --git a/docs/handbook/de/index.html b/docs/handbook/de/index.html
index 5014908e9..ec2c13c95 100644
--- a/docs/handbook/de/index.html
+++ b/docs/handbook/de/index.html
@@ -656,7 +656,8 @@
font-size: 13px;
color: var(--ink-2);
}
- a.dl-btn {
+ a.dl-btn,
+ button.dl-btn {
text-decoration: none;
font-size: 12.5px;
font-weight: 600;
@@ -670,7 +671,8 @@
border-color 0.15s,
color 0.15s;
}
- a.dl-btn:hover {
+ a.dl-btn:hover,
+ button.dl-btn:hover:not(:disabled) {
background: var(--surface);
border-color: var(--brand);
}
@@ -744,6 +746,20 @@
min-width: 180px;
flex: 1;
}
+ #geo-filter-live .geo-downloads {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ margin: 0 0 12px 0;
+ }
+ #geo-filter-live .geo-downloads button.dl-btn {
+ cursor: pointer;
+ font: inherit;
+ }
+ #geo-filter-live .geo-downloads button.dl-btn:disabled {
+ opacity: 0.45;
+ cursor: not-allowed;
+ }
#geo-filter-status {
margin: 0 0 10px 0;
font-size: 12.5px;
@@ -8150,6 +8166,9 @@
GAktientoken — Geo-Filter
(plus nullable Existing/IP/Tax unter demselben realunit-Objekt);
auf der country-Tabelle heissen die Spalten
realunitResidence / realunitNationality.
+ Ländernamen sind Deutsch und Englisch (ISO 3166 über
+ Intl.DisplayNames), nicht die native foreignName
+ der API. CSV, Excel und PDF laden die aktuell angezeigte Liste.
Die Tabelle kopiert keine Zeilen ins Repo. Onboarding prüft nur Residence
und Nationality; IP, Steueransässigkeit und Grandfathering bleiben leer,
bis Legal sie füllt.
@@ -8171,6 +8190,11 @@
GAktientoken — Geo-Filter
+
+
+
+
+
Lade GET /v1/country …
@@ -8184,7 +8208,8 @@
GAktientoken — Geo-Filter
ISO
-
Land
+
Land (DE)
+
Country (EN)
Wohnsitz
Nationalität
Wohnsitz Bestand
From 519bdbb2e1019ad3068fc1f35eb058430b151bd6 Mon Sep 17 00:00:00 2001
From: Blume1977
Date: Mon, 24 Aug 2026 13:48:24 +0200
Subject: [PATCH 04/25] =?UTF-8?q?fix(buy):=20keep=20the=20amount=20field?=
=?UTF-8?q?=20editable=20=E2=80=94=20quote=20the=20Rappen-exact=20payable?=
=?UTF-8?q?=20from=20state=20(#934)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
EN:
The buy amount field keeps the typed value instead of being overwritten
by the conversion. Quotes still use the Rappen-exact payable. Tests pin
currency-switch and quote wiring so that regression cannot return
silently.
DE:
Das Betragsfeld behält die Eingabe statt sie mit dem Umrechnungsergebnis
zu überschreiben. Quotes laufen weiter über den rappengenauen Betrag.
Tests pinnen Währungswechsel und Quote-Verdrahtung, damit die Regression
nicht still zurückkommt.
Details
Since #926 the conversion result was written back into `fiatText`, and
the BuyView listener synced it into the amount TextField after every
conversion. Editing became impossible: every keystroke or backspace was
overwritten with shares × list price 250ms later (prefill 300 → 299.46;
deleting digits snapped back to the next whole-share multiple and got
stuck). Reported by a customer via RealUnit (only the default amount
worked) and reproduced on-device.
Fix:
- Conversion results land in a new `payableText` only; `fiatText` always
keeps the user's own text (typing in the shares field still converts
into the fiat field, that path is unchanged output).
- All quote requests (initial, listener re-fetch, gate retries in
`PaymentActionButton`) use `quoteAmountText`: the Rappen-exact payable
when a conversion is live, otherwise the typed amount. The #926 goal
(quote and details in Rappen, no whole-franc rounding) stays intact.
- The details page keeps rendering the charge echoed by the quote,
unchanged.
- Tests pin: deleting digits keeps the typed text; currency switch
preserves `fiatText`; `getPaymentInfo` is called with the live payable
rather than the field text.
The exact charge is visible on the confirmation/details page. An
optional display line under the field (`payableText` is already in
state) is a product choice and is not part of this PR.
`flutter analyze` clean on the touched files; `flutter test` of the
three buy test files: 31 passed.
---------
Co-authored-by: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com>
---
lib/screens/buy/buy_page.dart | 8 +-
.../buy_converter/buy_converter_cubit.dart | 20 ++-
.../buy_converter/buy_converter_state.dart | 13 +-
.../buy/widgets/payment_action_button.dart | 50 +++----
test/screens/buy/buy_page_test.dart | 70 ++++++++-
.../buy/cubits/buy_converter_cubit_test.dart | 135 +++++++++++++-----
.../widgets/payment_action_button_test.dart | 40 ++++--
7 files changed, 239 insertions(+), 97 deletions(-)
diff --git a/lib/screens/buy/buy_page.dart b/lib/screens/buy/buy_page.dart
index 2d827bf91..634350492 100644
--- a/lib/screens/buy/buy_page.dart
+++ b/lib/screens/buy/buy_page.dart
@@ -58,8 +58,10 @@ class _BuyViewState extends State {
listener: (context, state) {
_syncController(_amountController, state.fiatText);
_syncController(_resultController, state.sharesText);
+ // The quote charges the Rappen-exact payable of the conversion,
+ // not the field text: the field keeps what the user typed.
context.read().getPaymentInfo(
- amount: _amountController.text,
+ amount: state.quoteAmountText,
currency: state.currency,
);
},
@@ -84,9 +86,7 @@ class _BuyViewState extends State {
],
),
actions: [
- PaymentActionButton(
- amountController: _amountController,
- ),
+ const PaymentActionButton(),
],
),
),
diff --git a/lib/screens/buy/cubits/buy_converter/buy_converter_cubit.dart b/lib/screens/buy/cubits/buy_converter/buy_converter_cubit.dart
index 0f574bc0c..5eac17a3f 100644
--- a/lib/screens/buy/cubits/buy_converter/buy_converter_cubit.dart
+++ b/lib/screens/buy/cubits/buy_converter/buy_converter_cubit.dart
@@ -26,9 +26,12 @@ class BuyConverterCubit extends Cubit {
// newer result (e.g. `460 → shares=321` overwriting `4600 → shares=3216`).
int _seq = 0;
- /// User changed fiat → convert to shares
+ /// User changed fiat → convert to shares. The conversion result lands in
+ /// [BuyConverterState.sharesText] and [BuyConverterState.payableText] only:
+ /// writing it back into [BuyConverterState.fiatText] would overwrite the
+ /// text field mid-edit and make typing/deleting impossible.
Future onFiatChanged(String value) async {
- emit(state.copyWith(fiatText: value));
+ emit(state.copyWith(fiatText: value, payableText: ''));
_fiatDebounce?.cancel();
final mySeq = ++_seq;
@@ -44,7 +47,7 @@ class BuyConverterCubit extends Cubit {
emit(
state.copyWith(
sharesText: result.shares.toString(),
- fiatText: payable.toStringAsFixed(2),
+ payableText: payable.toStringAsFixed(2),
loading: false,
),
);
@@ -56,9 +59,11 @@ class BuyConverterCubit extends Cubit {
});
}
- /// User changed shares → convert to fiat
+ /// User changed shares → convert to fiat. Writing the result into
+ /// [BuyConverterState.fiatText] is safe here: the user is typing in the
+ /// shares field, so the fiat field is pure conversion output.
Future onSharesChanged(String value) async {
- emit(state.copyWith(sharesText: value));
+ emit(state.copyWith(sharesText: value, payableText: ''));
_sharesDebounce?.cancel();
final mySeq = ++_seq;
@@ -72,6 +77,7 @@ class BuyConverterCubit extends Cubit {
emit(
state.copyWith(
fiatText: result.totalCost.toStringAsFixed(_fractionDigits(value)),
+ payableText: result.totalCost.toStringAsFixed(2),
loading: false,
),
);
@@ -88,7 +94,7 @@ class BuyConverterCubit extends Cubit {
// Flip currency immediately so the picker reflects the user choice
// even if an in-flight conversion arrives later and gets dropped by
// the seq guard.
- emit(state.copyWith(loading: true, currency: currency));
+ emit(state.copyWith(loading: true, currency: currency, payableText: ''));
try {
final result = await _brokerbotService.getBuyShares(state.fiatText, currency);
@@ -98,7 +104,7 @@ class BuyConverterCubit extends Cubit {
emit(
state.copyWith(
sharesText: result.shares.toString(),
- fiatText: payable.toStringAsFixed(2),
+ payableText: payable.toStringAsFixed(2),
loading: false,
),
);
diff --git a/lib/screens/buy/cubits/buy_converter/buy_converter_state.dart b/lib/screens/buy/cubits/buy_converter/buy_converter_state.dart
index 7e2cfe402..5f4e087e7 100644
--- a/lib/screens/buy/cubits/buy_converter/buy_converter_state.dart
+++ b/lib/screens/buy/cubits/buy_converter/buy_converter_state.dart
@@ -3,30 +3,41 @@ part of 'buy_converter_cubit.dart';
class BuyConverterState extends Equatable {
final String fiatText;
final String sharesText;
+ // Rappen-exact amount the live conversion charges (shares × list price in
+ // Rappen). Empty while no conversion result is live. The fiat input field
+ // must never render this — it always keeps the user's own [fiatText].
+ final String payableText;
final Currency currency;
final bool loading;
const BuyConverterState({
this.fiatText = '',
this.sharesText = '',
+ this.payableText = '',
this.currency = Currency.chf,
this.loading = false,
});
+ /// Amount a quote must be requested with: the Rappen-exact payable when a
+ /// conversion result is live, otherwise the raw typed amount.
+ String get quoteAmountText => payableText.isNotEmpty ? payableText : fiatText;
+
BuyConverterState copyWith({
String? fiatText,
String? sharesText,
+ String? payableText,
Currency? currency,
bool? loading,
}) {
return BuyConverterState(
fiatText: fiatText ?? this.fiatText,
sharesText: sharesText ?? this.sharesText,
+ payableText: payableText ?? this.payableText,
currency: currency ?? this.currency,
loading: loading ?? this.loading,
);
}
@override
- List